From 26a853184fe8ac9fb800bea20e554b42fa5f7e06 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:40:04 +0100 Subject: [PATCH] feat(api): complete M5 swap and dispatch backend slice --- .../command-api/scripts/seed-v2-demo-data.mjs | 106 ++- .../command-api/scripts/v2-demo-fixture.mjs | 43 + ...08_v2_swap_workflow_and_dispatch_teams.sql | 76 ++ .../src/contracts/commands/mobile.js | 27 + backend/command-api/src/routes/mobile.js | 47 + .../src/services/mobile-command-service.js | 894 +++++++++++++++++- .../src/services/notification-dispatcher.js | 258 ++++- .../command-api/test/mobile-routes.test.js | 80 ++ backend/query-api/src/routes/mobile.js | 33 + .../src/services/mobile-query-service.js | 360 ++++++- backend/query-api/test/mobile-routes.test.js | 33 + .../scripts/ensure-v2-demo-users.mjs | 145 +-- .../scripts/live-smoke-v2-unified.mjs | 72 +- docs/BACKEND/API_GUIDES/V2/README.md | 17 +- .../V2/mobile-frontend-implementation-spec.md | 26 +- docs/BACKEND/API_GUIDES/V2/staff-shifts.md | 11 +- docs/BACKEND/API_GUIDES/V2/unified-api.md | 49 + makefiles/backend.mk | 2 +- 18 files changed, 2170 insertions(+), 109 deletions(-) create mode 100644 backend/command-api/sql/v2/008_v2_swap_workflow_and_dispatch_teams.sql diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index 836a2382..b8875221 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -46,6 +46,8 @@ async function main() { const checkedOutAt = hoursFromNow(-20.25); const assignedStartsAt = hoursFromNow(0.1); const assignedEndsAt = hoursFromNow(8.1); + const swapEligibleStartsAt = hoursFromNow(26); + const swapEligibleEndsAt = hoursFromNow(34); const availableStartsAt = hoursFromNow(30); const availableEndsAt = hoursFromNow(38); const cancelledStartsAt = hoursFromNow(20); @@ -58,6 +60,7 @@ async function main() { await upsertUser(client, fixture.users.operationsManager); await upsertUser(client, fixture.users.vendorManager); await upsertUser(client, fixture.users.staffAna); + await upsertUser(client, fixture.users.staffBen); await client.query( ` @@ -74,7 +77,8 @@ async function main() { ($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb), ($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb), ($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb), - ($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb) + ($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb), + ($1, $6, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb) `, [ fixture.tenant.id, @@ -82,6 +86,7 @@ async function main() { fixture.users.operationsManager.id, fixture.users.vendorManager.id, fixture.users.staffAna.id, + fixture.users.staffBen.id, ] ); @@ -177,10 +182,13 @@ async function main() { id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status, average_rating, rating_count, metadata ) - VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, 'COMPLETED', 4.50, 1, $8::jsonb) + VALUES + ($1, $3, $4, $5, $6, $7, 'ACTIVE', $8, 'COMPLETED', 4.50, 1, $9::jsonb), + ($2, $3, $10, $11, $12, $13, 'ACTIVE', $14, 'COMPLETED', 4.90, 3, $15::jsonb) `, [ fixture.staff.ana.id, + fixture.staff.ben.id, fixture.tenant.id, fixture.users.staffAna.id, fixture.staff.ana.fullName, @@ -208,29 +216,63 @@ async function main() { phone: '+15550007777', }, }), + fixture.users.staffBen.id, + fixture.staff.ben.fullName, + fixture.staff.ben.email, + fixture.staff.ben.phone, + fixture.staff.ben.primaryRole, + JSON.stringify({ + favoriteCandidate: false, + seeded: true, + firstName: 'Ben', + lastName: 'Barista', + bio: 'Reliable event barista used for swap coverage and dispatch team ranking.', + preferredLocations: [ + { + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + }, + ], + maxDistanceMiles: 15, + industries: ['CATERING', 'CAFE'], + skills: ['BARISTA', 'CUSTOMER_SERVICE'], + emergencyContact: { + name: 'Noah Barista', + phone: '+15550008888', + }, + }), ] ); await client.query( ` INSERT INTO staff_roles (staff_id, role_id, is_primary) - VALUES ($1, $2, TRUE) + VALUES + ($1, $3, TRUE), + ($2, $3, TRUE) `, - [fixture.staff.ana.id, fixture.roles.barista.id] + [fixture.staff.ana.id, fixture.staff.ben.id, fixture.roles.barista.id] ); await client.query( ` INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata) - VALUES ($1, $2, $3, $4, $5, 'TEMP', 'ACTIVE', $6::jsonb) + VALUES + ($1, $3, $4, $5, $6, 'TEMP', 'ACTIVE', $7::jsonb), + ($2, $3, $4, $8, $9, 'TEMP', 'ACTIVE', $10::jsonb) `, [ fixture.workforce.ana.id, + fixture.workforce.ben.id, fixture.tenant.id, fixture.vendor.id, fixture.staff.ana.id, fixture.workforce.ana.workforceNumber, JSON.stringify({ source: 'seed-v2-demo' }), + fixture.staff.ben.id, + fixture.workforce.ben.workforceNumber, + JSON.stringify({ source: 'seed-v2-demo' }), ] ); @@ -338,6 +380,29 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO dispatch_team_memberships ( + id, tenant_id, business_id, hub_id, staff_id, team_type, source, status, reason, effective_at, created_by_user_id, metadata + ) + VALUES + ($1, $4, $5, NULL, $6, 'CORE', 'MANUAL', 'ACTIVE', 'Seeded core team member', NOW() - INTERVAL '7 days', $7, '{"seeded":true}'::jsonb), + ($2, $4, $5, $8, $9, 'CERTIFIED_LOCATION', 'MANUAL', 'ACTIVE', 'Seeded location-certified member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb), + ($3, $4, $5, NULL, $9, 'MARKETPLACE', 'SYSTEM', 'ACTIVE', 'Seeded marketplace fallback member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb) + `, + [ + fixture.dispatchTeamMemberships.anaCore.id, + fixture.dispatchTeamMemberships.benCertifiedLocation.id, + fixture.dispatchTeamMemberships.benMarketplace.id, + fixture.tenant.id, + fixture.business.id, + fixture.staff.ana.id, + fixture.users.operationsManager.id, + fixture.clockPoint.id, + fixture.staff.ben.id, + ] + ); + await client.query( ` INSERT INTO orders ( @@ -445,9 +510,10 @@ async function main() { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb), - ($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $30, $31, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb), - ($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb), - ($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 'GEO_REQUIRED', TRUE, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb) + ($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $35, $36, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb), + ($20, $2, $3, $4, $5, $6, $21, $22, 'ASSIGNED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $35, $36, 1, 1, 'Future swap-eligible shift for workflow smoke coverage', '{"slice":"swap_eligible"}'::jsonb), + ($25, $2, $3, $4, $5, $6, $26, $27, 'CANCELLED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb), + ($30, $2, $3, $4, $5, $6, $31, $32, 'COMPLETED', $33, $34, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $35, $36, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb) `, [ fixture.shifts.available.id, @@ -469,6 +535,11 @@ async function main() { fixture.shifts.assigned.title, assignedStartsAt, assignedEndsAt, + fixture.shifts.swapEligible.id, + fixture.shifts.swapEligible.code, + fixture.shifts.swapEligible.title, + swapEligibleStartsAt, + swapEligibleEndsAt, fixture.shifts.cancelled.id, fixture.shifts.cancelled.code, fixture.shifts.cancelled.title, @@ -512,19 +583,22 @@ async function main() { VALUES ($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb), ($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb), - ($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb), - ($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb) + ($5, $6, $7, $8, $9, 1, 1, 2400, 3700, '{"slice":"swap_eligible"}'::jsonb), + ($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb), + ($12, $13, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb) `, [ fixture.shiftRoles.availableBarista.id, fixture.shifts.available.id, fixture.shiftRoles.assignedBarista.id, fixture.shifts.assigned.id, - fixture.shiftRoles.cancelledBarista.id, - fixture.shifts.cancelled.id, + fixture.shiftRoles.swapEligibleBarista.id, + fixture.shifts.swapEligible.id, fixture.roles.barista.id, fixture.roles.barista.code, fixture.roles.barista.name, + fixture.shiftRoles.cancelledBarista.id, + fixture.shifts.cancelled.id, fixture.shiftRoles.noShowBarista.id, fixture.shifts.noShow.id, ] @@ -578,8 +652,9 @@ async function main() { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb), - ($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb), - ($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb) + ($9, $2, $3, $4, $10, $11, $7, $8, 'ACCEPTED', NOW(), NOW(), NULL, NULL, '{"slice":"swap_eligible"}'::jsonb), + ($12, $2, $3, $4, $13, $14, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb), + ($15, $2, $3, $4, $16, $17, $7, $8, 'NO_SHOW', $18, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb) `, [ fixture.assignments.assignedAna.id, @@ -590,6 +665,9 @@ async function main() { fixture.shiftRoles.assignedBarista.id, fixture.workforce.ana.id, fixture.staff.ana.id, + fixture.assignments.swapEligibleAna.id, + fixture.shifts.swapEligible.id, + fixture.shiftRoles.swapEligibleBarista.id, fixture.assignments.cancelledAna.id, fixture.shifts.cancelled.id, fixture.shiftRoles.cancelledBarista.id, diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 78d0a392..fbeb02a0 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -25,6 +25,11 @@ export const V2DemoFixture = { email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com', displayName: 'Ana Barista', }, + staffBen: { + id: process.env.V2_DEMO_STAFF_BEN_UID || 'demo-staff-ben-v2', + email: process.env.V2_DEMO_STAFF_BEN_EMAIL || 'ben.barista+v2@krowd.com', + displayName: 'Ben Barista', + }, }, business: { id: '14f4fcfb-f21f-4ba9-9328-90f794a56001', @@ -62,12 +67,23 @@ export const V2DemoFixture = { phone: '+15557654321', primaryRole: 'BARISTA', }, + ben: { + id: '4b7dff1a-1856-4d59-b450-5a6736461002', + fullName: 'Ben Barista', + email: 'ben.barista+v2@krowd.com', + phone: '+15557654322', + primaryRole: 'BARISTA', + }, }, workforce: { ana: { id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001', workforceNumber: 'WF-V2-ANA-001', }, + ben: { + id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa002', + workforceNumber: 'WF-V2-BEN-001', + }, }, clockPoint: { id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001', @@ -147,6 +163,13 @@ export const V2DemoFixture = { clockInMode: 'GEO_REQUIRED', allowClockInOverride: true, }, + swapEligible: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954007', + code: 'SHIFT-V2-SWAP-1', + title: 'Swap eligible barista shift', + clockInMode: 'GEO_REQUIRED', + allowClockInOverride: true, + }, cancelled: { id: '6e7dadad-99e4-45bb-b0da-7bb617954005', code: 'SHIFT-V2-CANCELLED-1', @@ -171,6 +194,9 @@ export const V2DemoFixture = { assignedBarista: { id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004', }, + swapEligibleBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b007', + }, cancelledBarista: { id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005', }, @@ -182,6 +208,9 @@ export const V2DemoFixture = { openAna: { id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001', }, + swapBen: { + id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34002', + }, }, assignments: { completedAna: { @@ -190,6 +219,9 @@ export const V2DemoFixture = { assignedAna: { id: 'f1d3f738-a132-4863-b222-4f9cb25aa002', }, + swapEligibleAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa005', + }, cancelledAna: { id: 'f1d3f738-a132-4863-b222-4f9cb25aa003', }, @@ -223,6 +255,17 @@ export const V2DemoFixture = { id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001', }, }, + dispatchTeamMemberships: { + anaCore: { + id: '7e211d49-0b72-40bd-a79f-a1158d8a1001', + }, + benMarketplace: { + id: '7e211d49-0b72-40bd-a79f-a1158d8a1002', + }, + benCertifiedLocation: { + id: '7e211d49-0b72-40bd-a79f-a1158d8a1003', + }, + }, documents: { governmentId: { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000', diff --git a/backend/command-api/sql/v2/008_v2_swap_workflow_and_dispatch_teams.sql b/backend/command-api/sql/v2/008_v2_swap_workflow_and_dispatch_teams.sql new file mode 100644 index 00000000..e33ae779 --- /dev/null +++ b/backend/command-api/sql/v2/008_v2_swap_workflow_and_dispatch_teams.sql @@ -0,0 +1,76 @@ +ALTER TABLE assignments + DROP CONSTRAINT IF EXISTS assignments_status_check; + +ALTER TABLE assignments + ADD CONSTRAINT assignments_status_check + CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'SWAPPED_OUT', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')); + +CREATE TABLE IF NOT EXISTS shift_swap_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE, + original_assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + original_staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + requested_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'OPEN' + CHECK (status IN ('OPEN', 'RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED')), + reason TEXT, + expires_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolved_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + selected_application_id UUID REFERENCES applications(id) ON DELETE SET NULL, + replacement_assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_swap_requests_open_original + ON shift_swap_requests (original_assignment_id) + WHERE status = 'OPEN'; + +CREATE INDEX IF NOT EXISTS idx_shift_swap_requests_status_expiry + ON shift_swap_requests (status, expires_at ASC); + +CREATE INDEX IF NOT EXISTS idx_shift_swap_requests_shift_role + ON shift_swap_requests (shift_role_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS dispatch_team_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + hub_id UUID REFERENCES clock_points(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + team_type TEXT NOT NULL + CHECK (team_type IN ('CORE', 'CERTIFIED_LOCATION', 'MARKETPLACE')), + source TEXT NOT NULL DEFAULT 'MANUAL' + CHECK (source IN ('MANUAL', 'AUTOMATED', 'SYSTEM')), + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE')), + reason TEXT, + effective_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_dispatch_team_certified_scope + CHECK (team_type <> 'CERTIFIED_LOCATION' OR hub_id IS NOT NULL) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_dispatch_team_memberships_active_global + ON dispatch_team_memberships (business_id, staff_id, team_type) + WHERE status = 'ACTIVE' AND hub_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_dispatch_team_memberships_active_hub + ON dispatch_team_memberships (business_id, hub_id, staff_id, team_type) + WHERE status = 'ACTIVE' AND hub_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_dispatch_team_memberships_staff + ON dispatch_team_memberships (staff_id, status, effective_at DESC); + +CREATE INDEX IF NOT EXISTS idx_dispatch_team_memberships_business_hub + ON dispatch_team_memberships (business_id, hub_id, status, effective_at DESC); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index 20b179fb..f4f0d567 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -207,11 +207,38 @@ export const shiftDecisionSchema = z.object({ reason: z.string().max(1000).optional(), }); +export const shiftSwapResolveSchema = z.object({ + swapRequestId: z.string().uuid(), + applicationId: z.string().uuid(), + note: z.string().max(2000).optional(), +}); + +export const shiftSwapCancelSchema = z.object({ + swapRequestId: z.string().uuid(), + reason: z.string().max(1000).optional(), +}); + export const shiftSubmitApprovalSchema = z.object({ shiftId: z.string().uuid(), note: z.string().max(2000).optional(), }); +export const dispatchTeamMembershipCreateSchema = z.object({ + staffId: z.string().uuid(), + hubId: z.string().uuid().optional(), + teamType: z.enum(['CORE', 'CERTIFIED_LOCATION', 'MARKETPLACE']), + source: z.enum(['MANUAL', 'AUTOMATED', 'SYSTEM']).optional(), + reason: z.string().max(1000).optional(), + effectiveAt: z.string().datetime().optional(), + expiresAt: z.string().datetime().optional(), + metadata: z.record(z.any()).optional(), +}); + +export const dispatchTeamMembershipDeleteSchema = z.object({ + membershipId: z.string().uuid(), + reason: z.string().max(1000).optional(), +}); + export const staffClockInSchema = z.object({ assignmentId: z.string().uuid().optional(), shiftId: z.string().uuid().optional(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index de62edd2..99bb8c8b 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -10,7 +10,9 @@ import { assignHubManager, assignHubNfc, cancelLateWorker, + cancelShiftSwapRequest, cancelClientOrder, + createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, @@ -24,6 +26,8 @@ import { rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, + removeDispatchTeamMembership, + resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, @@ -55,6 +59,8 @@ import { clientPermanentOrderSchema, clientRecurringOrderSchema, coverageReviewSchema, + dispatchTeamMembershipCreateSchema, + dispatchTeamMembershipDeleteSchema, emergencyContactCreateSchema, emergencyContactUpdateSchema, hubAssignManagerSchema, @@ -73,6 +79,8 @@ import { shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, + shiftSwapCancelSchema, + shiftSwapResolveSchema, shiftSubmitApprovalSchema, staffClockInSchema, staffClockOutSchema, @@ -90,7 +98,9 @@ const defaultHandlers = { assignHubManager, assignHubNfc, cancelLateWorker, + cancelShiftSwapRequest, cancelClientOrder, + createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, @@ -104,6 +114,8 @@ const defaultHandlers = { rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, + removeDispatchTeamMembership, + resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, @@ -301,6 +313,41 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }), })); + router.post(...mobileCommand('/client/coverage/swap-requests/:swapRequestId/resolve', { + schema: shiftSwapResolveSchema, + policyAction: 'client.coverage.write', + resource: 'shift_swap_request', + handler: handlers.resolveShiftSwapRequest, + paramShape: (req) => ({ ...req.body, swapRequestId: req.params.swapRequestId }), + })); + + router.post(...mobileCommand('/client/coverage/swap-requests/:swapRequestId/cancel', { + schema: shiftSwapCancelSchema, + policyAction: 'client.coverage.write', + resource: 'shift_swap_request', + handler: handlers.cancelShiftSwapRequest, + paramShape: (req) => ({ ...req.body, swapRequestId: req.params.swapRequestId }), + })); + + router.post(...mobileCommand('/client/coverage/dispatch-teams/memberships', { + schema: dispatchTeamMembershipCreateSchema, + policyAction: 'client.coverage.write', + resource: 'dispatch_team', + handler: handlers.createDispatchTeamMembership, + })); + + router.delete(...mobileCommand('/client/coverage/dispatch-teams/memberships/:membershipId', { + schema: dispatchTeamMembershipDeleteSchema, + policyAction: 'client.coverage.write', + resource: 'dispatch_team', + handler: handlers.removeDispatchTeamMembership, + paramShape: (req) => ({ + ...req.body, + membershipId: req.params.membershipId, + reason: req.body?.reason || req.query.reason, + }), + })); + router.post(...mobileCommand('/staff/profile/setup', { schema: staffProfileSetupSchema, policyAction: 'staff.profile.write', diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index c917f245..377ab9af 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -15,6 +15,19 @@ import { const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED']; const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED']; +const DISPATCH_TEAM_PRIORITY = { + CORE: 1, + CERTIFIED_LOCATION: 2, + MARKETPLACE: 3, +}; + +function parsePositiveIntEnv(name, fallback) { + const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const SHIFT_SWAP_WINDOW_MINUTES = parsePositiveIntEnv('SHIFT_SWAP_WINDOW_MINUTES', 120); +const SHIFT_SWAP_MIN_LEAD_MINUTES = parsePositiveIntEnv('SHIFT_SWAP_MIN_LEAD_MINUTES', 15); function toIsoOrNull(value) { return value ? new Date(value).toISOString() : null; @@ -37,6 +50,21 @@ function ensureArray(value) { return Array.isArray(value) ? value : []; } +function resolveDispatchPriority(teamType) { + return DISPATCH_TEAM_PRIORITY[teamType] || DISPATCH_TEAM_PRIORITY.MARKETPLACE; +} + +function computeSwapExpiry(startsAt) { + const shiftStart = new Date(startsAt).getTime(); + if (!Number.isFinite(shiftStart)) return null; + const now = Date.now(); + const latestByWindow = now + (SHIFT_SWAP_WINDOW_MINUTES * 60 * 1000); + const latestByShiftLead = shiftStart - (SHIFT_SWAP_MIN_LEAD_MINUTES * 60 * 1000); + const expiresAtMs = Math.min(latestByWindow, latestByShiftLead); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) return null; + return new Date(expiresAtMs); +} + async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) { const blocked = await client.query( ` @@ -849,6 +877,7 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, s.tenant_id, s.business_id, s.vendor_id, + s.clock_point_id, s.status AS shift_status, s.starts_at, s.ends_at, @@ -857,13 +886,26 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, sr.role_name, sr.workers_needed, sr.assigned_count, - sr.pay_rate_cents + sr.pay_rate_cents, + swap_request.id AS swap_request_id FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN LATERAL ( + SELECT id + FROM shift_swap_requests + WHERE shift_role_id = sr.id + AND status = 'OPEN' + AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + ) swap_request ON TRUE WHERE s.id = $1 AND s.tenant_id = $2 AND ($3::uuid IS NULL OR sr.id = $3) - AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED') + AND ( + s.status IN ('OPEN', 'PENDING_CONFIRMATION') + OR (s.status = 'ASSIGNED' AND swap_request.id IS NOT NULL) + ) AND NOT EXISTS ( SELECT 1 FROM applications a @@ -887,6 +929,217 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, return result.rows[0]; } +async function loadDispatchMembership(client, { + tenantId, + businessId, + hubId, + staffId, +}) { + const result = await client.query( + ` + SELECT + dtm.id, + dtm.team_type, + dtm.hub_id, + dtm.source, + dtm.effective_at, + dtm.expires_at + FROM dispatch_team_memberships dtm + WHERE dtm.tenant_id = $1 + AND dtm.business_id = $2 + 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 = $4) + ORDER BY + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END ASC, + CASE WHEN dtm.hub_id = $4 THEN 0 ELSE 1 END ASC, + dtm.created_at ASC + LIMIT 1 + `, + [tenantId, businessId, staffId, hubId || null] + ); + + if (result.rowCount === 0) { + return { + membershipId: null, + teamType: 'MARKETPLACE', + priority: resolveDispatchPriority('MARKETPLACE'), + source: 'SYSTEM', + scopedHubId: null, + }; + } + + return { + membershipId: result.rows[0].id, + teamType: result.rows[0].team_type, + priority: resolveDispatchPriority(result.rows[0].team_type), + source: result.rows[0].source, + scopedHubId: result.rows[0].hub_id, + }; +} + +async function requireSwapRequestForUpdate(client, tenantId, businessId, swapRequestId) { + const result = await client.query( + ` + SELECT + srq.id, + srq.tenant_id, + srq.business_id, + srq.vendor_id, + srq.shift_id, + srq.shift_role_id, + srq.original_assignment_id, + srq.original_staff_id, + srq.requested_by_user_id, + srq.status, + srq.reason, + srq.expires_at, + srq.metadata, + a.status AS assignment_status, + a.application_id AS original_application_id, + s.clock_point_id, + s.starts_at, + s.ends_at, + s.title AS shift_title, + sr.role_name, + sr.role_code, + st.full_name AS original_staff_name, + st.user_id AS original_staff_user_id + FROM shift_swap_requests srq + JOIN assignments a ON a.id = srq.original_assignment_id + JOIN shifts s ON s.id = srq.shift_id + JOIN shift_roles sr ON sr.id = srq.shift_role_id + JOIN staffs st ON st.id = srq.original_staff_id + WHERE srq.id = $1 + AND srq.tenant_id = $2 + AND srq.business_id = $3 + FOR UPDATE OF srq, a, s, sr + `, + [swapRequestId, tenantId, businessId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift swap request not found in business scope', 404, { + swapRequestId, + businessId, + }); + } + return result.rows[0]; +} + +async function requireSwapCandidateApplication(client, swapRequest, applicationId) { + const result = await client.query( + ` + SELECT + app.id, + app.staff_id, + app.status, + app.shift_id, + app.shift_role_id, + app.metadata, + st.full_name AS staff_name, + st.user_id AS staff_user_id, + w.id AS workforce_id + FROM applications app + JOIN staffs st ON st.id = app.staff_id + LEFT JOIN workforce w ON w.staff_id = st.id AND w.status = 'ACTIVE' + WHERE app.id = $1 + AND app.shift_role_id = $2 + AND app.shift_id = $3 + FOR UPDATE OF app + `, + [applicationId, swapRequest.shift_role_id, swapRequest.shift_id] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Swap candidate application not found for this shift', 404, { + applicationId, + swapRequestId: swapRequest.id, + }); + } + + const application = result.rows[0]; + if (!['PENDING', 'CONFIRMED'].includes(application.status)) { + throw new AppError('INVALID_SWAP_APPLICATION_STATE', 'Swap candidate must be pending or confirmed', 409, { + applicationId, + applicationStatus: application.status, + }); + } + if (application.staff_id === swapRequest.original_staff_id) { + throw new AppError('INVALID_SWAP_APPLICATION', 'Original staff cannot be selected as their own replacement', 409, { + applicationId, + swapRequestId: swapRequest.id, + }); + } + + return application; +} + +async function rejectOtherApplicationsForSwap(client, { + shiftRoleId, + selectedApplicationId = null, + reason, + actorUid, +}) { + await client.query( + ` + UPDATE applications + SET status = 'REJECTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $3::jsonb, + updated_at = NOW() + WHERE shift_role_id = $1 + AND status IN ('PENDING', 'CONFIRMED') + AND ($2::uuid IS NULL OR id <> $2) + `, + [ + shiftRoleId, + selectedApplicationId, + JSON.stringify({ + rejectedBy: actorUid || 'system', + rejectionReason: reason, + rejectedAt: new Date().toISOString(), + }), + ] + ); +} + +async function markSwapRequestStatus(client, { + swapRequestId, + status, + resolvedByUserId = null, + selectedApplicationId = null, + replacementAssignmentId = null, + metadata = {}, +}) { + await client.query( + ` + UPDATE shift_swap_requests + SET status = $2, + resolved_at = CASE WHEN $2 IN ('RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED') THEN NOW() ELSE resolved_at END, + resolved_by_user_id = COALESCE($3, resolved_by_user_id), + selected_application_id = COALESCE($4, selected_application_id), + replacement_assignment_id = COALESCE($5, replacement_assignment_id), + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + swapRequestId, + status, + resolvedByUserId, + selectedApplicationId, + replacementAssignmentId, + JSON.stringify(metadata || {}), + ] + ); +} + async function requirePendingAssignmentForActor(client, tenantId, shiftId, actorUid) { const result = await client.query( ` @@ -2499,6 +2752,12 @@ export async function applyForShift(actor, payload) { await ensureActorUser(client, actor); const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id); + const dispatchMembership = await loadDispatchMembership(client, { + tenantId: context.tenant.tenantId, + businessId: shiftRole.business_id, + hubId: shiftRole.clock_point_id, + staffId: staff.id, + }); await ensureStaffNotBlockedByBusiness(client, { tenantId: context.tenant.tenantId, businessId: shiftRole.business_id, @@ -2551,6 +2810,10 @@ export async function applyForShift(actor, payload) { JSON.stringify({ appliedBy: actor.uid, instantBookRequested: payload.instantBook === true, + dispatchTeamType: dispatchMembership.teamType, + dispatchPriority: dispatchMembership.priority, + dispatchTeamMembershipId: dispatchMembership.membershipId, + dispatchTeamScopeHubId: dispatchMembership.scopedHubId, }), ] ); @@ -2704,13 +2967,134 @@ export async function requestShiftSwap(actor, payload) { const context = await requireStaffContext(actor.uid); return withTransaction(async (client) => { await ensureActorUser(client, actor); - const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); - if (!['ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT'].includes(assignment.status)) { - throw new AppError('INVALID_SWAP_STATE', 'Only accepted or worked shifts can be marked for swap', 409, { + const assignmentResult = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata, + s.starts_at, + s.clock_point_id, + s.title AS shift_title, + sr.role_name, + st.full_name AS staff_name + FROM assignments a + JOIN staffs st ON st.id = a.staff_id + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND st.user_id = $3 + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a, s, sr + `, + [context.tenant.tenantId, payload.shiftId, actor.uid] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift assignment not found for current user', 404, { + shiftId: payload.shiftId, + }); + } + const assignment = assignmentResult.rows[0]; + + if (assignment.status !== 'ACCEPTED') { + throw new AppError('INVALID_SWAP_STATE', 'Only accepted future shifts can be marked for swap', 409, { shiftId: payload.shiftId, assignmentStatus: assignment.status, }); } + const expiresAt = computeSwapExpiry(assignment.starts_at); + if (!expiresAt) { + throw new AppError('SWAP_WINDOW_UNAVAILABLE', 'Shift is too close to start time for a valid swap window', 409, { + shiftId: payload.shiftId, + startsAt: assignment.starts_at, + minimumLeadMinutes: SHIFT_SWAP_MIN_LEAD_MINUTES, + }); + } + + const existingSwap = await client.query( + ` + SELECT id, status, expires_at + FROM shift_swap_requests + WHERE original_assignment_id = $1 + AND status = 'OPEN' + ORDER BY created_at DESC + LIMIT 1 + FOR UPDATE + `, + [assignment.id] + ); + + let swapRequestId; + if (existingSwap.rowCount > 0) { + swapRequestId = existingSwap.rows[0].id; + await client.query( + ` + UPDATE shift_swap_requests + SET reason = COALESCE($2, reason), + expires_at = $3, + metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + swapRequestId, + payload.reason || null, + expiresAt.toISOString(), + JSON.stringify({ + reopenedAt: new Date().toISOString(), + swapRequestedBy: actor.uid, + }), + ] + ); + } else { + const swapRequestResult = await client.query( + ` + INSERT INTO shift_swap_requests ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + original_assignment_id, + original_staff_id, + requested_by_user_id, + status, + reason, + expires_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, $11::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + assignment.business_id, + assignment.vendor_id, + assignment.shift_id, + assignment.shift_role_id, + assignment.id, + assignment.staff_id, + actor.uid, + payload.reason || null, + expiresAt.toISOString(), + JSON.stringify({ + requestedAt: new Date().toISOString(), + requestedBy: actor.uid, + }), + ] + ); + swapRequestId = swapRequestResult.rows[0].id; + } + await client.query( ` UPDATE assignments @@ -2722,22 +3106,516 @@ export async function requestShiftSwap(actor, payload) { [assignment.id, JSON.stringify({ swapRequestedAt: new Date().toISOString(), swapReason: payload.reason || null, + swapRequestId, + swapExpiresAt: expiresAt.toISOString(), })] ); await refreshShiftRoleCounts(client, assignment.shift_role_id); await refreshShiftCounts(client, assignment.shift_id); await insertDomainEvent(client, { tenantId: context.tenant.tenantId, - aggregateType: 'assignment', - aggregateId: assignment.id, + aggregateType: 'shift_swap_request', + aggregateId: swapRequestId, eventType: 'SHIFT_SWAP_REQUESTED', actorUserId: actor.uid, - payload, + payload: { + ...payload, + assignmentId: assignment.id, + expiresAt: expiresAt.toISOString(), + }, + }); + await enqueueHubManagerAlert(client, { + tenantId: context.tenant.tenantId, + businessId: assignment.business_id, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + hubId: assignment.clock_point_id, + notificationType: 'SHIFT_SWAP_REQUESTED', + priority: 'HIGH', + subject: 'Shift swap requested', + body: `${assignment.staff_name || 'A worker'} requested a swap for ${assignment.shift_title || assignment.role_name || 'a shift'}`, + payload: { + swapRequestId, + assignmentId: assignment.id, + shiftId: assignment.shift_id, + expiresAt: expiresAt.toISOString(), + reason: payload.reason || null, + }, + dedupeScope: swapRequestId, }); return { + swapRequestId, assignmentId: assignment.id, shiftId: assignment.shift_id, status: 'SWAP_REQUESTED', + expiresAt: expiresAt.toISOString(), + }; + }); +} + +export async function resolveShiftSwapRequest(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const swapRequest = await requireSwapRequestForUpdate( + client, + context.tenant.tenantId, + context.business.businessId, + payload.swapRequestId + ); + + if (swapRequest.status !== 'OPEN') { + throw new AppError('INVALID_SWAP_REQUEST_STATE', 'Only open swap requests can be resolved', 409, { + swapRequestId: payload.swapRequestId, + swapRequestStatus: swapRequest.status, + }); + } + if (new Date(swapRequest.expires_at).getTime() <= Date.now()) { + throw new AppError('SWAP_REQUEST_EXPIRED', 'The swap request has already expired and must be handled by the expiry worker', 409, { + swapRequestId: payload.swapRequestId, + expiresAt: swapRequest.expires_at, + }); + } + + const candidate = await requireSwapCandidateApplication(client, swapRequest, payload.applicationId); + + await client.query( + ` + UPDATE applications + SET status = 'CONFIRMED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + candidate.id, + JSON.stringify({ + selectedForSwapAt: new Date().toISOString(), + selectedForSwapBy: actor.uid, + selectedForSwapRequestId: swapRequest.id, + selectionNote: payload.note || null, + }), + ] + ); + + await rejectOtherApplicationsForSwap(client, { + shiftRoleId: swapRequest.shift_role_id, + selectedApplicationId: candidate.id, + reason: 'Replacement selected for swap request', + actorUid: actor.uid, + }); + + await client.query( + ` + UPDATE assignments + SET status = 'SWAPPED_OUT', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + swapRequest.original_assignment_id, + JSON.stringify({ + swappedOutAt: new Date().toISOString(), + swapResolvedBy: actor.uid, + swapRequestId: swapRequest.id, + replacementApplicationId: candidate.id, + }), + ] + ); + + const replacementAssignmentResult = 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, 'ACCEPTED', NOW(), NOW(), $9::jsonb) + RETURNING id, status + `, + [ + context.tenant.tenantId, + swapRequest.business_id, + swapRequest.vendor_id, + swapRequest.shift_id, + swapRequest.shift_role_id, + candidate.workforce_id, + candidate.staff_id, + candidate.id, + JSON.stringify({ + source: 'swap-resolution', + swapRequestId: swapRequest.id, + originalAssignmentId: swapRequest.original_assignment_id, + resolvedBy: actor.uid, + }), + ] + ); + + await markSwapRequestStatus(client, { + swapRequestId: swapRequest.id, + status: 'RESOLVED', + resolvedByUserId: actor.uid, + selectedApplicationId: candidate.id, + replacementAssignmentId: replacementAssignmentResult.rows[0].id, + metadata: { + resolvedAt: new Date().toISOString(), + resolutionNote: payload.note || null, + }, + }); + + await refreshShiftRoleCounts(client, swapRequest.shift_role_id); + await refreshShiftCounts(client, swapRequest.shift_id); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'shift_swap_request', + aggregateId: swapRequest.id, + eventType: 'SHIFT_SWAP_RESOLVED', + actorUserId: actor.uid, + payload: { + applicationId: candidate.id, + replacementAssignmentId: replacementAssignmentResult.rows[0].id, + note: payload.note || null, + }, + }); + + await enqueueUserAlert(client, { + tenantId: context.tenant.tenantId, + businessId: swapRequest.business_id, + shiftId: swapRequest.shift_id, + assignmentId: swapRequest.original_assignment_id, + recipientUserId: swapRequest.original_staff_user_id, + notificationType: 'SHIFT_SWAP_RESOLVED', + priority: 'HIGH', + subject: 'Swap request resolved', + body: `A replacement has been confirmed for ${swapRequest.shift_title || 'your shift'}`, + payload: { + swapRequestId: swapRequest.id, + replacementAssignmentId: replacementAssignmentResult.rows[0].id, + }, + dedupeScope: swapRequest.id, + }); + + await enqueueUserAlert(client, { + tenantId: context.tenant.tenantId, + businessId: swapRequest.business_id, + shiftId: swapRequest.shift_id, + assignmentId: replacementAssignmentResult.rows[0].id, + recipientUserId: candidate.staff_user_id, + notificationType: 'SHIFT_SWAP_ASSIGNMENT_CONFIRMED', + priority: 'HIGH', + subject: 'You were selected as the shift replacement', + body: `You have been confirmed for ${swapRequest.shift_title || 'a shift'} via swap coverage`, + payload: { + swapRequestId: swapRequest.id, + assignmentId: replacementAssignmentResult.rows[0].id, + }, + dedupeScope: replacementAssignmentResult.rows[0].id, + }); + + return { + swapRequestId: swapRequest.id, + status: 'RESOLVED', + originalAssignmentId: swapRequest.original_assignment_id, + replacementAssignmentId: replacementAssignmentResult.rows[0].id, + applicationId: candidate.id, + }; + }); +} + +export async function cancelShiftSwapRequest(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const swapRequest = await requireSwapRequestForUpdate( + client, + context.tenant.tenantId, + context.business.businessId, + payload.swapRequestId + ); + + if (swapRequest.status !== 'OPEN') { + throw new AppError('INVALID_SWAP_REQUEST_STATE', 'Only open swap requests can be cancelled', 409, { + swapRequestId: payload.swapRequestId, + swapRequestStatus: swapRequest.status, + }); + } + + await client.query( + ` + UPDATE assignments + SET status = 'ACCEPTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + swapRequest.original_assignment_id, + JSON.stringify({ + swapCancelledAt: new Date().toISOString(), + swapCancelledBy: actor.uid, + swapCancellationReason: payload.reason || null, + }), + ] + ); + + await rejectOtherApplicationsForSwap(client, { + shiftRoleId: swapRequest.shift_role_id, + selectedApplicationId: null, + reason: payload.reason || 'Swap request cancelled', + actorUid: actor.uid, + }); + + await markSwapRequestStatus(client, { + swapRequestId: swapRequest.id, + status: 'CANCELLED', + resolvedByUserId: actor.uid, + metadata: { + cancelledAt: new Date().toISOString(), + cancellationReason: payload.reason || null, + }, + }); + + await refreshShiftRoleCounts(client, swapRequest.shift_role_id); + await refreshShiftCounts(client, swapRequest.shift_id); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'shift_swap_request', + aggregateId: swapRequest.id, + eventType: 'SHIFT_SWAP_CANCELLED', + actorUserId: actor.uid, + payload, + }); + + await enqueueUserAlert(client, { + tenantId: context.tenant.tenantId, + businessId: swapRequest.business_id, + shiftId: swapRequest.shift_id, + assignmentId: swapRequest.original_assignment_id, + recipientUserId: swapRequest.original_staff_user_id, + notificationType: 'SHIFT_SWAP_CANCELLED', + priority: 'NORMAL', + subject: 'Swap request cancelled', + body: `Your swap request for ${swapRequest.shift_title || 'the shift'} was cancelled`, + payload: { + swapRequestId: swapRequest.id, + reason: payload.reason || null, + }, + dedupeScope: swapRequest.id, + }); + + return { + swapRequestId: swapRequest.id, + status: 'CANCELLED', + assignmentId: swapRequest.original_assignment_id, + }; + }); +} + +export async function createDispatchTeamMembership(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + + if (payload.effectiveAt && payload.expiresAt && new Date(payload.expiresAt).getTime() <= new Date(payload.effectiveAt).getTime()) { + throw new AppError('VALIDATION_ERROR', 'expiresAt must be after effectiveAt', 400, { + effectiveAt: payload.effectiveAt, + expiresAt: payload.expiresAt, + }); + } + if (payload.teamType === 'CERTIFIED_LOCATION' && !payload.hubId) { + throw new AppError('VALIDATION_ERROR', 'hubId is required for CERTIFIED_LOCATION memberships', 400); + } + if (payload.hubId) { + await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + } + const staffResult = await client.query( + ` + SELECT id + FROM staffs + WHERE tenant_id = $1 + AND id = $2 + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, payload.staffId] + ); + if (staffResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Staff profile not found in tenant scope', 404, { + staffId: payload.staffId, + }); + } + + const existing = await client.query( + ` + SELECT id, status + FROM dispatch_team_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + AND team_type = $4 + AND ( + ($5::uuid IS NULL AND hub_id IS NULL) + OR hub_id = $5 + ) + LIMIT 1 + FOR UPDATE + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + payload.teamType, + payload.hubId || null, + ] + ); + + let membershipId; + if (existing.rowCount > 0) { + membershipId = existing.rows[0].id; + await client.query( + ` + UPDATE dispatch_team_memberships + SET status = 'ACTIVE', + source = COALESCE($2, source), + reason = COALESCE($3, reason), + effective_at = COALESCE($4::timestamptz, effective_at, NOW()), + expires_at = $5::timestamptz, + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + membershipId, + payload.source || 'MANUAL', + payload.reason || null, + payload.effectiveAt || null, + payload.expiresAt || null, + JSON.stringify(payload.metadata || {}), + ] + ); + } else { + const created = await client.query( + ` + INSERT INTO dispatch_team_memberships ( + tenant_id, + business_id, + hub_id, + staff_id, + team_type, + source, + status, + reason, + effective_at, + expires_at, + created_by_user_id, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, COALESCE($8::timestamptz, NOW()), $9::timestamptz, $10, $11::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.hubId || null, + payload.staffId, + payload.teamType, + payload.source || 'MANUAL', + payload.reason || null, + payload.effectiveAt || null, + payload.expiresAt || null, + actor.uid, + JSON.stringify(payload.metadata || {}), + ] + ); + membershipId = created.rows[0].id; + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'dispatch_team_membership', + aggregateId: membershipId, + eventType: 'DISPATCH_TEAM_MEMBERSHIP_UPSERTED', + actorUserId: actor.uid, + payload, + }); + + return { + membershipId, + staffId: payload.staffId, + teamType: payload.teamType, + hubId: payload.hubId || null, + status: 'ACTIVE', + priority: resolveDispatchPriority(payload.teamType), + }; + }); +} + +export async function removeDispatchTeamMembership(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const existing = await client.query( + ` + SELECT id, team_type, staff_id, hub_id + FROM dispatch_team_memberships + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + FOR UPDATE + `, + [payload.membershipId, context.tenant.tenantId, context.business.businessId] + ); + + if (existing.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Dispatch team membership not found', 404, { + membershipId: payload.membershipId, + }); + } + + await client.query( + ` + UPDATE dispatch_team_memberships + SET status = 'INACTIVE', + reason = COALESCE($2, reason), + expires_at = COALESCE(expires_at, NOW()), + metadata = COALESCE(metadata, '{}'::jsonb) || $3::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + payload.membershipId, + payload.reason || null, + JSON.stringify({ + removedAt: new Date().toISOString(), + removedBy: actor.uid, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'dispatch_team_membership', + aggregateId: payload.membershipId, + eventType: 'DISPATCH_TEAM_MEMBERSHIP_REMOVED', + actorUserId: actor.uid, + payload, + }); + + return { + membershipId: payload.membershipId, + status: 'INACTIVE', }; }); } diff --git a/backend/command-api/src/services/notification-dispatcher.js b/backend/command-api/src/services/notification-dispatcher.js index 00fd05cf..1c1d1074 100644 --- a/backend/command-api/src/services/notification-dispatcher.js +++ b/backend/command-api/src/services/notification-dispatcher.js @@ -1,5 +1,5 @@ import { query, withTransaction } from './db.js'; -import { enqueueNotification } from './notification-outbox.js'; +import { enqueueHubManagerAlert, enqueueNotification, enqueueUserAlert } from './notification-outbox.js'; import { markPushTokenInvalid, resolveNotificationTargetTokens, @@ -28,6 +28,82 @@ export function computeRetryDelayMinutes(attemptNumber) { return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60); } +async function refreshShiftRoleCounts(client, shiftRoleId) { + await client.query( + ` + UPDATE shift_roles sr + SET assigned_count = counts.assigned_count, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_role_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_count + FROM assignments + WHERE shift_role_id = $1 + ) counts + WHERE sr.id = counts.shift_role_id + `, + [shiftRoleId] + ); +} + +async function refreshShiftCounts(client, shiftId) { + await client.query( + ` + UPDATE shifts s + SET assigned_workers = counts.assigned_workers, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_workers + FROM assignments + WHERE shift_id = $1 + ) counts + WHERE s.id = counts.shift_id + `, + [shiftId] + ); +} + +async function insertDomainEvent(client, { + tenantId, + aggregateType, + aggregateId, + eventType, + actorUserId = null, + payload = {}, +}) { + await client.query( + ` + INSERT INTO domain_events ( + tenant_id, + aggregate_type, + aggregate_id, + sequence, + event_type, + actor_user_id, + payload + ) + SELECT + $1, + $2, + $3, + COALESCE(MAX(sequence) + 1, 1), + $4, + $5, + $6::jsonb + FROM domain_events + WHERE tenant_id = $1 + AND aggregate_type = $2 + AND aggregate_id = $3 + `, + [tenantId, aggregateType, aggregateId, eventType, actorUserId, JSON.stringify(payload || {})] + ); +} + async function recordDeliveryAttempt(client, { notificationId, devicePushTokenId = null, @@ -226,6 +302,183 @@ async function enqueueDueShiftReminders() { return { enqueued }; } +async function claimExpiredSwapRequests(limit) { + return withTransaction(async (client) => { + const claimed = await client.query( + ` + WITH due AS ( + SELECT id + FROM shift_swap_requests + WHERE ( + (status = 'OPEN' AND expires_at <= NOW()) + OR (status = 'EXPIRED' AND updated_at <= NOW() - INTERVAL '2 minutes') + ) + ORDER BY expires_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE shift_swap_requests srq + SET status = 'EXPIRED', + updated_at = NOW() + FROM due + WHERE srq.id = due.id + RETURNING srq.id + `, + [limit] + ); + + if (claimed.rowCount === 0) { + return []; + } + + const details = await client.query( + ` + SELECT + srq.id, + srq.tenant_id, + srq.business_id, + srq.shift_id, + srq.shift_role_id, + srq.original_assignment_id, + srq.original_staff_id, + srq.reason, + srq.expires_at, + s.clock_point_id, + s.title AS shift_title, + st.user_id AS original_staff_user_id + FROM shift_swap_requests srq + JOIN shifts s ON s.id = srq.shift_id + JOIN staffs st ON st.id = srq.original_staff_id + WHERE srq.id = ANY($1::uuid[]) + `, + [claimed.rows.map((row) => row.id)] + ); + + return details.rows; + }); +} + +async function processExpiredSwapRequests({ + limit = parseIntEnv('SHIFT_SWAP_AUTO_CANCEL_BATCH_LIMIT', 25), +} = {}) { + const claimed = await claimExpiredSwapRequests(limit); + let autoCancelled = 0; + + for (const swapRequest of claimed) { + await withTransaction(async (client) => { + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + AND status IN ('SWAP_REQUESTED', 'ACCEPTED') + `, + [ + swapRequest.original_assignment_id, + JSON.stringify({ + swapAutoCancelledAt: new Date().toISOString(), + swapAutoCancelledReason: 'Swap window expired without replacement', + }), + ] + ); + + await client.query( + ` + UPDATE applications + SET status = 'REJECTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE shift_role_id = $1 + AND status IN ('PENDING', 'CONFIRMED') + `, + [ + swapRequest.shift_role_id, + JSON.stringify({ + rejectedBy: 'system', + rejectionReason: 'Swap request expired', + rejectedAt: new Date().toISOString(), + }), + ] + ); + + await client.query( + ` + UPDATE shift_swap_requests + SET status = 'AUTO_CANCELLED', + resolved_at = NOW(), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + swapRequest.id, + JSON.stringify({ + autoCancelledAt: new Date().toISOString(), + autoCancelledReason: 'Swap window expired without replacement', + }), + ] + ); + + await refreshShiftRoleCounts(client, swapRequest.shift_role_id); + await refreshShiftCounts(client, swapRequest.shift_id); + await insertDomainEvent(client, { + tenantId: swapRequest.tenant_id, + aggregateType: 'shift_swap_request', + aggregateId: swapRequest.id, + eventType: 'SHIFT_SWAP_AUTO_CANCELLED', + actorUserId: null, + payload: { + reason: swapRequest.reason || null, + expiredAt: swapRequest.expires_at, + }, + }); + + await enqueueHubManagerAlert(client, { + tenantId: swapRequest.tenant_id, + businessId: swapRequest.business_id, + shiftId: swapRequest.shift_id, + assignmentId: swapRequest.original_assignment_id, + hubId: swapRequest.clock_point_id, + notificationType: 'SHIFT_SWAP_AUTO_CANCELLED', + priority: 'CRITICAL', + subject: 'Shift swap expired without coverage', + body: `${swapRequest.shift_title || 'A shift'} lost its assigned worker after the two-hour swap window expired`, + payload: { + swapRequestId: swapRequest.id, + assignmentId: swapRequest.original_assignment_id, + shiftId: swapRequest.shift_id, + }, + dedupeScope: swapRequest.id, + }); + + await enqueueUserAlert(client, { + tenantId: swapRequest.tenant_id, + businessId: swapRequest.business_id, + shiftId: swapRequest.shift_id, + assignmentId: swapRequest.original_assignment_id, + recipientUserId: swapRequest.original_staff_user_id, + notificationType: 'SHIFT_SWAP_AUTO_CANCELLED', + priority: 'HIGH', + subject: 'Shift swap expired', + body: 'Your shift swap request expired without a replacement and the assignment was cancelled', + payload: { + swapRequestId: swapRequest.id, + shiftId: swapRequest.shift_id, + }, + dedupeScope: swapRequest.id, + }); + }); + autoCancelled += 1; + } + + return { + claimed: claimed.length, + autoCancelled, + }; +} + async function settleNotification(notification, deliveryResults, maxAttempts) { const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length; const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length; @@ -301,10 +554,13 @@ export async function dispatchPendingNotifications({ sender = createPushSender(), } = {}) { const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5); + const swapSummary = await processExpiredSwapRequests(); const reminderSummary = await enqueueDueShiftReminders(); const claimed = await claimDueNotifications(limit); const summary = { + swapRequestsClaimed: swapSummary.claimed, + swapRequestsAutoCancelled: swapSummary.autoCancelled, remindersEnqueued: reminderSummary.enqueued, claimed: claimed.length, sent: 0, diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 6bec7af4..276c9a11 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -39,6 +39,16 @@ function createMobileHandlers() { orderId: payload.orderId, status: 'CANCELLED', }), + cancelShiftSwapRequest: async (_actor, payload) => ({ + swapRequestId: payload.swapRequestId, + status: 'CANCELLED', + }), + createDispatchTeamMembership: async (_actor, payload) => ({ + membershipId: 'dispatch-team-1', + staffId: payload.staffId, + teamType: payload.teamType, + status: 'ACTIVE', + }), createHub: async (_actor, payload) => ({ hubId: 'hub-1', name: payload.name, @@ -60,9 +70,18 @@ function createMobileHandlers() { platform: payload.platform, notificationsEnabled: payload.notificationsEnabled ?? true, }), + removeDispatchTeamMembership: async (_actor, payload) => ({ + membershipId: payload.membershipId, + status: 'INACTIVE', + }), unregisterClientPushToken: async () => ({ removedCount: 1, }), + resolveShiftSwapRequest: async (_actor, payload) => ({ + swapRequestId: payload.swapRequestId, + applicationId: payload.applicationId, + status: 'RESOLVED', + }), applyForShift: async (_actor, payload) => ({ shiftId: payload.shiftId, status: 'APPLIED', @@ -390,3 +409,64 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id assert.equal(res.body.timesheetId, 'timesheet-1'); assert.equal(res.body.submitted, true); }); + +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) + .post('/commands/client/coverage/swap-requests/11111111-1111-4111-8111-111111111111/resolve') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'swap-resolve-1') + .send({ + applicationId: '22222222-2222-4222-8222-222222222222', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.swapRequestId, '11111111-1111-4111-8111-111111111111'); + assert.equal(res.body.applicationId, '22222222-2222-4222-8222-222222222222'); + assert.equal(res.body.status, 'RESOLVED'); +}); + +test('POST /commands/client/coverage/swap-requests/:swapRequestId/cancel injects swap request id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/coverage/swap-requests/33333333-3333-4333-8333-333333333333/cancel') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'swap-cancel-1') + .send({ + reason: 'No longer needed', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.swapRequestId, '33333333-3333-4333-8333-333333333333'); + assert.equal(res.body.status, 'CANCELLED'); +}); + +test('POST /commands/client/coverage/dispatch-teams/memberships returns injected dispatch team membership response', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/coverage/dispatch-teams/memberships') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'dispatch-team-create-1') + .send({ + staffId: '44444444-4444-4444-8444-444444444444', + hubId: '55555555-5555-4555-8555-555555555555', + teamType: 'CERTIFIED_LOCATION', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.membershipId, 'dispatch-team-1'); + assert.equal(res.body.teamType, 'CERTIFIED_LOCATION'); + assert.equal(res.body.status, 'ACTIVE'); +}); + +test('DELETE /commands/client/coverage/dispatch-teams/memberships/:membershipId injects membership id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .delete('/commands/client/coverage/dispatch-teams/memberships/66666666-6666-4666-8666-666666666666?reason=cleanup') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'dispatch-team-delete-1'); + + assert.equal(res.status, 200); + assert.equal(res.body.membershipId, '66666666-6666-4666-8666-666666666666'); + assert.equal(res.body.status, 'INACTIVE'); +}); diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index f39d3a30..8bdc257a 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -34,6 +34,8 @@ import { listCostCenters, listCoreTeam, listCoverageByDate, + listCoverageDispatchCandidates, + listCoverageDispatchTeams, listCompletedShifts, listEmergencyContacts, listFaqCategories, @@ -44,6 +46,7 @@ import { listOpenShifts, listTaxForms, listTimeCardEntries, + listSwapRequests, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -99,6 +102,8 @@ const defaultQueryService = { listCostCenters, listCoreTeam, listCoverageByDate, + listCoverageDispatchCandidates, + listCoverageDispatchTeams, listCompletedShifts, listEmergencyContacts, listFaqCategories, @@ -109,6 +114,7 @@ const defaultQueryService = { listOpenShifts, listTaxForms, listTimeCardEntries, + listSwapRequests, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -266,6 +272,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/coverage/swap-requests', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listSwapRequests(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage/dispatch-teams', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoverageDispatchTeams(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage/dispatch-candidates', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoverageDispatchCandidates(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { try { const items = await queryService.listHubs(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 420f00f4..e6e54e9b 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -906,6 +906,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { SELECT s.id AS "shiftId", sr.id AS "roleId", + NULL::uuid AS "swapRequestId", b.business_name AS "clientName", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, @@ -932,12 +933,40 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { ) AS "totalRate", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", FALSE AS "instantBook", - sr.workers_needed AS "requiredWorkerCount" + sr.workers_needed AS "requiredWorkerCount", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority" FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id JOIN orders o ON o.id = s.order_id JOIN businesses b ON b.id = s.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 s.tenant_id = $1 AND s.status = 'OPEN' AND sr.role_code = $4 @@ -954,6 +983,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { SELECT s.id AS "shiftId", sr.id AS "roleId", + ssr.id AS "swapRequestId", b.business_name AS "clientName", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, @@ -980,14 +1010,45 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { ) AS "totalRate", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", FALSE AS "instantBook", - 1::INTEGER AS "requiredWorkerCount" - FROM assignments a + 1::INTEGER AS "requiredWorkerCount", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority" + FROM shift_swap_requests ssr + JOIN assignments a ON a.id = ssr.original_assignment_id JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id JOIN orders o ON o.id = s.order_id JOIN businesses b ON b.id = s.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 a.tenant_id = $1 + AND ssr.status = 'OPEN' + AND ssr.expires_at > NOW() AND a.status = 'SWAP_REQUESTED' AND a.staff_id <> $3 AND sr.role_code = $4 @@ -1006,7 +1067,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { UNION ALL SELECT * FROM swap_roles ) items - ORDER BY "startTime" ASC + ORDER BY "dispatchPriority" ASC, "startTime" ASC LIMIT $5 `, [ @@ -1369,17 +1430,165 @@ export async function listStaffBenefitHistory(actorUid, { limit, offset } = {}) return result.rows; } -export async function listCoreTeam(actorUid) { +export async function listSwapRequests(actorUid, { shiftId, status = 'OPEN', limit } = {}) { const context = await requireClientContext(actorUid); - const result = await query( + const safeLimit = parseLimit(limit, 20, 100); + const allowedStatuses = new Set(['OPEN', 'RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED']); + const normalizedStatus = allowedStatuses.has(`${status || 'OPEN'}`.toUpperCase()) + ? `${status || 'OPEN'}`.toUpperCase() + : 'OPEN'; + + const swapResult = await query( ` SELECT + srq.id AS "swapRequestId", + srq.shift_id AS "shiftId", + srq.shift_role_id AS "roleId", + srq.original_assignment_id AS "originalAssignmentId", + srq.original_staff_id AS "originalStaffId", + srq.status, + srq.reason, + srq.expires_at AS "expiresAt", + srq.resolved_at AS "resolvedAt", + s.title AS "shiftTitle", + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(cp.label, s.location_name) AS location, + COALESCE(cp.address, s.location_address) AS address, + b.business_name AS "clientName", + st.full_name AS "originalStaffName", + sr.role_name AS "roleName" + FROM shift_swap_requests srq + JOIN shifts s ON s.id = srq.shift_id + JOIN shift_roles sr ON sr.id = srq.shift_role_id + JOIN staffs st ON st.id = srq.original_staff_id + JOIN businesses b ON b.id = srq.business_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE srq.tenant_id = $1 + AND srq.business_id = $2 + AND ($3::uuid IS NULL OR srq.shift_id = $3) + AND srq.status = $4 + ORDER BY srq.created_at DESC + LIMIT $5 + `, + [context.tenant.tenantId, context.business.businessId, shiftId || null, normalizedStatus, safeLimit] + ); + + if (swapResult.rowCount === 0) { + return []; + } + + const swapIds = swapResult.rows.map((row) => row.swapRequestId); + const candidateResult = await query( + ` + SELECT + srq.id AS "swapRequestId", + app.id AS "applicationId", + app.status AS "applicationStatus", + app.created_at AS "appliedAt", st.id AS "staffId", st.full_name AS "fullName", st.primary_role AS "primaryRole", st.average_rating AS "averageRating", st.rating_count AS "ratingCount", - TRUE AS favorite + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority" + FROM shift_swap_requests srq + JOIN shifts s ON s.id = srq.shift_id + JOIN applications app ON app.shift_role_id = srq.shift_role_id + JOIN staffs st ON st.id = app.staff_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 = srq.tenant_id + AND dtm.business_id = srq.business_id + AND dtm.staff_id = st.id + 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 srq.id = ANY($1::uuid[]) + AND app.status IN ('PENDING', 'CONFIRMED') + ORDER BY srq.created_at DESC, "dispatchPriority" ASC, st.average_rating DESC, app.created_at ASC + `, + [swapIds] + ); + + const candidatesBySwapId = new Map(); + for (const row of candidateResult.rows) { + if (!candidatesBySwapId.has(row.swapRequestId)) { + candidatesBySwapId.set(row.swapRequestId, []); + } + candidatesBySwapId.get(row.swapRequestId).push(row); + } + + return swapResult.rows.map((row) => ({ + ...row, + candidates: candidatesBySwapId.get(row.swapRequestId) || [], + candidateCount: (candidatesBySwapId.get(row.swapRequestId) || []).length, + })); +} + +export async function listCoreTeam(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + dtm.id AS "membershipId", + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + COALESCE(sf.id IS NOT NULL, FALSE) AS favorite, + dtm.team_type AS "teamType" + FROM dispatch_team_memberships dtm + JOIN staffs st ON st.id = dtm.staff_id + LEFT JOIN staff_favorites sf + ON sf.staff_id = dtm.staff_id + AND sf.tenant_id = dtm.tenant_id + AND sf.business_id = dtm.business_id + WHERE dtm.tenant_id = $1 + AND dtm.business_id = $2 + AND dtm.status = 'ACTIVE' + AND dtm.team_type = 'CORE' + AND dtm.effective_at <= NOW() + AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW()) + ORDER BY st.average_rating DESC, st.full_name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + if (result.rowCount > 0) { + return result.rows; + } + + const favoritesFallback = await query( + ` + SELECT + NULL::uuid AS "membershipId", + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + TRUE AS favorite, + 'CORE'::text AS "teamType" FROM staff_favorites sf JOIN staffs st ON st.id = sf.staff_id WHERE sf.tenant_id = $1 @@ -1388,6 +1597,143 @@ export async function listCoreTeam(actorUid) { `, [context.tenant.tenantId, context.business.businessId] ); + return favoritesFallback.rows; +} + +export async function listCoverageDispatchTeams(actorUid, { hubId, teamType } = {}) { + const context = await requireClientContext(actorUid); + const normalizedTeamType = teamType ? `${teamType}`.toUpperCase() : null; + const result = await query( + ` + SELECT + dtm.id AS "membershipId", + dtm.staff_id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + dtm.team_type AS "teamType", + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END AS "dispatchPriority", + dtm.source, + dtm.status, + dtm.reason, + dtm.effective_at AS "effectiveAt", + dtm.expires_at AS "expiresAt", + dtm.hub_id AS "hubId", + cp.label AS "hubLabel" + FROM dispatch_team_memberships dtm + JOIN staffs st ON st.id = dtm.staff_id + LEFT JOIN clock_points cp ON cp.id = dtm.hub_id + WHERE dtm.tenant_id = $1 + AND dtm.business_id = $2 + AND dtm.status = 'ACTIVE' + AND dtm.effective_at <= NOW() + AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW()) + AND ($3::uuid IS NULL OR dtm.hub_id = $3) + AND ($4::text IS NULL OR dtm.team_type = $4) + ORDER BY "dispatchPriority" ASC, st.full_name ASC + `, + [context.tenant.tenantId, context.business.businessId, hubId || null, normalizedTeamType] + ); + return result.rows; +} + +export async function listCoverageDispatchCandidates(actorUid, { shiftId, roleId, limit } = {}) { + const context = await requireClientContext(actorUid); + if (!shiftId) { + throw new AppError('VALIDATION_ERROR', 'shiftId is required', 400, { field: 'shiftId' }); + } + + const safeLimit = parseLimit(limit, 25, 100); + const result = await query( + ` + WITH target_role AS ( + SELECT + s.id AS shift_id, + s.tenant_id, + s.business_id, + s.clock_point_id, + sr.id AS shift_role_id, + sr.role_id, + sr.role_code, + sr.role_name + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.id = $3 + AND ($4::uuid IS NULL OR sr.id = $4) + ORDER BY sr.created_at ASC + LIMIT 1 + ) + SELECT + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority", + dispatch.hub_id AS "dispatchHubId" + FROM target_role tr + JOIN staffs st + ON st.tenant_id = tr.tenant_id + AND st.status = 'ACTIVE' + LEFT JOIN staff_blocks sb + ON sb.tenant_id = tr.tenant_id + AND sb.business_id = tr.business_id + AND sb.staff_id = st.id + LEFT JOIN LATERAL ( + SELECT + dtm.team_type, + dtm.hub_id, + 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 = tr.tenant_id + AND dtm.business_id = tr.business_id + AND dtm.staff_id = st.id + 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 = tr.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 = tr.clock_point_id THEN 0 ELSE 1 END ASC, + dtm.created_at ASC + LIMIT 1 + ) dispatch ON TRUE + WHERE sb.id IS NULL + AND ( + st.primary_role = tr.role_code + OR EXISTS ( + SELECT 1 + FROM staff_roles str + WHERE str.staff_id = st.id + AND str.role_id = tr.role_id + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM assignments a + WHERE a.shift_id = tr.shift_id + AND a.staff_id = st.id + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ) + ORDER BY "dispatchPriority" ASC, st.average_rating DESC, st.full_name ASC + LIMIT $5 + `, + [context.tenant.tenantId, context.business.businessId, shiftId, roleId || null, safeLimit] + ); return result.rows; } diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 12c3d506..8a7946dc 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -38,6 +38,8 @@ function createMobileQueryService() { listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]), listCoreTeam: async () => ([{ staffId: 'core-1' }]), listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]), + listCoverageDispatchCandidates: async () => ([{ staffId: 'dispatch-1' }]), + listCoverageDispatchTeams: async () => ([{ membershipId: 'dispatch-team-1' }]), listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]), listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]), listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]), @@ -61,6 +63,7 @@ function createMobileQueryService() { listTaxForms: async () => ([{ formType: 'W4' }]), listAttireChecklist: async () => ([{ documentId: 'attire-1' }]), listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]), + listSwapRequests: async () => ([{ swapRequestId: 'swap-1' }]), listTodayShifts: async () => ([{ shiftId: 'today-1' }]), listVendorRoles: async () => ([{ roleId: 'role-1' }]), listVendors: async () => ([{ vendorId: 'vendor-1' }]), @@ -138,6 +141,36 @@ test('GET /query/client/coverage/incidents returns injected incidents list', asy assert.equal(res.body.items[0].incidentId, 'incident-1'); }); +test('GET /query/client/coverage/swap-requests returns injected swap request list', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/swap-requests?status=OPEN') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].swapRequestId, 'swap-1'); +}); + +test('GET /query/client/coverage/dispatch-teams returns injected dispatch team memberships', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/dispatch-teams') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].membershipId, 'dispatch-team-1'); +}); + +test('GET /query/client/coverage/dispatch-candidates returns injected candidate list', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/dispatch-candidates?shiftId=shift-1') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].staffId, 'dispatch-1'); +}); + test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/unified-api/scripts/ensure-v2-demo-users.mjs b/backend/unified-api/scripts/ensure-v2-demo-users.mjs index 1a1ff5a6..e5976b70 100644 --- a/backend/unified-api/scripts/ensure-v2-demo-users.mjs +++ b/backend/unified-api/scripts/ensure-v2-demo-users.mjs @@ -1,12 +1,18 @@ -import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js'; import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; import { getAuth } from 'firebase-admin/auth'; +import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs'; -const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; -const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; -const staffPhone = process.env.V2_DEMO_STAFF_PHONE || '+15557654321'; +const ownerUid = fixture.users.businessOwner.id; +const ownerEmail = fixture.users.businessOwner.email; +const staffUid = fixture.users.staffAna.id; +const staffEmail = fixture.users.staffAna.email; +const staffPhone = process.env.V2_DEMO_STAFF_PHONE || fixture.staff.ana.phone; +const staffBenUid = fixture.users.staffBen.id; +const staffBenEmail = fixture.users.staffBen.email; +const staffBenPhone = process.env.V2_DEMO_STAFF_BEN_PHONE || fixture.staff.ben.phone; const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; +const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!'; function ensureAdminApp() { if (getApps().length === 0) { @@ -19,42 +25,8 @@ function getAdminAuth() { return getAuth(); } -async function ensureUser({ email, password, displayName }) { - try { - const signedIn = await signInWithPassword({ email, password }); - return { - uid: signedIn.localId, - email, - password, - created: false, - displayName, - }; - } catch (error) { - const message = error?.message || ''; - if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) { - throw error; - } - } - - try { - const signedUp = await signUpWithPassword({ email, password }); - return { - uid: signedUp.localId, - email, - password, - created: true, - displayName, - }; - } catch (error) { - const message = error?.message || ''; - if (message.includes('EMAIL_EXISTS')) { - throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`); - } - throw error; - } -} - async function getUserByPhoneNumber(phoneNumber) { + if (!phoneNumber) return null; try { return await getAdminAuth().getUserByPhoneNumber(phoneNumber); } catch (error) { @@ -63,57 +35,90 @@ async function getUserByPhoneNumber(phoneNumber) { } } -async function reconcileStaffPhoneIdentity({ uid, email, displayName, phoneNumber }) { +async function getUserByEmail(email) { + try { + return await getAdminAuth().getUserByEmail(email); + } catch (error) { + if (error?.code === 'auth/user-not-found') return null; + throw error; + } +} + +async function ensureManagedUser({ uid, email, password, displayName, phoneNumber }) { const auth = getAdminAuth(); - const current = await auth.getUser(uid); - const existingPhoneUser = await getUserByPhoneNumber(phoneNumber); - let deletedConflictingUid = null; - - if (existingPhoneUser && existingPhoneUser.uid !== uid) { - deletedConflictingUid = existingPhoneUser.uid; - await auth.deleteUser(existingPhoneUser.uid); + const existingByEmail = await getUserByEmail(email); + if (existingByEmail && existingByEmail.uid !== uid) { + await auth.deleteUser(existingByEmail.uid); + } + const existingByPhone = await getUserByPhoneNumber(phoneNumber); + if (existingByPhone && existingByPhone.uid !== uid) { + await auth.deleteUser(existingByPhone.uid); } - const updatePayload = {}; - if (current.displayName !== displayName) updatePayload.displayName = displayName; - if (current.email !== email) updatePayload.email = email; - if (current.phoneNumber !== phoneNumber) updatePayload.phoneNumber = phoneNumber; - - if (Object.keys(updatePayload).length > 0) { - await auth.updateUser(uid, updatePayload); + try { + await auth.updateUser(uid, { + email, + password, + displayName, + ...(phoneNumber ? { phoneNumber } : {}), + emailVerified: true, + disabled: false, + }); + } catch (error) { + if (error?.code !== 'auth/user-not-found') { + throw error; + } + await auth.createUser({ + uid, + email, + password, + displayName, + ...(phoneNumber ? { phoneNumber } : {}), + emailVerified: true, + disabled: false, + }); } - const reconciled = await auth.getUser(uid); + const user = await auth.getUser(uid); return { - uid: reconciled.uid, - email: reconciled.email, - phoneNumber: reconciled.phoneNumber, - deletedConflictingUid, + uid: user.uid, + email: user.email, + phoneNumber: user.phoneNumber, + displayName: user.displayName, + created: true, }; } async function main() { - const owner = await ensureUser({ + const owner = await ensureManagedUser({ + uid: ownerUid, email: ownerEmail, password: ownerPassword, - displayName: 'Legendary Demo Owner V2', + displayName: fixture.users.businessOwner.displayName, }); - const staff = await ensureUser({ + const staff = await ensureManagedUser({ + uid: staffUid, email: staffEmail, password: staffPassword, - displayName: 'Ana Barista V2', - }); - - const reconciledStaff = await reconcileStaffPhoneIdentity({ - uid: staff.uid, - email: staff.email, - displayName: staff.displayName, + displayName: fixture.users.staffAna.displayName, phoneNumber: staffPhone, }); + const staffBen = await ensureManagedUser({ + uid: staffBenUid, + email: staffBenEmail, + password: staffBenPassword, + displayName: fixture.users.staffBen.displayName, + phoneNumber: staffBenPhone, + }); + // eslint-disable-next-line no-console - console.log(JSON.stringify({ owner, staff: { ...staff, ...reconciledStaff } }, null, 2)); + console.log(JSON.stringify({ + owner, + staff, + staffBen, + }, null, 2)); } main().catch((error) => { diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index 844514f4..f6936a38 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -5,8 +5,10 @@ import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixt const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app'; const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const staffBenEmail = process.env.V2_DEMO_STAFF_BEN_EMAIL || 'ben.barista+v2@krowd.com'; const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; +const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!'; function uniqueKey(prefix) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -175,13 +177,22 @@ async function signInStaff() { }); } +async function signInStaffBen() { + return signInWithPassword({ + email: staffBenEmail, + password: staffBenPassword, + }); +} + async function main() { const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`; const ownerSession = await signInClient(); const staffAuth = await signInStaff(); + const staffBenAuth = await signInStaffBen(); assert.ok(ownerSession.sessionToken); assert.ok(staffAuth.idToken); + assert.ok(staffBenAuth.idToken); assert.equal(ownerSession.business.businessId, fixture.business.id); logStep('auth.client.sign-in.ok', { tenantId: ownerSession.tenant.tenantId, @@ -191,6 +202,10 @@ async function main() { uid: staffAuth.localId, email: staffEmail, }); + logStep('auth.staff-b.password-sign-in.ok', { + uid: staffBenAuth.localId, + email: staffBenEmail, + }); const authSession = await apiCall('/auth/session', { token: ownerSession.sessionToken, @@ -342,6 +357,13 @@ async function main() { assert.ok(Array.isArray(coreTeam.items)); logStep('client.coverage.core-team.ok', { count: coreTeam.items.length }); + const dispatchTeams = await apiCall('/client/coverage/dispatch-teams', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(dispatchTeams.items)); + assert.ok(dispatchTeams.items.length >= 2); + logStep('client.coverage.dispatch-teams.ok', { count: dispatchTeams.items.length }); + const coverageIncidentsBefore = await apiCall(`/client/coverage/incidents?${reportWindow}`, { token: ownerSession.sessionToken, }); @@ -1197,7 +1219,7 @@ async function main() { assert.equal(submittedCompletedShift.submitted, true); logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift); - const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { + const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/request-swap`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-swap'), @@ -1207,6 +1229,54 @@ async function main() { }); logStep('staff.shifts.request-swap.ok', requestedSwap); + const benOpenShifts = await apiCall('/staff/shifts/open?limit=10', { + token: staffBenAuth.idToken, + }); + const benSwapShift = benOpenShifts.items.find((item) => item.shiftId === fixture.shifts.swapEligible.id); + assert.ok(benSwapShift); + assert.equal(benSwapShift.swapRequestId, requestedSwap.swapRequestId); + assert.equal(benSwapShift.dispatchTeam, 'CERTIFIED_LOCATION'); + logStep('staff-b.shifts.open-swap.ok', benSwapShift); + + const dispatchCandidates = await apiCall(`/client/coverage/dispatch-candidates?shiftId=${fixture.shifts.swapEligible.id}&roleId=${fixture.shiftRoles.swapEligibleBarista.id}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(dispatchCandidates.items)); + assert.ok(dispatchCandidates.items.length >= 1); + assert.equal(dispatchCandidates.items[0].staffId, fixture.staff.ben.id); + logStep('client.coverage.dispatch-candidates.ok', { count: dispatchCandidates.items.length }); + + const benSwapApplication = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/apply`, { + method: 'POST', + token: staffBenAuth.idToken, + idempotencyKey: uniqueKey('staff-b-shift-swap-apply'), + body: { + roleId: fixture.shiftRoles.swapEligibleBarista.id, + }, + }); + assert.ok(benSwapApplication.applicationId); + logStep('staff-b.shifts.apply-swap.ok', benSwapApplication); + + const swapRequests = await apiCall('/client/coverage/swap-requests?status=OPEN', { + token: ownerSession.sessionToken, + }); + const openSwapRequest = swapRequests.items.find((item) => item.swapRequestId === requestedSwap.swapRequestId); + assert.ok(openSwapRequest); + assert.ok(openSwapRequest.candidates.some((candidate) => candidate.staffId === fixture.staff.ben.id)); + logStep('client.coverage.swap-requests.ok', { count: swapRequests.items.length }); + + const resolvedSwap = await apiCall(`/client/coverage/swap-requests/${requestedSwap.swapRequestId}/resolve`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('client-swap-resolve'), + body: { + applicationId: benSwapApplication.applicationId, + note: 'Smoke resolved swap request', + }, + }); + assert.equal(resolvedSwap.status, 'RESOLVED'); + logStep('client.coverage.swap-resolve.ok', resolvedSwap); + const blockedReview = await apiCall('/client/coverage/reviews', { method: 'POST', token: ownerSession.sessionToken, diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index fb71b760..56961739 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -24,10 +24,11 @@ What was validated live against the deployed stack: - client coverage incident feed for geofence and override review - client blocked-staff review and invited shift-manager creation - client hub, order, coverage review, device token, and late-worker cancellation flows +- client swap-request review, dispatch-team management, and dispatch-candidate ranking - client invoice approve and dispute - staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card - staff benefit history read model -- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request +- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, swap request, and completed-shift submission - direct file upload helpers and verification job creation through the unified host - client and staff sign-out @@ -109,6 +110,20 @@ Important operational rules: - background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed - incident review lives on `GET /client/coverage/incidents` - confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel` +- client swap review is exposed on: + - `GET /client/coverage/swap-requests` + - `POST /client/coverage/swap-requests/:swapRequestId/resolve` + - `POST /client/coverage/swap-requests/:swapRequestId/cancel` +- dispatch-team management is exposed on: + - `GET /client/coverage/dispatch-teams` + - `GET /client/coverage/dispatch-candidates` + - `POST /client/coverage/dispatch-teams/memberships` + - `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` +- dispatch ranking order is: + 1. `CORE` + 2. `CERTIFIED_LOCATION` + 3. `MARKETPLACE` +- expired swap requests are auto-cancelled by the notification worker and emit manager plus staff alerts - queued alerts are written to `notification_outbox`, dispatched by the private Cloud Run worker service `krow-notification-worker-v2`, and recorded in `notification_deliveries` ## 5) Route model 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 e4610a62..ce26f3ed 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -80,8 +80,15 @@ Token refresh: - `GET /client/coverage/core-team?date=YYYY-MM-DD` - `GET /client/coverage/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` - `GET /client/coverage/blocked-staff` +- `GET /client/coverage/swap-requests?status=OPEN` +- `GET /client/coverage/dispatch-teams` +- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid` - `POST /client/coverage/reviews` - `POST /client/coverage/late-workers/:assignmentId/cancel` +- `POST /client/coverage/swap-requests/:swapRequestId/resolve` +- `POST /client/coverage/swap-requests/:swapRequestId/cancel` +- `POST /client/coverage/dispatch-teams/memberships` +- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` Use `POST /client/coverage/reviews` when the business is rating a worker after coverage review. @@ -98,6 +105,19 @@ Payload may include: If `markAsBlocked` is `true`, backend blocks that worker for that business and rejects future apply or assign attempts until a later review sets `markAsBlocked: false`. +Swap-management rule: + +- use `GET /client/coverage/swap-requests` as the client review feed +- use `GET /client/coverage/dispatch-candidates` for the ranked replacement list +- use `POST /client/coverage/swap-requests/:swapRequestId/resolve` when ops selects a replacement +- use `POST /client/coverage/swap-requests/:swapRequestId/cancel` when ops wants to close the swap request without replacement + +Dispatch-priority rule: + +1. `CORE` +2. `CERTIFIED_LOCATION` +3. `MARKETPLACE` + ### Orders - `GET /client/orders/view` @@ -178,10 +198,8 @@ Current swap behavior: - backend records the swap request - assignment moves to `SWAP_REQUESTED` - shift becomes visible in the replacement pool - -Current limitation: - -- full manager-side swap resolution lifecycle is not yet a separate frontend contract +- client/ops can review and resolve swap requests through the coverage endpoints +- if the swap request expires without coverage, backend auto-cancels it and alerts both the manager path and the original worker ### Clock in / clock out diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index a8f85d23..802cdf75 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -99,9 +99,16 @@ Current backend behavior: - stores the reason - emits `SHIFT_SWAP_REQUESTED` - exposes the shift in the replacement pool +- starts the swap-expiry window used by backend auto-cancellation -This is enough for the current staff UI. -It is not yet the full manager-side swap resolution lifecycle. +Manager/ops review happens through: + +- `GET /client/coverage/swap-requests` +- `GET /client/coverage/dispatch-candidates` +- `POST /client/coverage/swap-requests/:swapRequestId/resolve` +- `POST /client/coverage/swap-requests/:swapRequestId/cancel` + +If the swap request expires without coverage, backend auto-cancels it and alerts the manager path plus the original worker. ### Submit completed shift for approval diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 80d3efc1..8f1a62c8 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -45,6 +45,9 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/coverage/core-team` - `GET /client/coverage/incidents` - `GET /client/coverage/blocked-staff` +- `GET /client/coverage/swap-requests` +- `GET /client/coverage/dispatch-teams` +- `GET /client/coverage/dispatch-candidates` - `GET /client/hubs` - `GET /client/cost-centers` - `GET /client/vendors` @@ -80,6 +83,10 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/billing/invoices/:invoiceId/dispute` - `POST /client/coverage/reviews` - `POST /client/coverage/late-workers/:assignmentId/cancel` +- `POST /client/coverage/swap-requests/:swapRequestId/resolve` +- `POST /client/coverage/swap-requests/:swapRequestId/cancel` +- `POST /client/coverage/dispatch-teams/memberships` +- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` Coverage-review request payload may also send: @@ -94,6 +101,45 @@ Coverage-review request payload may also send: If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`. +Swap-review routes: + +- `GET /client/coverage/swap-requests?status=OPEN` +- `POST /client/coverage/swap-requests/:swapRequestId/resolve` +- `POST /client/coverage/swap-requests/:swapRequestId/cancel` + +Resolve example: + +```json +{ + "applicationId": "uuid", + "note": "Dispatch selected the strongest replacement candidate" +} +``` + +Dispatch-team routes: + +- `GET /client/coverage/dispatch-teams` +- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid` +- `POST /client/coverage/dispatch-teams/memberships` +- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` + +Dispatch-team membership example: + +```json +{ + "staffId": "uuid", + "hubId": "uuid", + "teamType": "CORE", + "notes": "Preferred lead barista for this location" +} +``` + +Dispatch priority order is: + +1. `CORE` +2. `CERTIFIED_LOCATION` +3. `MARKETPLACE` + Shift-manager creation example: ```json @@ -257,6 +303,9 @@ These are exposed as direct unified aliases even though they are backed by `core - `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. - `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business. - `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time. +- `GET /client/coverage/swap-requests` is the manager/ops review feed for swap requests, candidate applications, and status. +- `GET /client/coverage/dispatch-candidates` returns ranked candidates with the dispatch-team priority already applied. +- swap auto-cancellation is backend-driven. If a swap request expires without a replacement, backend cancels the original assignment, marks the swap request `AUTO_CANCELLED`, and alerts both the manager path and the original worker. - Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index. - Push delivery is backed by: - SQL token registry in `device_push_tokens` diff --git a/makefiles/backend.mk b/makefiles/backend.mk index e940d293..71c3255d 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -459,7 +459,7 @@ backend-configure-notification-scheduler-v2: --time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \ --uri="$$URL/tasks/dispatch-notifications" \ --http-method=POST \ - --headers=Content-Type=application/json \ + --update-headers=Content-Type=application/json \ --message-body='{}' \ --oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \ --oidc-token-audience="$$URL"; \