diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index a2cd2dba..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' }), ] ); @@ -256,6 +298,36 @@ async function main() { [fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title] ); + await client.query( + ` + INSERT INTO staff_benefit_history ( + id, tenant_id, staff_id, benefit_id, benefit_type, title, status, + effective_at, ended_at, tracked_hours, target_hours, notes, metadata + ) + VALUES + ( + $1, $3, $4, $5, 'COMMUTER', $6, 'PENDING', + NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days', 18, 40, + 'Hours were below threshold for payout window.', + '{"source":"seed-v2-demo","period":"previous"}'::jsonb + ), + ( + $2, $3, $4, $5, 'COMMUTER', $6, 'ACTIVE', + NOW() - INTERVAL '9 days', NULL, 32, 40, + 'Current active commuter stipend tracking.', + '{"source":"seed-v2-demo","period":"current"}'::jsonb + ) + `, + [ + fixture.benefitHistory.commuterPending.id, + fixture.benefitHistory.commuterActive.id, + fixture.tenant.id, + fixture.staff.ana.id, + fixture.benefits.commuter.id, + fixture.benefits.commuter.title, + ] + ); + await client.query( ` INSERT INTO emergency_contacts ( @@ -308,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 ( @@ -415,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, @@ -439,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, @@ -482,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, ] @@ -548,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, @@ -560,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 3fd31310..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', @@ -99,6 +115,14 @@ export const V2DemoFixture = { title: 'Commuter Support', }, }, + benefitHistory: { + commuterActive: { + id: '9e46729a-ff53-4d1b-9110-7ee5c38a9001', + }, + commuterPending: { + id: '9e46729a-ff53-4d1b-9110-7ee5c38a9002', + }, + }, orders: { open: { id: 'b6132d7a-45c3-4879-b349-46b2fd518001', @@ -139,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', @@ -163,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', }, @@ -174,6 +208,9 @@ export const V2DemoFixture = { openAna: { id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001', }, + swapBen: { + id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34002', + }, }, assignments: { completedAna: { @@ -182,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', }, @@ -215,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/007_v2_staff_blocks_and_benefit_history.sql b/backend/command-api/sql/v2/007_v2_staff_blocks_and_benefit_history.sql new file mode 100644 index 00000000..f7da362a --- /dev/null +++ b/backend/command-api/sql/v2/007_v2_staff_blocks_and_benefit_history.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS staff_blocks ( + 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, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + reason TEXT, + issue_flags JSONB NOT NULL DEFAULT '[]'::jsonb, + 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_staff_blocks_business_staff + ON staff_blocks (business_id, staff_id); + +CREATE INDEX IF NOT EXISTS idx_staff_blocks_business_created_at + ON staff_blocks (business_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS staff_benefit_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + benefit_id UUID REFERENCES staff_benefits(id) ON DELETE SET NULL, + benefit_type TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE', + effective_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + tracked_hours INTEGER NOT NULL DEFAULT 0, + target_hours INTEGER, + notes TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_staff_effective_at + ON staff_benefit_history (staff_id, effective_at DESC); + +CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_tenant_benefit_type + ON staff_benefit_history (tenant_id, benefit_type, effective_at DESC); 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 e7f65551..f4f0d567 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -97,6 +97,16 @@ export const hubAssignManagerSchema = z.object({ message: 'businessMembershipId or managerUserId is required', }); +export const shiftManagerCreateSchema = z.object({ + hubId: z.string().uuid().optional(), + email: z.string().email(), + firstName: z.string().min(1).max(120), + lastName: z.string().min(1).max(120), + phone: z.string().min(7).max(40).optional(), + role: z.enum(['manager', 'viewer']).optional(), + metadata: z.record(z.any()).optional(), +}); + export const invoiceApproveSchema = z.object({ invoiceId: z.string().uuid(), }); @@ -111,6 +121,7 @@ export const coverageReviewSchema = z.object({ assignmentId: z.string().uuid().optional(), rating: z.number().int().min(1).max(5), markAsFavorite: z.boolean().optional(), + markAsBlocked: z.boolean().optional(), issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(), feedback: z.string().max(5000).optional(), }); @@ -196,6 +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 be97e3c0..99bb8c8b 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -10,24 +10,30 @@ import { assignHubManager, assignHubNfc, cancelLateWorker, + cancelShiftSwapRequest, cancelClientOrder, + createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, createClientRecurringOrder, createEditedOrderCopy, createHub, + createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, + removeDispatchTeamMembership, + resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, staffClockIn, staffClockOut, + submitCompletedShiftForApproval, submitLocationStreamBatch, submitTaxForm, unregisterClientPushToken, @@ -53,6 +59,8 @@ import { clientPermanentOrderSchema, clientRecurringOrderSchema, coverageReviewSchema, + dispatchTeamMembershipCreateSchema, + dispatchTeamMembershipDeleteSchema, emergencyContactCreateSchema, emergencyContactUpdateSchema, hubAssignManagerSchema, @@ -68,8 +76,12 @@ import { profileExperienceSchema, pushTokenDeleteSchema, pushTokenRegisterSchema, + shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, + shiftSwapCancelSchema, + shiftSwapResolveSchema, + shiftSubmitApprovalSchema, staffClockInSchema, staffClockOutSchema, staffLocationBatchSchema, @@ -86,24 +98,30 @@ const defaultHandlers = { assignHubManager, assignHubNfc, cancelLateWorker, + cancelShiftSwapRequest, cancelClientOrder, + createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, createClientRecurringOrder, createEditedOrderCopy, createHub, + createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, + removeDispatchTeamMembership, + resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, staffClockIn, staffClockOut, + submitCompletedShiftForApproval, submitLocationStreamBatch, submitTaxForm, unregisterClientPushToken, @@ -257,6 +275,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); + router.post(...mobileCommand('/client/shift-managers', { + schema: shiftManagerCreateSchema, + policyAction: 'client.hubs.update', + resource: 'hub_manager', + handler: handlers.createShiftManager, + })); + router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', { schema: invoiceApproveSchema, policyAction: 'client.billing.write', @@ -288,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', @@ -402,6 +462,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); + router.post(...mobileCommand('/staff/shifts/:shiftId/submit-for-approval', { + schema: shiftSubmitApprovalSchema, + policyAction: 'staff.shifts.submit', + resource: 'shift', + handler: handlers.submitCompletedShiftForApproval, + paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), + })); + router.put(...mobileCommand('/staff/profile/personal-info', { schema: personalInfoUpdateSchema, policyAction: 'staff.profile.write', diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index a9cce39b..ba257a12 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -44,6 +44,30 @@ async function ensureActorUser(client, actor) { ); } +async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) { + const blocked = await client.query( + ` + SELECT id, reason, issue_flags + FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + LIMIT 1 + `, + [tenantId, businessId, staffId] + ); + + if (blocked.rowCount > 0) { + throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, { + businessId, + staffId, + blockId: blocked.rows[0].id, + reason: blocked.rows[0].reason || null, + issueFlags: Array.isArray(blocked.rows[0].issue_flags) ? blocked.rows[0].issue_flags : [], + }); + } +} + async function insertDomainEvent(client, { tenantId, aggregateType, @@ -986,6 +1010,11 @@ export async function assignStaffToShift(actor, payload) { } const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId); + await ensureStaffNotBlockedByBusiness(client, { + tenantId: shift.tenant_id, + businessId: shift.business_id, + staffId: workforce.staff_id, + }); let application = null; if (payload.applicationId) { application = await requireApplication(client, payload.tenantId, payload.applicationId); diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index e0017cd5..377ab9af 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -8,12 +8,27 @@ import { uploadLocationBatch } from './location-log-storage.js'; import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js'; import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js'; import { - cancelOrder as cancelOrderCommand, clockIn as clockInCommand, clockOut as clockOutCommand, createOrder as createOrderCommand, } from './command-service.js'; +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; } @@ -35,6 +50,45 @@ 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( + ` + SELECT id, reason, issue_flags + FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + LIMIT 1 + `, + [tenantId, businessId, staffId] + ); + + if (blocked.rowCount > 0) { + throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, { + businessId, + staffId, + blockId: blocked.rows[0].id, + reason: blocked.rows[0].reason || null, + issueFlags: ensureArray(blocked.rows[0].issue_flags || []), + }); + } +} + function buildAssignmentReferencePayload(assignment) { return { assignmentId: assignment.id, @@ -397,18 +451,153 @@ async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId WHERE o.id = $1 AND o.tenant_id = $2 AND o.business_id = $3 + AND s.starts_at > NOW() + AND s.status NOT IN ('CANCELLED', 'COMPLETED') GROUP BY o.id `, [orderId, tenantId, businessId] ); if (result.rowCount === 0) { - throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId }); + throw new AppError('ORDER_EDIT_BLOCKED', 'Order has no future shifts available for edit', 409, { orderId }); } return result.rows[0]; } +async function cancelFutureOrderSlice(client, { + actorUid, + tenantId, + businessId, + orderId, + reason, + metadata = {}, +}) { + const orderResult = await client.query( + ` + SELECT id, order_number, status + FROM orders + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + FOR UPDATE + `, + [orderId, tenantId, businessId] + ); + + if (orderResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for cancel flow', 404, { orderId }); + } + + const order = orderResult.rows[0]; + const futureShiftsResult = await client.query( + ` + SELECT id + FROM shifts + WHERE order_id = $1 + AND starts_at > NOW() + AND status NOT IN ('CANCELLED', 'COMPLETED') + ORDER BY starts_at ASC + FOR UPDATE + `, + [order.id] + ); + + if (futureShiftsResult.rowCount === 0) { + return { + orderId: order.id, + orderNumber: order.order_number, + status: order.status, + futureOnly: true, + cancelledShiftCount: 0, + alreadyCancelled: true, + }; + } + + const shiftIds = futureShiftsResult.rows.map((row) => row.id); + await client.query( + ` + UPDATE orders + SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + order.id, + JSON.stringify({ + futureCancellationReason: reason || null, + futureCancellationBy: actorUid, + futureCancellationAt: new Date().toISOString(), + ...metadata, + }), + ] + ); + + await client.query( + ` + UPDATE shifts + SET status = 'CANCELLED', + updated_at = NOW() + WHERE id = ANY($1::uuid[]) + `, + [shiftIds] + ); + + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id = ANY($1::uuid[]) + AND status = ANY($2::text[]) + `, + [shiftIds, MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES] + ); + + await client.query( + ` + UPDATE applications + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id = ANY($1::uuid[]) + AND status = ANY($2::text[]) + `, + [shiftIds, MOBILE_CANCELLABLE_APPLICATION_STATUSES] + ); + + for (const shiftId of shiftIds) { + const roleIds = await client.query( + 'SELECT id FROM shift_roles WHERE shift_id = $1', + [shiftId] + ); + for (const role of roleIds.rows) { + await refreshShiftRoleCounts(client, role.id); + } + await refreshShiftCounts(client, shiftId); + } + + await insertDomainEvent(client, { + tenantId, + aggregateType: 'order', + aggregateId: order.id, + eventType: 'ORDER_FUTURE_SLICE_CANCELLED', + actorUserId: actorUid, + payload: { + reason: reason || null, + shiftIds, + futureOnly: true, + }, + }); + + return { + orderId: order.id, + orderNumber: order.order_number, + status: 'CANCELLED', + futureOnly: true, + cancelledShiftCount: shiftIds.length, + }; +} + async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) { const context = await requireStaffContext(actorUid); if (payload.assignmentId) { @@ -688,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, @@ -696,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 @@ -726,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( ` @@ -1205,6 +1619,149 @@ export async function assignHubManager(actor, payload) { }); } +export async function createShiftManager(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + + const invitedEmail = payload.email.trim().toLowerCase(); + const fullName = `${payload.firstName} ${payload.lastName}`.trim(); + + const userLookup = await client.query( + ` + SELECT id + FROM users + WHERE LOWER(email) = $1 + LIMIT 1 + `, + [invitedEmail] + ); + + const existingMembership = await client.query( + ` + SELECT id, user_id, membership_status, metadata + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND ( + LOWER(invited_email) = $3 + OR ($4::text IS NOT NULL AND user_id = $4) + ) + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, context.business.businessId, invitedEmail, userLookup.rows[0]?.id || null] + ); + + const membershipMetadata = { + ...(existingMembership.rows[0]?.metadata || {}), + firstName: payload.firstName, + lastName: payload.lastName, + fullName, + phone: normalizePhone(payload.phone), + source: 'mobile-api', + createdBy: actor.uid, + ...(payload.metadata || {}), + }; + + let businessMembershipId; + let membershipStatus; + if (existingMembership.rowCount > 0) { + const result = await client.query( + ` + UPDATE business_memberships + SET user_id = COALESCE(user_id, $2), + invited_email = $3, + membership_status = CASE + WHEN COALESCE(user_id, $2) IS NOT NULL THEN 'ACTIVE' + ELSE membership_status + END, + business_role = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE id = $1 + RETURNING id, membership_status + `, + [ + existingMembership.rows[0].id, + userLookup.rows[0]?.id || null, + invitedEmail, + payload.role || 'manager', + JSON.stringify(membershipMetadata), + ] + ); + businessMembershipId = result.rows[0].id; + membershipStatus = result.rows[0].membership_status; + } else { + const result = await client.query( + ` + INSERT INTO business_memberships ( + tenant_id, + business_id, + user_id, + invited_email, + membership_status, + business_role, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, membership_status + `, + [ + context.tenant.tenantId, + context.business.businessId, + userLookup.rows[0]?.id || null, + invitedEmail, + userLookup.rows[0]?.id ? 'ACTIVE' : 'INVITED', + payload.role || 'manager', + JSON.stringify(membershipMetadata), + ] + ); + businessMembershipId = result.rows[0].id; + membershipStatus = result.rows[0].membership_status; + } + + let managerAssignmentId = null; + if (payload.hubId) { + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + const assigned = await client.query( + ` + INSERT INTO hub_managers (tenant_id, hub_id, business_membership_id) + VALUES ($1, $2, $3) + ON CONFLICT (hub_id, business_membership_id) DO UPDATE + SET updated_at = NOW() + RETURNING id + `, + [context.tenant.tenantId, hub.id, businessMembershipId] + ); + managerAssignmentId = assigned.rows[0].id; + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'business_membership', + aggregateId: businessMembershipId, + eventType: 'SHIFT_MANAGER_CREATED', + actorUserId: actor.uid, + payload: { + invitedEmail, + fullName, + hubId: payload.hubId || null, + membershipStatus, + }, + }); + + return { + businessMembershipId, + membershipStatus, + invitedEmail, + fullName, + role: payload.role || 'manager', + managerAssignmentId, + }; + }); +} + export async function approveInvoice(actor, payload) { const context = await requireClientContext(actor.uid); return withTransaction(async (client) => { @@ -1346,6 +1903,52 @@ export async function rateWorkerFromCoverage(actor, payload) { ); } + if (payload.markAsBlocked === true) { + await client.query( + ` + INSERT INTO staff_blocks ( + tenant_id, + business_id, + staff_id, + created_by_user_id, + reason, + issue_flags, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET reason = EXCLUDED.reason, + issue_flags = EXCLUDED.issue_flags, + metadata = COALESCE(staff_blocks.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + actor.uid, + payload.feedback || null, + JSON.stringify(ensureArray(payload.issueFlags || [])), + JSON.stringify({ + blockedByCoverageReview: true, + assignmentId: assignment.id, + }), + ] + ); + } + + if (payload.markAsBlocked === false) { + await client.query( + ` + DELETE FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId] + ); + } + await client.query( ` UPDATE staffs @@ -1380,6 +1983,7 @@ export async function rateWorkerFromCoverage(actor, payload) { staffId: payload.staffId, rating: payload.rating, markAsFavorite: payload.markAsFavorite ?? null, + markAsBlocked: payload.markAsBlocked ?? null, issueFlags: ensureArray(payload.issueFlags || []), feedback: payload.feedback || null, }; @@ -1547,11 +2151,31 @@ export async function createEditedOrderCopy(actor, payload) { ); const templateShifts = Array.isArray(template.shifts) ? template.shifts : []; - const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({ - ...role, - startTime: role.startTime || shift.startTime, - endTime: role.endTime || shift.endTime, - }))); + const templatePositions = Array.from( + templateShifts.reduce((deduped, shift) => { + for (const role of (Array.isArray(shift.roles) ? shift.roles : [])) { + const normalized = { + ...role, + startTime: role.startTime || shift.startTime, + endTime: role.endTime || shift.endTime, + }; + const key = [ + normalized.roleId || '', + normalized.roleCode || '', + normalized.roleName || '', + normalized.startTime || '', + normalized.endTime || '', + normalized.workerCount ?? '', + normalized.payRateCents ?? '', + normalized.billRateCents ?? '', + ].join('|'); + if (!deduped.has(key)) { + deduped.set(key, normalized); + } + } + return deduped; + }, new Map()).values() + ); const firstShift = templateShifts[0] || {}; const lastShift = templateShifts[templateShifts.length - 1] || {}; const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME'; @@ -1593,11 +2217,16 @@ export async function createEditedOrderCopy(actor, payload) { export async function cancelClientOrder(actor, payload) { const context = await requireClientContext(actor.uid); - return cancelOrderCommand(actor, { - tenantId: context.tenant.tenantId, - orderId: payload.orderId, - reason: payload.reason, - metadata: payload.metadata, + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + return cancelFutureOrderSlice(client, { + actorUid: actor.uid, + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + orderId: payload.orderId, + reason: payload.reason, + metadata: payload.metadata, + }); }); } @@ -2123,6 +2752,17 @@ 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, + staffId: staff.id, + }); const existingAssignment = await client.query( ` SELECT id @@ -2170,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, }), ] ); @@ -2323,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 @@ -2341,22 +3106,578 @@ 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', + }; + }); +} + +export async function submitCompletedShiftForApproval(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 (!['CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) { + throw new AppError('INVALID_TIMESHEET_STATE', 'Only completed or checked-out shifts can be submitted for approval', 409, { + shiftId: payload.shiftId, + assignmentStatus: assignment.status, + }); + } + + const timesheetResult = await client.query( + ` + INSERT INTO timesheets ( + tenant_id, + assignment_id, + staff_id, + status, + metadata + ) + VALUES ($1, $2, $3, 'SUBMITTED', $4::jsonb) + ON CONFLICT (assignment_id) DO UPDATE + SET status = CASE + WHEN timesheets.status IN ('APPROVED', 'PAID') THEN timesheets.status + ELSE 'SUBMITTED' + END, + metadata = COALESCE(timesheets.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id, status, metadata + `, + [ + context.tenant.tenantId, + assignment.id, + assignment.staff_id, + JSON.stringify({ + submittedAt: new Date().toISOString(), + submittedBy: actor.uid, + submissionNote: payload.note || null, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'timesheet', + aggregateId: timesheetResult.rows[0].id, + eventType: 'TIMESHEET_SUBMITTED_FOR_APPROVAL', + actorUserId: actor.uid, + payload, + }); + + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + timesheetId: timesheetResult.rows[0].id, + status: timesheetResult.rows[0].status, + submitted: timesheetResult.rows[0].status === 'SUBMITTED', }; }); } 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 466e1b48..276c9a11 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -39,11 +39,28 @@ 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, costCenterId: payload.costCenterId, }), + createShiftManager: async (_actor, payload) => ({ + businessMembershipId: 'membership-1', + membershipStatus: 'INVITED', + invitedEmail: payload.email, + fullName: `${payload.firstName} ${payload.lastName}`, + managerAssignmentId: payload.hubId ? 'hub-manager-1' : null, + }), approveInvoice: async (_actor, payload) => ({ invoiceId: payload.invoiceId, status: 'APPROVED', @@ -53,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', @@ -77,6 +103,12 @@ function createMobileHandlers() { assignmentId: payload.assignmentId || 'assignment-1', status: 'CLOCK_OUT', }), + submitCompletedShiftForApproval: async (_actor, payload) => ({ + shiftId: payload.shiftId, + timesheetId: 'timesheet-1', + status: 'SUBMITTED', + submitted: true, + }), submitLocationStreamBatch: async (_actor, payload) => ({ assignmentId: payload.assignmentId || 'assignment-1', pointCount: payload.points.length, @@ -161,6 +193,25 @@ test('POST /commands/client/hubs returns injected hub response', async () => { assert.equal(res.body.name, 'Google North Hub'); }); +test('POST /commands/client/shift-managers creates an invited manager profile', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/shift-managers') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'shift-manager-create-1') + .send({ + hubId: '11111111-1111-4111-8111-111111111111', + email: 'manager@example.com', + firstName: 'Shift', + lastName: 'Lead', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.membershipStatus, 'INVITED'); + assert.equal(res.body.invitedEmail, 'manager@example.com'); + assert.equal(res.body.managerAssignmentId, 'hub-manager-1'); +}); + test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => { const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const res = await request(app) @@ -342,3 +393,80 @@ test('POST /commands/staff/profile/bank-accounts uppercases account type', async assert.equal(res.body.accountType, 'CHECKING'); assert.equal(res.body.last4, '7890'); }); + +test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/shifts/77777777-7777-4777-8777-777777777777/submit-for-approval') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'shift-submit-approval-1') + .send({ + note: 'Worked full shift and ready for approval', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.shiftId, '77777777-7777-4777-8777-777777777777'); + 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/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 40a0ebe8..027e07af 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js'; import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js'; import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; import { invokeVertexModel } from '../services/llm.js'; +import { requireTenantContext } from '../services/actor-context.js'; +import { isDatabaseConfigured, query as dbQuery } from '../services/db.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js'; import { @@ -26,6 +28,8 @@ import { } from '../services/verification-jobs.js'; import { deleteCertificate, + finalizeCertificateUpload, + finalizeStaffDocumentUpload, uploadCertificate, uploadProfilePhoto, uploadStaffDocument, @@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({ expiresAt: z.string().datetime().optional(), }); +const finalizedDocumentUploadSchema = z.object({ + fileUri: z.string().max(4096).optional(), + photoUrl: z.string().max(4096).optional(), + verificationId: z.string().min(1).max(120), +}).strict(); + +const finalizedCertificateUploadSchema = certificateUploadMetaSchema.extend({ + fileUri: z.string().max(4096).optional(), + photoUrl: z.string().max(4096).optional(), + verificationId: z.string().min(1).max(120), +}).strict(); + +const rapidOrderProcessSchema = z.object({ + text: z.string().trim().min(1).max(4000).optional(), + audioFileUri: z.string().startsWith('gs://').max(2048).optional(), + locale: z.string().trim().min(2).max(35).optional().default('en-US'), + promptHints: z.array(z.string().trim().min(1).max(80)).max(20).optional().default([]), + timezone: z.string().trim().min(1).max(80).optional(), + now: z.string().datetime({ offset: true }).optional(), +}).strict().superRefine((value, ctx) => { + if (!value.text && !value.audioFileUri) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'text or audioFileUri is required', + path: ['text'], + }); + } +}); + function mockSignedUrl(fileUri, expiresInSeconds) { const encoded = encodeURIComponent(fileUri); const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); @@ -114,6 +147,72 @@ function enforceLlmRateLimit(uid) { } } +function normalizeRoleToken(value) { + return `${value || ''}` + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +async function loadRapidOrderRoleCatalog(actorUid) { + if (!isDatabaseConfigured()) { + return []; + } + + let context; + try { + context = await requireTenantContext(actorUid); + } catch { + return []; + } + + const result = await dbQuery( + ` + SELECT + rc.id AS "roleId", + rc.code AS "roleCode", + rc.name AS "roleName", + COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents" + FROM roles_catalog rc + LEFT JOIN shift_roles sr ON sr.role_id = rc.id + LEFT JOIN shifts s ON s.id = sr.shift_id + WHERE rc.tenant_id = $1 + AND rc.status = 'ACTIVE' + GROUP BY rc.id + ORDER BY rc.name ASC + `, + [context.tenant.tenantId] + ); + return result.rows; +} + +function enrichRapidOrderPositions(positions, roleCatalog) { + const catalog = roleCatalog.map((role) => ({ + ...role, + normalizedName: normalizeRoleToken(role.roleName), + normalizedCode: normalizeRoleToken(role.roleCode), + })); + + return positions.map((position) => { + const normalizedRole = normalizeRoleToken(position.role); + const exact = catalog.find((role) => role.normalizedName === normalizedRole || role.normalizedCode === normalizedRole); + const fuzzy = exact || catalog.find((role) => ( + role.normalizedName.includes(normalizedRole) || normalizedRole.includes(role.normalizedName) + )); + + return { + ...position, + roleId: fuzzy?.roleId || null, + roleCode: fuzzy?.roleCode || null, + roleName: fuzzy?.roleName || position.role, + hourlyRateCents: fuzzy?.hourlyRateCents || 0, + matched: Boolean(fuzzy), + }; + }); +} + async function handleUploadFile(req, res, next) { try { const file = req.file; @@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) { timezone: payload.timezone, now: payload.now, }); + const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid); return res.status(200).json({ ...result, + parsed: { + ...result.parsed, + positions: enrichRapidOrderPositions(result.parsed.positions, roleCatalog), + }, + catalog: { + roles: roleCatalog, + }, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleRapidOrderProcess(req, res, next) { + try { + const payload = parseBody(rapidOrderProcessSchema, req.body || {}); + enforceLlmRateLimit(req.actor.uid); + + let transcript = payload.text || null; + if (!transcript && payload.audioFileUri) { + validateFileUriAccess({ + fileUri: payload.audioFileUri, + actorUid: req.actor.uid, + }); + + if (requireRapidAudioFileExists() && !useMockUpload()) { + await ensureFileExistsForActor({ + fileUri: payload.audioFileUri, + actorUid: req.actor.uid, + }); + } + + const transcribed = await transcribeRapidOrderAudio({ + audioFileUri: payload.audioFileUri, + locale: payload.locale, + promptHints: payload.promptHints, + }); + transcript = transcribed.transcript; + } + + const startedAt = Date.now(); + const parsed = await parseRapidOrderText({ + text: transcript, + locale: payload.locale, + timezone: payload.timezone, + now: payload.now, + }); + const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid); + + return res.status(200).json({ + transcript, + parsed: { + ...parsed.parsed, + positions: enrichRapidOrderPositions(parsed.parsed.positions, roleCatalog), + }, + missingFields: parsed.missingFields, + warnings: parsed.warnings, + confidence: parsed.confidence, + catalog: { + roles: roleCatalog, + }, + model: parsed.model, latencyMs: Date.now() - startedAt, requestId: req.requestId, }); @@ -341,14 +505,25 @@ async function handleProfilePhotoUpload(req, res, next) { async function handleDocumentUpload(req, res, next) { try { const file = req.file; - if (!file) { - throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + if (file) { + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'document', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const result = await uploadStaffDocument({ + + const payload = parseBody(finalizedDocumentUploadSchema, req.body || {}); + const result = await finalizeStaffDocumentUpload({ actorUid: req.actor.uid, documentId: req.params.documentId, - file, routeType: 'document', + verificationId: payload.verificationId, }); return res.status(200).json({ ...result, @@ -362,14 +537,25 @@ async function handleDocumentUpload(req, res, next) { async function handleAttireUpload(req, res, next) { try { const file = req.file; - if (!file) { - throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + if (file) { + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'attire', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const result = await uploadStaffDocument({ + + const payload = parseBody(finalizedDocumentUploadSchema, req.body || {}); + const result = await finalizeStaffDocumentUpload({ actorUid: req.actor.uid, documentId: req.params.documentId, - file, routeType: 'attire', + verificationId: payload.verificationId, }); return res.status(200).json({ ...result, @@ -383,13 +569,22 @@ async function handleAttireUpload(req, res, next) { async function handleCertificateUpload(req, res, next) { try { const file = req.file; - if (!file) { - throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + if (file) { + const payload = parseBody(certificateUploadMetaSchema, req.body || {}); + const result = await uploadCertificate({ + actorUid: req.actor.uid, + file, + payload, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const payload = parseBody(certificateUploadMetaSchema, req.body || {}); - const result = await uploadCertificate({ + + const payload = parseBody(finalizedCertificateUploadSchema, req.body || {}); + const result = await finalizeCertificateUpload({ actorUid: req.actor.uid, - file, payload, }); return res.status(200).json({ @@ -464,9 +659,12 @@ export function createCoreRouter() { router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe); router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse); + router.post('/rapid-orders/process', requireAuth, requirePolicy('core.rapid-order.process', 'model'), handleRapidOrderProcess); router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload); router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload); + router.put('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleDocumentUpload); router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload); + router.put('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleAttireUpload); router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload); router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete); router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js index 392c9076..07ad0420 100644 --- a/backend/core-api/src/services/mobile-upload.js +++ b/backend/core-api/src/services/mobile-upload.js @@ -2,7 +2,7 @@ import { AppError } from '../lib/errors.js'; import { requireStaffContext } from './actor-context.js'; import { generateReadSignedUrl, uploadToGcs } from './storage.js'; import { query, withTransaction } from './db.js'; -import { createVerificationJob } from './verification-jobs.js'; +import { createVerificationJob, getVerificationJob } from './verification-jobs.js'; function safeName(value) { return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_'); @@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) { } } +function normalizeDocumentStatusFromVerification(status) { + switch (`${status || ''}`.toUpperCase()) { + case 'AUTO_PASS': + case 'APPROVED': + return 'VERIFIED'; + case 'AUTO_FAIL': + case 'REJECTED': + return 'REJECTED'; + default: + return 'PENDING'; + } +} + +async function resolveVerificationBackedUpload({ + actorUid, + verificationId, + subjectId, + allowedTypes, +}) { + if (!verificationId) { + throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400); + } + + const verification = await getVerificationJob(verificationId, actorUid); + if (subjectId && verification.subjectId && verification.subjectId !== subjectId) { + throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, { + verificationId, + subjectId, + verificationSubjectId: verification.subjectId, + }); + } + + if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) { + throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, { + verificationId, + verificationType: verification.type, + allowedTypes, + }); + } + + return { + verification, + fileUri: verification.fileUri, + status: normalizeDocumentStatusFromVerification(verification.status), + }; +} + export async function uploadProfilePhoto({ actorUid, file }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ @@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp }; } +export async function finalizeStaffDocumentUpload({ + actorUid, + documentId, + routeType, + verificationId, +}) { + const context = await requireStaffContext(actorUid); + const document = await requireDocument( + context.tenant.tenantId, + documentId, + routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM'] + ); + + const finalized = await resolveVerificationBackedUpload({ + actorUid, + verificationId, + subjectId: documentId, + allowedTypes: routeType === 'attire' + ? ['attire'] + : ['government_id', 'document', 'tax_form'], + }); + + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + verification_job_id, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET file_uri = EXCLUDED.file_uri, + status = EXCLUDED.status, + verification_job_id = EXCLUDED.verification_job_id, + metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + context.staff.staffId, + document.id, + finalized.fileUri, + finalized.status, + finalized.verification.verificationId, + JSON.stringify({ + verificationStatus: finalized.verification.status, + routeType, + finalizedFromVerification: true, + }), + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, finalized.fileUri); + return { + documentId: document.id, + documentType: document.document_type, + fileUri: finalized.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification: finalized.verification, + status: finalized.status, + }; +} + export async function uploadCertificate({ actorUid, file, payload }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ @@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) { }; } +export async function finalizeCertificateUpload({ actorUid, payload }) { + const context = await requireStaffContext(actorUid); + const finalized = await resolveVerificationBackedUpload({ + actorUid, + verificationId: payload.verificationId, + subjectId: payload.certificateType, + allowedTypes: ['certification'], + }); + + const certificateResult = await withTransaction(async (client) => { + const existing = await client.query( + ` + SELECT id + FROM certificates + WHERE tenant_id = $1 + AND staff_id = $2 + AND certificate_type = $3 + ORDER BY created_at DESC + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, context.staff.staffId, payload.certificateType] + ); + + const metadata = JSON.stringify({ + name: payload.name, + issuer: payload.issuer || null, + verificationStatus: finalized.verification.status, + finalizedFromVerification: true, + }); + + if (existing.rowCount > 0) { + return client.query( + ` + UPDATE certificates + SET certificate_number = $2, + expires_at = $3, + status = $4, + file_uri = $5, + verification_job_id = $6, + metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb, + updated_at = NOW() + WHERE id = $1 + RETURNING id + `, + [ + existing.rows[0].id, + payload.certificateNumber || null, + payload.expiresAt || null, + finalized.status, + finalized.fileUri, + finalized.verification.verificationId, + metadata, + ] + ); + } + + return client.query( + ` + INSERT INTO certificates ( + tenant_id, + staff_id, + certificate_type, + certificate_number, + issued_at, + expires_at, + status, + file_uri, + verification_job_id, + metadata + ) + VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.staff.staffId, + payload.certificateType, + payload.certificateNumber || null, + payload.expiresAt || null, + finalized.status, + finalized.fileUri, + finalized.verification.verificationId, + metadata, + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, finalized.fileUri); + return { + certificateId: certificateResult.rows[0].id, + certificateType: payload.certificateType, + fileUri: finalized.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification: finalized.verification, + status: finalized.status, + }; +} + export async function deleteCertificate({ actorUid, certificateType }) { const context = await requireStaffContext(actorUid); const result = await query( diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index d6613a07..f2193843 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -267,6 +267,25 @@ test('POST /core/rapid-orders/parse rejects unknown fields', async () => { assert.equal(res.body.code, 'VALIDATION_ERROR'); }); +test('POST /core/rapid-orders/process accepts text-only flow', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/process') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 2 servers ASAP for 4 hours', + locale: 'en-US', + timezone: 'America/New_York', + now: '2026-02-27T12:00:00.000Z', + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.transcript, 'string'); + assert.equal(res.body.parsed.orderType, 'ONE_TIME'); + assert.equal(Array.isArray(res.body.parsed.positions), true); + assert.equal(Array.isArray(res.body.catalog.roles), true); +}); + test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => { process.env.LLM_RATE_LIMIT_PER_MINUTE = '1'; const app = createApp(); diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 402dc345..8bdc257a 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -28,11 +28,14 @@ import { listAssignedShifts, listBusinessAccounts, listBusinessTeamMembers, + listBlockedStaff, listCancelledShifts, listCertificates, listCostCenters, listCoreTeam, listCoverageByDate, + listCoverageDispatchCandidates, + listCoverageDispatchTeams, listCompletedShifts, listEmergencyContacts, listFaqCategories, @@ -43,6 +46,7 @@ import { listOpenShifts, listTaxForms, listTimeCardEntries, + listSwapRequests, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -53,6 +57,7 @@ import { listStaffAvailability, listStaffBankAccounts, listStaffBenefits, + listStaffBenefitHistory, listTodayShifts, listVendorRoles, listVendors, @@ -91,11 +96,14 @@ const defaultQueryService = { listAssignedShifts, listBusinessAccounts, listBusinessTeamMembers, + listBlockedStaff, listCancelledShifts, listCertificates, listCostCenters, listCoreTeam, listCoverageByDate, + listCoverageDispatchCandidates, + listCoverageDispatchTeams, listCompletedShifts, listEmergencyContacts, listFaqCategories, @@ -106,6 +114,7 @@ const defaultQueryService = { listOpenShifts, listTaxForms, listTimeCardEntries, + listSwapRequests, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -116,6 +125,7 @@ const defaultQueryService = { listStaffAvailability, listStaffBankAccounts, listStaffBenefits, + listStaffBenefitHistory, listTodayShifts, listVendorRoles, listVendors, @@ -253,6 +263,42 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/coverage/blocked-staff', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listBlockedStaff(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + 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); @@ -622,6 +668,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/benefits/history', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listStaffBenefitHistory(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { try { const items = await queryService.listTimeCardEntries(req.actor.uid, req.query); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index cad050e9..e6e54e9b 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -52,6 +52,13 @@ function metadataBoolean(metadata, key, fallback = false) { return fallback; } +function membershipDisplayName(row) { + const firstName = row?.firstName || row?.metadata?.firstName || null; + const lastName = row?.lastName || row?.metadata?.lastName || null; + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim(); + return fullName || row?.name || row?.displayName || row?.email || row?.invitedEmail || null; +} + function getProfileCompletionFromMetadata(staffRow) { const metadata = staffRow?.metadata || {}; const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); @@ -171,17 +178,32 @@ export async function listRecentReorders(actorUid, limit) { o.id, o.title, o.starts_at AS "date", - COALESCE(cp.label, o.location_name) AS "hubName", - COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount", - COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType" + MAX(COALESCE(cp.label, o.location_name)) AS "hubName", + MAX(b.business_name) AS "clientName", + COALESCE(SUM(sr.workers_needed), 0)::INTEGER AS "positionCount", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + COALESCE(ROUND(AVG(sr.bill_rate_cents))::INTEGER, 0) AS "hourlyRateCents", + COALESCE( + SUM( + sr.bill_rate_cents + * sr.workers_needed + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ), + 0 + )::BIGINT AS "totalPriceCents", + COALESCE( + SUM(GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)), + 0 + )::NUMERIC(12,2) AS hours FROM orders o + JOIN businesses b ON b.id = o.business_id LEFT JOIN shifts s ON s.order_id = o.id LEFT JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE o.tenant_id = $1 AND o.business_id = $2 AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED') - GROUP BY o.id, cp.label + GROUP BY o.id ORDER BY o.starts_at DESC NULLS LAST LIMIT $3 `, @@ -498,10 +520,20 @@ export async function listHubManagers(actorUid, hubId) { hm.id AS "managerAssignmentId", bm.id AS "businessMembershipId", u.id AS "managerId", - COALESCE(u.display_name, u.email) AS name + u.display_name AS "displayName", + u.email, + bm.invited_email AS "invitedEmail", + bm.membership_status AS "membershipStatus", + bm.metadata, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''), + u.display_name, + u.email, + bm.invited_email + ) AS name FROM hub_managers hm JOIN business_memberships bm ON bm.id = hm.business_membership_id - JOIN users u ON u.id = bm.user_id + LEFT JOIN users u ON u.id = bm.user_id WHERE hm.tenant_id = $1 AND hm.hub_id = $2 ORDER BY name ASC @@ -520,15 +552,33 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate } sr.id AS "itemId", o.id AS "orderId", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + o.title AS "eventName", + b.business_name AS "clientName", + sr.role_name AS title, sr.role_name AS "roleName", - s.starts_at AS date, + to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, s.starts_at AS "startsAt", s.ends_at AS "endsAt", + to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI') AS "startTime", + to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') AS "endTime", sr.workers_needed AS "requiredWorkerCount", sr.assigned_count AS "filledCount", sr.bill_rate_cents AS "hourlyRateCents", + ROUND(COALESCE(sr.bill_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)::NUMERIC(12,2) AS hours, (sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents", + ROUND( + ( + sr.bill_rate_cents + * sr.workers_needed + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) AS "totalValue", COALESCE(cp.label, s.location_name) AS "locationName", + COALESCE(s.location_address, cp.address) AS "locationAddress", + hm.business_membership_id AS "hubManagerId", + COALESCE(u.display_name, u.email) AS "hubManagerName", s.status, COALESCE( json_agg( @@ -544,14 +594,34 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate } FROM shift_roles sr JOIN shifts s ON s.id = sr.shift_id JOIN orders o ON o.id = s.order_id + JOIN businesses b ON b.id = o.business_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN assignments a ON a.shift_role_id = sr.id LEFT JOIN staffs st ON st.id = a.staff_id + LEFT JOIN LATERAL ( + SELECT business_membership_id + FROM hub_managers + WHERE tenant_id = o.tenant_id + AND hub_id = s.clock_point_id + ORDER BY created_at ASC + LIMIT 1 + ) hm ON TRUE + LEFT JOIN business_memberships bm ON bm.id = hm.business_membership_id + LEFT JOIN users u ON u.id = bm.user_id WHERE o.tenant_id = $1 AND o.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz - GROUP BY sr.id, o.id, s.id, cp.label + GROUP BY + sr.id, + o.id, + s.id, + cp.label, + cp.address, + b.business_name, + hm.business_membership_id, + u.display_name, + u.email ORDER BY s.starts_at ASC, sr.role_name ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] @@ -633,6 +703,23 @@ export async function listTodayShifts(actorUid) { COALESCE(s.title, sr.role_name || ' shift') AS title, b.business_name AS "clientName", ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents", + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) AS "totalRate", + COALESCE( + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::INTEGER, + 0 + ) AS "totalRateCents", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, COALESCE(s.location_address, cp.address) AS "locationAddress", @@ -656,7 +743,7 @@ export async function listTodayShifts(actorUid) { AND a.staff_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at < $4::timestamptz - AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC `, [context.tenant.tenantId, context.staff.staffId, from, to] @@ -767,24 +854,43 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) { SELECT a.id AS "assignmentId", s.id AS "shiftId", + b.business_name AS "clientName", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE( + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::INTEGER, + 0 + ) AS "totalRateCents", + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) AS "totalRate", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", a.status FROM assignments a 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 WHERE a.tenant_id = $1 AND a.staff_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz - AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') ORDER BY s.starts_at ASC `, [context.tenant.tenantId, context.staff.staffId, range.start, range.end] @@ -800,19 +906,67 @@ 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, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE( + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::INTEGER, + 0 + ) AS "totalRateCents", + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) 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 @@ -829,21 +983,72 @@ 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, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE( + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::INTEGER, + 0 + ) AS "totalRateCents", + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) 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 @@ -862,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 `, [ @@ -911,8 +1116,11 @@ export async function getStaffShiftDetail(actorUid, shiftId) { s.id AS "shiftId", s.title, o.description, + b.business_name AS "clientName", COALESCE(cp.label, s.location_name) AS location, - s.location_address AS address, + COALESCE(s.location_address, cp.address) AS address, + COALESCE(s.latitude, cp.latitude) AS latitude, + COALESCE(s.longitude, cp.longitude) AS longitude, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", @@ -923,6 +1131,23 @@ export async function getStaffShiftDetail(actorUid, shiftId) { sr.id AS "roleId", sr.role_name AS "roleName", sr.pay_rate_cents AS "hourlyRateCents", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE( + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::INTEGER, + 0 + ) AS "totalRateCents", + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + )::numeric / 100, + 2 + ) AS "totalRate", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", sr.workers_needed AS "requiredCount", sr.assigned_count AS "confirmedCount", @@ -930,6 +1155,7 @@ export async function getStaffShiftDetail(actorUid, shiftId) { app.status AS "applicationStatus" FROM shifts s JOIN orders o ON o.id = s.order_id + JOIN businesses b ON b.id = s.business_id JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3 @@ -981,12 +1207,41 @@ export async function listCompletedShifts(actorUid) { a.id AS "assignmentId", s.id AS "shiftId", s.title, + b.business_name AS "clientName", COALESCE(cp.label, s.location_name) AS location, - s.starts_at AS date, + to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", + COALESCE(ts.status, 'PENDING') AS "timesheetStatus", COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + COALESCE( + ts.gross_pay_cents, + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::BIGINT + ) AS "totalRateCents", + ROUND( + COALESCE( + ts.gross_pay_cents, + ROUND( + ( + COALESCE(sr.pay_rate_cents, 0) + * GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0) + ) + )::BIGINT + )::numeric / 100, + 2 + ) AS "totalRate", COALESCE(rp.status, 'PENDING') AS "paymentStatus" FROM assignments a JOIN shifts s ON s.id = a.shift_id + JOIN businesses b ON b.id = s.business_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN timesheets ts ON ts.assignment_id = a.id LEFT JOIN recent_payments rp ON rp.assignment_id = a.id @@ -1003,19 +1258,22 @@ export async function listCompletedShifts(actorUid) { export async function getProfileSectionsStatus(actorUid) { const context = await requireStaffContext(actorUid); const completion = getProfileCompletionFromMetadata(context.staff); - const [documents, certificates, benefits] = await Promise.all([ + const [documents, certificates, benefits, attire, taxForms] = await Promise.all([ listProfileDocuments(actorUid), listCertificates(actorUid), listStaffBenefits(actorUid), + listAttireChecklist(actorUid), + listTaxForms(actorUid), ]); return { personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations, emergencyContactCompleted: completion.fields.emergencyContact, experienceCompleted: completion.fields.skills && completion.fields.industries, - attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'), - taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'), + attireCompleted: attire.every((item) => item.status === 'VERIFIED'), + taxFormsCompleted: taxForms.every((item) => item.status === 'VERIFIED' || item.status === 'SUBMITTED'), benefitsConfigured: benefits.length > 0, certificateCount: certificates.length, + documentCount: documents.length, }; } @@ -1054,6 +1312,7 @@ export async function listProfileDocuments(actorUid) { d.id AS "documentId", d.document_type AS "documentType", d.name, + COALESCE(d.metadata->>'description', '') AS description, sd.id AS "staffDocumentId", sd.file_uri AS "fileUri", COALESCE(sd.status, 'NOT_UPLOADED') AS status, @@ -1065,7 +1324,7 @@ export async function listProfileDocuments(actorUid) { AND sd.tenant_id = d.tenant_id AND sd.staff_id = $2 WHERE d.tenant_id = $1 - AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM') + AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID') ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId] @@ -1142,7 +1401,343 @@ export async function listStaffBenefits(actorUid) { return result.rows; } +export async function listStaffBenefitHistory(actorUid, { limit, offset } = {}) { + const context = await requireStaffContext(actorUid); + const safeLimit = parseLimit(limit, 20, 100); + const safeOffset = Number.isFinite(Number(offset)) && Number(offset) >= 0 ? Number(offset) : 0; + const result = await query( + ` + SELECT + id AS "historyId", + benefit_id AS "benefitId", + benefit_type AS "benefitType", + title, + status, + effective_at AS "effectiveAt", + ended_at AS "endedAt", + tracked_hours AS "trackedHours", + target_hours AS "targetHours", + notes, + metadata + FROM staff_benefit_history + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY effective_at DESC, created_at DESC + LIMIT $3 OFFSET $4 + `, + [context.tenant.tenantId, context.staff.staffId, safeLimit, safeOffset] + ); + return result.rows; +} + +export async function listSwapRequests(actorUid, { shiftId, status = 'OPEN', limit } = {}) { + const context = await requireClientContext(actorUid); + 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", + 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 + AND sf.business_id = $2 + ORDER BY st.average_rating DESC, st.full_name ASC + `, + [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; +} + +export async function listBlockedStaff(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` @@ -1150,14 +1745,14 @@ export async function listCoreTeam(actorUid) { 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 - FROM staff_favorites sf - JOIN staffs st ON st.id = sf.staff_id - WHERE sf.tenant_id = $1 - AND sf.business_id = $2 - ORDER BY st.average_rating DESC, st.full_name ASC + sb.reason, + sb.issue_flags AS "issueFlags", + sb.created_at AS "blockedAt" + FROM staff_blocks sb + JOIN staffs st ON st.id = sb.staff_id + WHERE sb.tenant_id = $1 + AND sb.business_id = $2 + ORDER BY sb.created_at DESC, st.full_name ASC `, [context.tenant.tenantId, context.business.businessId] ); @@ -1224,19 +1819,29 @@ export async function listBusinessTeamMembers(actorUid) { SELECT bm.id AS "businessMembershipId", u.id AS "userId", - COALESCE(u.display_name, u.email) AS name, + u.display_name AS "displayName", u.email, - bm.business_role AS role + bm.invited_email AS "invitedEmail", + bm.business_role AS role, + bm.membership_status AS "membershipStatus", + bm.metadata FROM business_memberships bm - JOIN users u ON u.id = bm.user_id + LEFT JOIN users u ON u.id = bm.user_id WHERE bm.tenant_id = $1 AND bm.business_id = $2 - AND bm.membership_status = 'ACTIVE' - ORDER BY name ASC + AND bm.membership_status IN ('ACTIVE', 'INVITED') + ORDER BY COALESCE(u.display_name, u.email, bm.invited_email) ASC `, [context.tenant.tenantId, context.business.businessId] ); - return result.rows; + return result.rows.map((row) => ({ + businessMembershipId: row.businessMembershipId, + userId: row.userId, + name: membershipDisplayName(row), + email: row.email || row.invitedEmail || null, + role: row.role, + membershipStatus: row.membershipStatus, + })); } export async function getReportSummary(actorUid, { startDate, endDate }) { @@ -1645,9 +2250,12 @@ export async function listTaxForms(actorUid) { SELECT d.id AS "documentId", d.name AS "formType", + COALESCE(d.metadata->>'description', '') AS description, sd.id AS "staffDocumentId", + sd.file_uri AS "fileUri", COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, - COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields + COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields, + sd.expires_at AS "expiresAt" FROM documents d LEFT JOIN staff_documents sd ON sd.document_id = d.id 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 61be8e53..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)}`; @@ -37,6 +39,7 @@ async function apiCall(path, { idempotencyKey, body, expectedStatus = 200, + allowFailure = false, } = {}) { const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; @@ -49,6 +52,12 @@ async function apiCall(path, { body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await readJson(response); + if (allowFailure) { + return { + statusCode: response.status, + body: payload, + }; + } if (response.status !== expectedStatus) { throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); } @@ -87,6 +96,70 @@ async function uploadFile(path, token, { return payload; } +async function finalizeVerifiedUpload({ + token, + uploadCategory, + filename, + contentType, + content, + finalizePath, + finalizeMethod = 'PUT', + verificationType, + subjectId, + rules = {}, + finalizeBody = {}, +}) { + const uploaded = await uploadFile('/upload-file', token, { + filename, + contentType, + content, + fields: { + visibility: 'private', + category: uploadCategory, + }, + }); + + const signed = await apiCall('/create-signed-url', { + method: 'POST', + token, + body: { + fileUri: uploaded.fileUri, + expiresInSeconds: 300, + }, + }); + + const verification = await apiCall('/verifications', { + method: 'POST', + token, + body: { + type: verificationType, + subjectType: 'worker', + subjectId, + fileUri: uploaded.fileUri, + rules, + }, + expectedStatus: 202, + }); + + const finalized = await apiCall(finalizePath, { + method: finalizeMethod, + token, + body: { + ...finalizeBody, + verificationId: verification.verificationId, + fileUri: signed.signedUrl, + photoUrl: signed.signedUrl, + }, + }); + + return { + uploaded, + signed, + verification, + finalized, + }; +} + async function signInClient() { return apiCall('/auth/client/sign-in', { method: 'POST', @@ -104,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, @@ -120,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, @@ -210,6 +296,10 @@ async function main() { token: ownerSession.sessionToken, }); assert.ok(Array.isArray(clientReorders.items)); + if (clientReorders.items[0]) { + assert.equal(typeof clientReorders.items[0].hourlyRateCents, 'number'); + assert.equal(typeof clientReorders.items[0].totalPriceCents, 'number'); + } logStep('client.reorders.ok', { count: clientReorders.items.length }); const billingAccounts = await apiCall('/client/billing/accounts', { @@ -267,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, }); @@ -313,10 +410,37 @@ async function main() { assert.ok(Array.isArray(teamMembers.items)); logStep('client.team-members.ok', { count: teamMembers.items.length }); + const createdShiftManager = await apiCall('/client/shift-managers', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('create-shift-manager'), + body: { + hubId: fixture.clockPoint.id, + email: `smoke.manager.${Date.now()}@krowd.com`, + firstName: 'Smoke', + lastName: 'Manager', + phone: '+15550009999', + }, + }); + assert.ok(createdShiftManager.businessMembershipId); + assert.equal(createdShiftManager.membershipStatus, 'INVITED'); + assert.ok(createdShiftManager.managerAssignmentId); + logStep('client.shift-manager.create.ok', createdShiftManager); + + const teamMembersAfterCreate = await apiCall('/client/team-members', { + token: ownerSession.sessionToken, + }); + assert.ok(teamMembersAfterCreate.items.some((item) => item.businessMembershipId === createdShiftManager.businessMembershipId)); + logStep('client.team-members.after-create.ok', { count: teamMembersAfterCreate.items.length }); + const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, { token: ownerSession.sessionToken, }); assert.ok(Array.isArray(viewedOrders.items)); + if (viewedOrders.items[0]) { + assert.ok(viewedOrders.items[0].clientName); + assert.equal(typeof viewedOrders.items[0].hourlyRate, 'number'); + } logStep('client.orders.view.ok', { count: viewedOrders.items.length }); const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { @@ -519,7 +643,7 @@ async function main() { assert.ok(createdPermanentOrder.orderId); logStep('client.orders.create-permanent.ok', createdPermanentOrder); - const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, { + const editedOrderCopy = await apiCall(`/client/orders/${createdRecurringOrder.orderId}/edit`, { method: 'POST', token: ownerSession.sessionToken, idempotencyKey: uniqueKey('order-edit'), @@ -528,6 +652,7 @@ async function main() { }, }); assert.ok(editedOrderCopy.orderId); + assert.notEqual(editedOrderCopy.orderId, createdRecurringOrder.orderId); logStep('client.orders.edit-copy.ok', editedOrderCopy); const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, { @@ -538,6 +663,7 @@ async function main() { reason: 'Smoke cancel validation', }, }); + assert.equal(cancelledOrder.futureOnly, true); logStep('client.orders.cancel.ok', cancelledOrder); const coverageReview = await apiCall('/client/coverage/reviews', { @@ -609,6 +735,14 @@ async function main() { token: staffAuth.idToken, }); assert.ok(Array.isArray(staffDashboard.recommendedShifts)); + if (staffDashboard.todaysShifts[0]) { + assert.ok(staffDashboard.todaysShifts[0].clientName); + assert.equal(typeof staffDashboard.todaysShifts[0].totalRate, 'number'); + } + if (staffDashboard.recommendedShifts[0]) { + assert.ok(staffDashboard.recommendedShifts[0].clientName); + assert.equal(typeof staffDashboard.recommendedShifts[0].totalRate, 'number'); + } logStep('staff.dashboard.ok', { todaysShifts: staffDashboard.todaysShifts.length, recommendedShifts: staffDashboard.recommendedShifts.length, @@ -672,7 +806,9 @@ async function main() { }); const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id) || openShifts.items[0]; + const blockedApplyCandidate = openShifts.items.find((shift) => shift.shiftId !== openShift.shiftId); assert.ok(openShift); + assert.ok(blockedApplyCandidate); logStep('staff.shifts.open.ok', { count: openShifts.items.length }); const pendingShifts = await apiCall('/staff/shifts/pending', { @@ -693,12 +829,22 @@ async function main() { token: staffAuth.idToken, }); assert.ok(Array.isArray(completedShifts.items)); + if (completedShifts.items[0]) { + assert.ok(completedShifts.items[0].clientName); + assert.ok(completedShifts.items[0].date); + assert.ok(completedShifts.items[0].startTime); + assert.ok(completedShifts.items[0].endTime); + assert.equal(typeof completedShifts.items[0].hourlyRate, 'number'); + assert.equal(typeof completedShifts.items[0].totalRate, 'number'); + } logStep('staff.shifts.completed.ok', { count: completedShifts.items.length }); const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, { token: staffAuth.idToken, }); assert.equal(shiftDetail.shiftId, openShift.shiftId); + assert.equal(typeof shiftDetail.latitude, 'number'); + assert.equal(typeof shiftDetail.longitude, 'number'); logStep('staff.shifts.detail.ok', shiftDetail); const profileSections = await apiCall('/staff/profile/sections', { @@ -727,6 +873,7 @@ async function main() { token: staffAuth.idToken, }); assert.ok(Array.isArray(profileDocumentsBefore.items)); + assert.ok(profileDocumentsBefore.items.every((item) => item.documentType !== 'ATTIRE')); logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length }); const attireChecklistBefore = await apiCall('/staff/profile/attire', { @@ -765,6 +912,13 @@ async function main() { assert.ok(Array.isArray(benefits.items)); logStep('staff.profile.benefits.ok', { count: benefits.items.length }); + const benefitHistory = await apiCall('/staff/profile/benefits/history?limit=10', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(benefitHistory.items)); + assert.ok(benefitHistory.items.length >= 1); + logStep('staff.profile.benefits.history.ok', { count: benefitHistory.items.length }); + const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, { token: staffAuth.idToken, }); @@ -1054,7 +1208,18 @@ async function main() { assert.ok(clockOut.securityProofId); logStep('staff.clock-out.ok', clockOut); - const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { + const submittedCompletedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/submit-for-approval`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-submit-approval'), + body: { + note: 'Smoke approval submission', + }, + }); + assert.equal(submittedCompletedShift.submitted, true); + logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift); + + const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/request-swap`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-swap'), @@ -1064,6 +1229,107 @@ 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, + idempotencyKey: uniqueKey('coverage-block'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 2, + markAsBlocked: true, + markAsFavorite: false, + issueFlags: ['LATE_CLOCK_IN'], + feedback: 'Smoke blocked staff test', + }, + }); + assert.equal(blockedReview.markAsBlocked, true); + logStep('client.coverage.block.ok', blockedReview); + + const blockedStaff = await apiCall('/client/coverage/blocked-staff', { + token: ownerSession.sessionToken, + }); + assert.ok(blockedStaff.items.some((item) => item.staffId === fixture.staff.ana.id)); + logStep('client.coverage.blocked-staff.ok', { count: blockedStaff.items.length }); + + const blockedApplyAttempt = await apiCall(`/staff/shifts/${blockedApplyCandidate.shiftId}/apply`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-apply-blocked'), + body: { + roleId: blockedApplyCandidate.roleId, + }, + allowFailure: true, + }); + assert.equal(blockedApplyAttempt.statusCode, 409); + assert.equal(blockedApplyAttempt.body?.code, 'STAFF_BLOCKED'); + logStep('staff.shifts.apply-blocked.ok', blockedApplyAttempt.body); + + const unblockedReview = await apiCall('/client/coverage/reviews', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('coverage-unblock'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 5, + markAsBlocked: false, + markAsFavorite: true, + issueFlags: [], + feedback: 'Smoke unblock cleanup', + }, + }); + assert.equal(unblockedReview.markAsBlocked, false); + logStep('client.coverage.unblock.ok', unblockedReview); + const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, { filename: 'profile-photo.jpg', contentType: 'image/jpeg', @@ -1072,35 +1338,63 @@ async function main() { assert.ok(uploadedProfilePhoto.fileUri); logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto); - const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, { + const uploadedGovId = await finalizeVerifiedUpload({ + token: staffAuth.idToken, + uploadCategory: 'staff-document', filename: 'government-id.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-government-id'), + finalizePath: `/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, + finalizeMethod: 'PUT', + verificationType: 'government_id', + subjectId: fixture.documents.governmentId.id, + rules: { + documentId: fixture.documents.governmentId.id, + }, }); - assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id); - logStep('staff.profile.document.upload.ok', uploadedGovId); + assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id); + logStep('staff.profile.document.upload.ok', uploadedGovId.finalized); - const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, { + const uploadedAttire = await finalizeVerifiedUpload({ + token: staffAuth.idToken, + uploadCategory: 'staff-attire', filename: 'black-shirt.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-black-shirt'), + finalizePath: `/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, + finalizeMethod: 'PUT', + verificationType: 'attire', + subjectId: fixture.documents.attireBlackShirt.id, + rules: { + dressCode: 'Black shirt', + }, }); - assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id); - logStep('staff.profile.attire.upload.ok', uploadedAttire); + assert.equal(uploadedAttire.finalized.documentId, fixture.documents.attireBlackShirt.id); + logStep('staff.profile.attire.upload.ok', uploadedAttire.finalized); const certificateType = `ALCOHOL_SERVICE_${Date.now()}`; - const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, { + const uploadedCertificate = await finalizeVerifiedUpload({ + token: staffAuth.idToken, + uploadCategory: 'staff-certificate', filename: 'certificate.pdf', contentType: 'application/pdf', content: Buffer.from('fake-certificate'), - fields: { + finalizePath: '/staff/profile/certificates', + finalizeMethod: 'POST', + verificationType: 'certification', + subjectId: certificateType, + rules: { + certificateName: 'Alcohol Service Permit', + certificateIssuer: 'Demo Issuer', + }, + finalizeBody: { certificateType, name: 'Alcohol Service Permit', issuer: 'Demo Issuer', }, }); - assert.equal(uploadedCertificate.certificateType, certificateType); - logStep('staff.profile.certificate.upload.ok', uploadedCertificate); + assert.equal(uploadedCertificate.finalized.certificateType, certificateType); + logStep('staff.profile.certificate.upload.ok', uploadedCertificate.finalized); const profileDocumentsAfter = await apiCall('/staff/profile/documents', { token: staffAuth.idToken, diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js index 3dcc971a..8e7e5da5 100644 --- a/backend/unified-api/src/routes/proxy.js +++ b/backend/unified-api/src/routes/proxy.js @@ -20,14 +20,15 @@ const DIRECT_CORE_ALIASES = [ { methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/rapid-orders\/process$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` }, { - methods: new Set(['POST']), + methods: new Set(['POST', 'PUT']), pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/, targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`, }, { - methods: new Set(['POST']), + methods: new Set(['POST', 'PUT']), pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/, targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`, }, diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 113cfbec..02c42355 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -182,3 +182,56 @@ test('proxy forwards direct core upload aliases to core api', async () => { assert.equal(res.status, 200); assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload'); }); + +test('proxy forwards PUT document upload aliases to core api', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + let seenMethod = null; + const app = createApp({ + fetchImpl: async (url, init = {}) => { + seenUrl = `${url}`; + seenMethod = init.method; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app) + .put('/staff/profile/documents/doc-1/upload') + .set('Authorization', 'Bearer test-token') + .send({ verificationId: 'verification-1' }); + + assert.equal(res.status, 200); + assert.equal(seenMethod, 'PUT'); + assert.equal(seenUrl, 'https://core.example/core/staff/documents/doc-1/upload'); +}); + +test('proxy forwards rapid order process alias to core api', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + const app = createApp({ + fetchImpl: async (url) => { + seenUrl = `${url}`; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app) + .post('/rapid-orders/process') + .set('Authorization', 'Bearer test-token') + .send({ text: 'Need 2 servers ASAP for 4 hours' }); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://core.example/core/rapid-orders/process'); +}); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 2025ab4e..acc94c0c 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -22,10 +22,13 @@ What was validated live against the deployed stack: - staff auth bootstrap - client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports - 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 availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request +- 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, swap request, and completed-shift submission - direct file upload helpers and verification job creation through the unified host - client and staff sign-out @@ -107,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 @@ -143,6 +160,9 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend - [Authentication](./authentication.md) - [Unified API](./unified-api.md) +- [Mobile Coding Agent Spec](./mobile-coding-agent-spec.md) +- [Mobile Frontend Implementation Spec](./mobile-frontend-implementation-spec.md) +- [Staff Shifts](./staff-shifts.md) - [Core API](./core-api.md) - [Command API](./command-api.md) - [Query API](./query-api.md) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md new file mode 100644 index 00000000..a369f422 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -0,0 +1,388 @@ +# Mobile Coding Agent Spec + +This document is the frontend handoff spec for an AI coding agent working on the mobile applications against the v2 backend. + +Use this as the primary implementation brief. + +Base URL: + +- `https://krow-api-v2-933560802882.us-central1.run.app` + +Supporting docs: + +- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/authentication.md` +- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/unified-api.md` +- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md` +- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/staff-shifts.md` + +## 1) Non-negotiable rules + +- Use the unified base URL only. +- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend. +- Send `Authorization: Bearer ` on protected routes. +- Send `Idempotency-Key` on every write route. +- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. +- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. + +## 2) What is implemented now + +Safe to build against now: + +- client auth/session +- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, reports +- client coverage reviews, worker rating, block/unblock, late-worker cancellation +- client swap review and dispatch-team management +- staff auth/session +- staff dashboard, availability, payments, shifts, profile, tax forms, bank accounts, benefits, documents, attire, certificates +- staff clock-in, clock-out, location streaming, swap request, completed-shift submission +- upload, signed URL, verification, and rapid-order processing + +Not part of this implementation spec: + +- backend `/auth/refresh` +- SMS or email notification fallback +- AI-driven report insights +- AI-driven personalized shift matching +- full NFC attestation enforcement +- chat backend + +## 3) Auth implementation + +### Client app + +Use: + +- `POST /auth/client/sign-in` +- `POST /auth/client/sign-up` +- `GET /auth/session` +- `POST /auth/client/sign-out` + +Do not build a separate refresh route. + +Token refresh remains on the Firebase client SDK side. + +### Staff app + +Use: + +- `POST /auth/staff/phone/start` +- `POST /auth/staff/phone/verify` +- `GET /auth/session` +- `POST /auth/staff/sign-out` + +Important: + +- `POST /auth/staff/phone/start` can return `mode = CLIENT_FIREBASE_SDK` +- when that happens, the app must complete Firebase phone verification on-device +- after that, the app must call `POST /auth/staff/phone/verify` with the Firebase `idToken` + +Do not assume staff auth is a fully backend-managed OTP flow. + +## 4) Core data model assumptions + +- `order`: client-facing staffing request +- `shift`: a scheduled work instance under an order +- `shiftRole`: a role slot inside a shift +- `application`: worker applies to a `shiftRole` +- `assignment`: worker is actually attached to a shift + +Rules: + +- `GET /staff/shifts/open` returns opportunities, not assignments +- `GET /staff/shifts/assigned` returns active assigned shifts +- `GET /client/orders/view` is the timeline/read model for client +- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only + +## 5) Client app screen mapping + +### Home + +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` + +### Billing + +- `GET /client/billing/accounts` +- `GET /client/billing/invoices/pending` +- `GET /client/billing/invoices/history` +- `GET /client/billing/current-bill` +- `GET /client/billing/savings` +- `GET /client/billing/spend-breakdown` +- `POST /client/billing/invoices/:invoiceId/approve` +- `POST /client/billing/invoices/:invoiceId/dispute` + +### Coverage + +- `GET /client/coverage?date=YYYY-MM-DD` +- `GET /client/coverage/stats?date=YYYY-MM-DD` +- `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` + +Coverage review payload: + +```json +{ + "assignmentId": "uuid", + "rating": 4, + "comment": "Strong performance on the shift", + "markAsBlocked": false +} +``` + +Rules: + +- worker rating happens through `POST /client/coverage/reviews` +- the same endpoint also supports `markAsFavorite` to add or remove a worker from business favorites +- blocking a worker is done through the same endpoint using `markAsBlocked` +- dispatch ranking order is: + 1. `CORE` + 2. `CERTIFIED_LOCATION` + 3. `MARKETPLACE` + +Swap management flow: + +1. worker requests swap +2. backend moves original assignment to `SWAP_REQUESTED` +3. replacement workers see the shift in `GET /staff/shifts/open` +4. client/ops reads `GET /client/coverage/swap-requests` +5. client/ops reads `GET /client/coverage/dispatch-candidates` +6. client/ops resolves or cancels the swap request +7. if unresolved and expired, backend auto-cancels it + +### Orders + +- `GET /client/orders/view` +- `GET /client/orders/:orderId/reorder-preview` +- `POST /client/orders/one-time` +- `POST /client/orders/recurring` +- `POST /client/orders/permanent` +- `POST /client/orders/:orderId/edit` +- `POST /client/orders/:orderId/cancel` +- `POST /rapid-orders/process` + +Rules: + +- use `POST /rapid-orders/process` for the single-call rapid-order flow +- recent orders should expect total price and hourly rate fields +- order edit and cancel only affect future shifts + +### Hubs and managers + +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `POST /client/shift-managers` +- `POST /client/hubs` +- `PUT /client/hubs/:hubId` +- `DELETE /client/hubs/:hubId` +- `POST /client/hubs/:hubId/assign-nfc` +- `POST /client/hubs/:hubId/managers` + +Rules: + +- `POST /client/shift-managers` creates an invited manager identity +- if `hubId` is present, backend links the manager to that hub too + +### Reports + +- `GET /client/reports/summary?date=YYYY-MM-DD` +- `GET /client/reports/daily-ops?date=YYYY-MM-DD` +- `GET /client/reports/spend?date=YYYY-MM-DD` +- `GET /client/reports/coverage?date=YYYY-MM-DD` +- `GET /client/reports/forecast?date=YYYY-MM-DD` +- `GET /client/reports/performance?date=YYYY-MM-DD` +- `GET /client/reports/no-show?date=YYYY-MM-DD` + +Important: + +- these are operational reports +- this is not the same as the separate AI insights research issue + +## 6) Staff app screen mapping + +### Home + +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` + +### Availability + +- `GET /staff/availability` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` + +### Find shifts + +- `GET /staff/shifts/open` +- `POST /staff/shifts/:shiftId/apply` + +Rule: + +- use `roleId` from the open-shifts response + +### My shifts + +- `GET /staff/shifts/pending` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `POST /staff/shifts/:shiftId/submit-for-approval` + +Staff shift detail and list rules: + +- assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` +- shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` +- completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate` + +### Clock in / clock out + +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `POST /staff/location-streams` + +Clock-in payload rules: + +- if using NFC, send `nfcTagId` +- if using geo, send `latitude`, `longitude`, `accuracyMeters` +- send `overrideReason` only when geo override is allowed +- send `proofNonce` and `proofTimestamp` on attendance writes +- send `attestationProvider` and `attestationToken` only if the device has them + +Clock-in read rules: + +`GET /staff/clock-in/shifts/today` returns fields including: + +- `clientName` +- `hourlyRate` +- `totalRate` +- `latitude` +- `longitude` +- `clockInMode` +- `allowClockInOverride` +- `geofenceRadiusMeters` +- `nfcTagId` + +Policy values: + +- `NFC_REQUIRED` +- `GEO_REQUIRED` +- `EITHER` + +### Payments + +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` + +### Profile + +- `GET /staff/profile/sections` +- `GET /staff/profile/personal-info` +- `GET /staff/profile/industries` +- `GET /staff/profile/skills` +- `GET /staff/profile/documents` +- `GET /staff/profile/attire` +- `GET /staff/profile/tax-forms` +- `GET /staff/profile/emergency-contacts` +- `GET /staff/profile/certificates` +- `GET /staff/profile/bank-accounts` +- `GET /staff/profile/benefits` +- `GET /staff/profile/benefits/history` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `PUT /staff/profile/personal-info` +- `PUT /staff/profile/experience` +- `PUT /staff/profile/locations` +- `POST /staff/profile/emergency-contacts` +- `PUT /staff/profile/emergency-contacts/:contactId` +- `PUT /staff/profile/tax-forms/:formType` +- `POST /staff/profile/tax-forms/:formType/submit` +- `POST /staff/profile/bank-accounts` +- `PUT /staff/profile/privacy` + +Profile data rules: + +- `GET /staff/profile/documents` returns documents only +- `GET /staff/profile/attire` returns attire only +- `GET /staff/profile/tax-forms` returns tax forms only +- `GET /staff/profile/certificates` returns certificates only +- benefit summary and benefit history are separate endpoints + +### FAQ + +- `GET /staff/faqs` +- `GET /staff/faqs/search?q=...` + +## 7) Upload flows + +### General upload pattern + +For documents, attire, and certificates: + +1. `POST /upload-file` +2. `POST /create-signed-url` +3. upload bytes to storage +4. `POST /verifications` +5. finalize using the appropriate staff route + +Staff upload routes: + +- `POST /staff/profile/photo` +- `POST /staff/profile/documents/:documentId/upload` +- `PUT /staff/profile/documents/:documentId/upload` +- `POST /staff/profile/attire/:documentId/upload` +- `PUT /staff/profile/attire/:documentId/upload` +- `POST /staff/profile/certificates` +- `DELETE /staff/profile/certificates/:certificateId` + +Rules: + +- backend treats verification-linked file state as the source of truth +- frontend may still send `fileUri` or `photoUrl`, but verification linkage wins + +## 8) What the coding agent should not assume + +- do not invent a backend refresh route +- do not assume swap is staff-only; there is now a client/ops review side +- do not assume documents and attire share the same read endpoint +- do not assume backend direct CRUD on internal services +- do not assume AI reports, SMS fallback, or full NFC attestation are available + +## 9) Suggested implementation order for the coding agent + +1. auth/session flows +2. client home + orders + coverage +3. staff home + shifts + clock-in +4. profile sections and upload flows +5. reports and billing polish +6. swap review and dispatch-team management + +## 10) Definition of done for frontend integration + +Frontend implementation is aligned when: + +- every screen calls the unified v2 routes only +- every write sends an `Idempotency-Key` +- staff shift apply uses `roleId` from open shifts +- clock-in respects `clockInMode` +- swap request uses the staff endpoint and swap review uses the client coverage endpoints +- documents, attire, certificates, and tax forms use their correct route families diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md new file mode 100644 index 00000000..a72f198a --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -0,0 +1,305 @@ +# Mobile Frontend Implementation Spec + +This is the shortest path for frontend to implement the v2 mobile clients against the unified backend. + +Base URL: + +- `https://krow-api-v2-933560802882.us-central1.run.app` + +Use this doc together with: + +- [Authentication](./authentication.md) +- [Unified API](./unified-api.md) +- [Staff Shifts](./staff-shifts.md) + +## 1) Global rules + +- Use unified routes only. +- Send `Authorization: Bearer ` on protected routes. +- Send `Idempotency-Key` on all write routes. +- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend. + +## 2) Core model frontend should assume + +- `order` is the client-facing request for staffing. +- `shift` is the concrete scheduled unit of work under an order. +- `shiftRole` is the role slot inside a shift that staff apply to. +- `assignment` is the worker-to-shift record once a worker is attached. + +Important consequences: + +- `GET /staff/shifts/open` returns open shift-role opportunities. +- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. +- `GET /client/orders/view` is the timeline/read model for the client app. +- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. + +## 3) Auth implementation + +### Client app + +- sign in with `POST /auth/client/sign-in` +- sign up with `POST /auth/client/sign-up` +- hydrate session with `GET /auth/session` +- sign out with `POST /auth/client/sign-out` + +### Staff app + +- start phone auth with `POST /auth/staff/phone/start` +- complete phone auth with `POST /auth/staff/phone/verify` +- hydrate session with `GET /auth/session` +- sign out with `POST /auth/staff/sign-out` + +Token refresh: + +- keep using Firebase client SDK refresh behavior +- there is no backend `/auth/refresh` route + +## 4) Client app screen mapping + +### Home / dashboard + +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` + +### Billing / payments + +- `GET /client/billing/accounts` +- `GET /client/billing/invoices/pending` +- `GET /client/billing/invoices/history` +- `GET /client/billing/current-bill` +- `GET /client/billing/savings` +- `GET /client/billing/spend-breakdown` +- `POST /client/billing/invoices/:invoiceId/approve` +- `POST /client/billing/invoices/:invoiceId/dispute` + +### Coverage + +- `GET /client/coverage?date=YYYY-MM-DD` +- `GET /client/coverage/stats?date=YYYY-MM-DD` +- `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. + +Payload may include: + +```json +{ + "staffId": "uuid", + "assignmentId": "uuid", + "rating": 4, + "feedback": "Strong performance on the shift", + "markAsFavorite": true, + "markAsBlocked": false +} +``` + +If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. 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` +- `GET /client/orders/:orderId/reorder-preview` +- `POST /client/orders/one-time` +- `POST /client/orders/recurring` +- `POST /client/orders/permanent` +- `POST /client/orders/:orderId/edit` +- `POST /client/orders/:orderId/cancel` + +Rapid-order flow: + +- use `POST /rapid-orders/process` for the single-call transcribe-and-parse flow + +### Hubs and managers + +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `POST /client/shift-managers` +- `POST /client/hubs` +- `PUT /client/hubs/:hubId` +- `DELETE /client/hubs/:hubId` +- `POST /client/hubs/:hubId/assign-nfc` +- `POST /client/hubs/:hubId/managers` + +`POST /client/shift-managers` is the fastest path to create an invited manager identity for a business. If `hubId` is provided, backend also links that manager to the hub. + +### Reports + +- `GET /client/reports/summary?date=YYYY-MM-DD` +- `GET /client/reports/daily-ops?date=YYYY-MM-DD` +- `GET /client/reports/spend?date=YYYY-MM-DD` +- `GET /client/reports/coverage?date=YYYY-MM-DD` +- `GET /client/reports/forecast?date=YYYY-MM-DD` +- `GET /client/reports/performance?date=YYYY-MM-DD` +- `GET /client/reports/no-show?date=YYYY-MM-DD` + +## 5) Staff app screen mapping + +### Home / dashboard + +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` + +### Availability + +- `GET /staff/availability` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` + +### Find shifts + +- `GET /staff/shifts/open` +- `POST /staff/shifts/:shiftId/apply` + +Rule: + +- send the `roleId` from the open-shifts response +- this is the concrete `shift_roles.id` + +### My shifts + +- `GET /staff/shifts/pending` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `POST /staff/shifts/:shiftId/submit-for-approval` + +Current swap behavior: + +- backend records the swap request +- assignment moves to `SWAP_REQUESTED` +- shift becomes visible in the replacement pool +- 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 + +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `POST /staff/location-streams` + +Frontend should respect: + +- `clockInMode` +- `allowClockInOverride` +- `latitude` +- `longitude` +- `geofenceRadiusMeters` +- `nfcTagId` + +Clock-in proof rules: + +- use `nfcTagId` for NFC clocking +- use `latitude`, `longitude`, and `accuracyMeters` for geolocation clocking +- send `overrideReason` only when a geofence override is allowed +- send `proofNonce` and `proofTimestamp` on attendance writes + +### Payments + +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` + +### Profile + +- `GET /staff/profile/sections` +- `GET /staff/profile/personal-info` +- `GET /staff/profile/industries` +- `GET /staff/profile/skills` +- `GET /staff/profile/documents` +- `GET /staff/profile/attire` +- `GET /staff/profile/tax-forms` +- `GET /staff/profile/emergency-contacts` +- `GET /staff/profile/certificates` +- `GET /staff/profile/bank-accounts` +- `GET /staff/profile/benefits` +- `GET /staff/profile/benefits/history` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `PUT /staff/profile/personal-info` +- `PUT /staff/profile/experience` +- `PUT /staff/profile/locations` +- `POST /staff/profile/emergency-contacts` +- `PUT /staff/profile/emergency-contacts/:contactId` +- `PUT /staff/profile/tax-forms/:formType` +- `POST /staff/profile/tax-forms/:formType/submit` +- `POST /staff/profile/bank-accounts` +- `PUT /staff/profile/privacy` + +Document model rule: + +- `GET /staff/profile/documents` returns only documents +- `GET /staff/profile/attire` returns attire items +- `GET /staff/profile/tax-forms` returns tax-form rows +- `GET /staff/profile/certificates` returns certificates + +### FAQ + +- `GET /staff/faqs` +- `GET /staff/faqs/search?q=...` + +## 6) Upload implementation + +For documents, attire, and certificates: + +1. `POST /upload-file` +2. `POST /create-signed-url` +3. upload file bytes to storage with the signed URL +4. `POST /verifications` +5. finalize with: + - `PUT /staff/profile/documents/:documentId/upload` + - `PUT /staff/profile/attire/:documentId/upload` + - `POST /staff/profile/certificates` + +Use the verification-linked file as the source of truth. + +## 7) What frontend should not assume + +- do not assume order edit mutates past shifts +- do not assume swap resolution is complete beyond the request step +- do not assume raw `/query/*` or `/commands/*` routes are stable for app integration +- do not assume blocked workers can still apply to future shifts for that business + +## 8) Demo reset + +To reset dev demo data: + +```bash +source ~/.nvm/nvm.sh +nvm use 23.5.0 +cd backend/command-api +npm run seed:v2-demo +``` diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md new file mode 100644 index 00000000..802cdf75 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -0,0 +1,183 @@ +# Staff Shifts V2 + +This document is the frontend handoff for the `staff/shifts/*` routes on the unified v2 API. + +Base URL: + +- `https://krow-api-v2-933560802882.us-central1.run.app` + +## Read routes + +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/open` +- `GET /staff/shifts/pending` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` + +## Write routes + +- `POST /staff/shifts/:shiftId/apply` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `POST /staff/shifts/:shiftId/submit-for-approval` + +All write routes require: + +- `Authorization: Bearer ` +- `Idempotency-Key: ` + +## Shift lifecycle + +### Find shifts + +`GET /staff/shifts/open` + +- use this for the worker marketplace feed +- the worker applies to a concrete shift role +- send the `roleId` returned by the open-shifts response +- `roleId` here means `shift_roles.id`, not the role catalog id + +Apply request example: + +```json +{ + "roleId": "uuid", + "instantBook": false +} +``` + +### Pending shifts + +`GET /staff/shifts/pending` + +- use `POST /staff/shifts/:shiftId/accept` to accept +- use `POST /staff/shifts/:shiftId/decline` to decline + +### Assigned shifts + +`GET /staff/shifts/assigned` + +Each item now includes: + +- `clientName` +- `hourlyRate` +- `totalRate` +- `startTime` +- `endTime` + +### Shift detail + +`GET /staff/shifts/:shiftId` + +Each detail response now includes: + +- `clientName` +- `latitude` +- `longitude` +- `hourlyRate` +- `totalRate` + +Use this as the source of truth for the shift detail screen. + +### Request swap + +`POST /staff/shifts/:shiftId/request-swap` + +Example: + +```json +{ + "reason": "Need coverage for a family emergency" +} +``` + +Current backend behavior: + +- marks the assignment as `SWAP_REQUESTED` +- stores the reason +- emits `SHIFT_SWAP_REQUESTED` +- exposes the shift in the replacement pool +- starts the swap-expiry window used by backend auto-cancellation + +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 + +`POST /staff/shifts/:shiftId/submit-for-approval` + +Use this after the worker has clocked out. + +Example: + +```json +{ + "note": "Worked full shift and all tasks were completed" +} +``` + +Current backend behavior: + +- only allows shifts in `CHECKED_OUT` or `COMPLETED` +- creates or updates the assignment timesheet +- sets the timesheet to `SUBMITTED` unless it is already `APPROVED` or `PAID` +- emits `TIMESHEET_SUBMITTED_FOR_APPROVAL` + +Example response: + +```json +{ + "assignmentId": "uuid", + "shiftId": "uuid", + "timesheetId": "uuid", + "status": "SUBMITTED", + "submitted": true +} +``` + +## Completed shifts + +`GET /staff/shifts/completed` + +Each item now includes: + +- `date` +- `clientName` +- `startTime` +- `endTime` +- `hourlyRate` +- `totalRate` +- `timesheetStatus` +- `paymentStatus` + +## Clock-in support fields + +`GET /staff/clock-in/shifts/today` + +Each item now includes: + +- `clientName` +- `hourlyRate` +- `totalRate` +- `latitude` +- `longitude` +- `clockInMode` +- `allowClockInOverride` + +## Frontend rule + +Use the unified routes only. + +Do not build new mobile work on: + +- `/query/*` +- `/commands/*` +- `/core/*` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index aea858ec..9e160f04 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -44,6 +44,10 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/coverage/stats` - `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` @@ -69,6 +73,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/orders/permanent` - `POST /client/orders/:orderId/edit` - `POST /client/orders/:orderId/cancel` +- `POST /client/shift-managers` - `POST /client/hubs` - `PUT /client/hubs/:hubId` - `DELETE /client/hubs/:hubId` @@ -78,6 +83,79 @@ 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: + +```json +{ + "staffId": "uuid", + "assignmentId": "uuid", + "rating": 2, + "feedback": "Worker left the shift early without approval", + "markAsFavorite": false, + "issueFlags": ["LEFT_EARLY"], + "markAsBlocked": true +} +``` + +If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. 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 +{ + "firstName": "Nora", + "lastName": "Lead", + "email": "nora.lead@example.com", + "phone": "+15550001234", + "hubId": "uuid" +} +``` + +The manager is created as an invited business membership. If `hubId` is present, backend also links the manager to that hub. ## 3) Staff routes @@ -109,6 +187,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /staff/profile/certificates` - `GET /staff/profile/bank-accounts` - `GET /staff/profile/benefits` +- `GET /staff/profile/benefits/history` - `GET /staff/profile/time-card` - `GET /staff/profile/privacy` - `GET /staff/faqs` @@ -153,6 +232,7 @@ Example `GET /staff/clock-in/shifts/today` item: - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline` - `POST /staff/shifts/:shiftId/request-swap` +- `POST /staff/shifts/:shiftId/submit-for-approval` - `PUT /staff/profile/personal-info` - `PUT /staff/profile/experience` - `PUT /staff/profile/locations` @@ -174,6 +254,7 @@ These are exposed as direct unified aliases even though they are backed by `core - `POST /invoke-llm` - `POST /rapid-orders/transcribe` - `POST /rapid-orders/parse` +- `POST /rapid-orders/process` - `POST /verifications` - `GET /verifications/:verificationId` - `POST /verifications/:verificationId/review` @@ -183,7 +264,9 @@ These are exposed as direct unified aliases even though they are backed by `core - `POST /staff/profile/photo` - `POST /staff/profile/documents/:documentId/upload` +- `PUT /staff/profile/documents/:documentId/upload` - `POST /staff/profile/attire/:documentId/upload` +- `PUT /staff/profile/attire/:documentId/upload` - `POST /staff/profile/certificates` - `DELETE /staff/profile/certificates/:certificateId` @@ -191,7 +274,22 @@ These are exposed as direct unified aliases even though they are backed by `core - `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id. - `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend. +- Document routes now return only document rows. They do not mix in attire items anymore. +- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`. +- Staff benefit activity should come from `GET /staff/profile/benefits/history`; the summary card should keep using `GET /staff/profile/benefits`. - File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. +- The frontend upload contract for documents, attire, and certificates is: + 1. `POST /upload-file` + 2. `POST /create-signed-url` + 3. `POST /verifications` + 4. finalize with: + - `PUT /staff/profile/documents/:documentId/upload` + - `PUT /staff/profile/attire/:documentId/upload` + - `POST /staff/profile/certificates` +- Finalization requires `verificationId`. Frontend may still send `fileUri` or `photoUrl`, but the backend treats the verification-linked file as the source of truth. +- `POST /rapid-orders/process` is the single-call route for "transcribe + parse". +- `POST /client/orders/:orderId/edit` builds a replacement order from future shifts only. +- `POST /client/orders/:orderId/cancel` cancels future shifts only on the mobile surface and leaves historical shifts intact. - Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`. - Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`. - `clockInMode` values are: @@ -206,7 +304,11 @@ These are exposed as direct unified aliases even though they are backed by `core - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides - `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. - `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"; \