feat(api): complete M5 swap and dispatch backend slice

This commit is contained in:
zouantchaw
2026-03-18 10:40:04 +01:00
parent 32f6cd55c8
commit 26a853184f
18 changed files with 2170 additions and 109 deletions

View File

@@ -46,6 +46,8 @@ async function main() {
const checkedOutAt = hoursFromNow(-20.25); const checkedOutAt = hoursFromNow(-20.25);
const assignedStartsAt = hoursFromNow(0.1); const assignedStartsAt = hoursFromNow(0.1);
const assignedEndsAt = hoursFromNow(8.1); const assignedEndsAt = hoursFromNow(8.1);
const swapEligibleStartsAt = hoursFromNow(26);
const swapEligibleEndsAt = hoursFromNow(34);
const availableStartsAt = hoursFromNow(30); const availableStartsAt = hoursFromNow(30);
const availableEndsAt = hoursFromNow(38); const availableEndsAt = hoursFromNow(38);
const cancelledStartsAt = hoursFromNow(20); const cancelledStartsAt = hoursFromNow(20);
@@ -58,6 +60,7 @@ async function main() {
await upsertUser(client, fixture.users.operationsManager); await upsertUser(client, fixture.users.operationsManager);
await upsertUser(client, fixture.users.vendorManager); await upsertUser(client, fixture.users.vendorManager);
await upsertUser(client, fixture.users.staffAna); await upsertUser(client, fixture.users.staffAna);
await upsertUser(client, fixture.users.staffBen);
await client.query( await client.query(
` `
@@ -74,7 +77,8 @@ async function main() {
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb), ($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb), ($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_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, fixture.tenant.id,
@@ -82,6 +86,7 @@ async function main() {
fixture.users.operationsManager.id, fixture.users.operationsManager.id,
fixture.users.vendorManager.id, fixture.users.vendorManager.id,
fixture.users.staffAna.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, id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
average_rating, rating_count, metadata 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.ana.id,
fixture.staff.ben.id,
fixture.tenant.id, fixture.tenant.id,
fixture.users.staffAna.id, fixture.users.staffAna.id,
fixture.staff.ana.fullName, fixture.staff.ana.fullName,
@@ -208,29 +216,63 @@ async function main() {
phone: '+15550007777', 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( await client.query(
` `
INSERT INTO staff_roles (staff_id, role_id, is_primary) 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( await client.query(
` `
INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata) 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.ana.id,
fixture.workforce.ben.id,
fixture.tenant.id, fixture.tenant.id,
fixture.vendor.id, fixture.vendor.id,
fixture.staff.ana.id, fixture.staff.ana.id,
fixture.workforce.ana.workforceNumber, fixture.workforce.ana.workforceNumber,
JSON.stringify({ source: 'seed-v2-demo' }), JSON.stringify({ source: 'seed-v2-demo' }),
fixture.staff.ben.id,
fixture.workforce.ben.workforceNumber,
JSON.stringify({ source: 'seed-v2-demo' }),
] ]
); );
@@ -338,6 +380,29 @@ async function main() {
] ]
); );
await client.query(
`
INSERT INTO dispatch_team_memberships (
id, tenant_id, business_id, hub_id, staff_id, team_type, source, status, reason, effective_at, created_by_user_id, metadata
)
VALUES
($1, $4, $5, NULL, $6, 'CORE', 'MANUAL', 'ACTIVE', 'Seeded core team member', NOW() - INTERVAL '7 days', $7, '{"seeded":true}'::jsonb),
($2, $4, $5, $8, $9, 'CERTIFIED_LOCATION', 'MANUAL', 'ACTIVE', 'Seeded location-certified member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb),
($3, $4, $5, NULL, $9, 'MARKETPLACE', 'SYSTEM', 'ACTIVE', 'Seeded marketplace fallback member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb)
`,
[
fixture.dispatchTeamMemberships.anaCore.id,
fixture.dispatchTeamMemberships.benCertifiedLocation.id,
fixture.dispatchTeamMemberships.benMarketplace.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.users.operationsManager.id,
fixture.clockPoint.id,
fixture.staff.ben.id,
]
);
await client.query( await client.query(
` `
INSERT INTO orders ( INSERT INTO orders (
@@ -445,9 +510,10 @@ async function main() {
) )
VALUES 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), ($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), ($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, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::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, '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) ($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, fixture.shifts.available.id,
@@ -469,6 +535,11 @@ async function main() {
fixture.shifts.assigned.title, fixture.shifts.assigned.title,
assignedStartsAt, assignedStartsAt,
assignedEndsAt, assignedEndsAt,
fixture.shifts.swapEligible.id,
fixture.shifts.swapEligible.code,
fixture.shifts.swapEligible.title,
swapEligibleStartsAt,
swapEligibleEndsAt,
fixture.shifts.cancelled.id, fixture.shifts.cancelled.id,
fixture.shifts.cancelled.code, fixture.shifts.cancelled.code,
fixture.shifts.cancelled.title, fixture.shifts.cancelled.title,
@@ -512,19 +583,22 @@ async function main() {
VALUES VALUES
($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb), ($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb),
($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::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), ($5, $6, $7, $8, $9, 1, 1, 2400, 3700, '{"slice":"swap_eligible"}'::jsonb),
($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::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.shiftRoles.availableBarista.id,
fixture.shifts.available.id, fixture.shifts.available.id,
fixture.shiftRoles.assignedBarista.id, fixture.shiftRoles.assignedBarista.id,
fixture.shifts.assigned.id, fixture.shifts.assigned.id,
fixture.shiftRoles.cancelledBarista.id, fixture.shiftRoles.swapEligibleBarista.id,
fixture.shifts.cancelled.id, fixture.shifts.swapEligible.id,
fixture.roles.barista.id, fixture.roles.barista.id,
fixture.roles.barista.code, fixture.roles.barista.code,
fixture.roles.barista.name, fixture.roles.barista.name,
fixture.shiftRoles.cancelledBarista.id,
fixture.shifts.cancelled.id,
fixture.shiftRoles.noShowBarista.id, fixture.shiftRoles.noShowBarista.id,
fixture.shifts.noShow.id, fixture.shifts.noShow.id,
] ]
@@ -578,8 +652,9 @@ async function main() {
) )
VALUES VALUES
($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb), ($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), ($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, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::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, fixture.assignments.assignedAna.id,
@@ -590,6 +665,9 @@ async function main() {
fixture.shiftRoles.assignedBarista.id, fixture.shiftRoles.assignedBarista.id,
fixture.workforce.ana.id, fixture.workforce.ana.id,
fixture.staff.ana.id, fixture.staff.ana.id,
fixture.assignments.swapEligibleAna.id,
fixture.shifts.swapEligible.id,
fixture.shiftRoles.swapEligibleBarista.id,
fixture.assignments.cancelledAna.id, fixture.assignments.cancelledAna.id,
fixture.shifts.cancelled.id, fixture.shifts.cancelled.id,
fixture.shiftRoles.cancelledBarista.id, fixture.shiftRoles.cancelledBarista.id,

View File

@@ -25,6 +25,11 @@ export const V2DemoFixture = {
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com', email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
displayName: 'Ana Barista', 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: { business: {
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001', id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
@@ -62,12 +67,23 @@ export const V2DemoFixture = {
phone: '+15557654321', phone: '+15557654321',
primaryRole: 'BARISTA', primaryRole: 'BARISTA',
}, },
ben: {
id: '4b7dff1a-1856-4d59-b450-5a6736461002',
fullName: 'Ben Barista',
email: 'ben.barista+v2@krowd.com',
phone: '+15557654322',
primaryRole: 'BARISTA',
},
}, },
workforce: { workforce: {
ana: { ana: {
id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001', id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001',
workforceNumber: 'WF-V2-ANA-001', workforceNumber: 'WF-V2-ANA-001',
}, },
ben: {
id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa002',
workforceNumber: 'WF-V2-BEN-001',
},
}, },
clockPoint: { clockPoint: {
id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001', id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001',
@@ -147,6 +163,13 @@ export const V2DemoFixture = {
clockInMode: 'GEO_REQUIRED', clockInMode: 'GEO_REQUIRED',
allowClockInOverride: true, 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: { cancelled: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954005', id: '6e7dadad-99e4-45bb-b0da-7bb617954005',
code: 'SHIFT-V2-CANCELLED-1', code: 'SHIFT-V2-CANCELLED-1',
@@ -171,6 +194,9 @@ export const V2DemoFixture = {
assignedBarista: { assignedBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004', id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004',
}, },
swapEligibleBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b007',
},
cancelledBarista: { cancelledBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005', id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005',
}, },
@@ -182,6 +208,9 @@ export const V2DemoFixture = {
openAna: { openAna: {
id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001', id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001',
}, },
swapBen: {
id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34002',
},
}, },
assignments: { assignments: {
completedAna: { completedAna: {
@@ -190,6 +219,9 @@ export const V2DemoFixture = {
assignedAna: { assignedAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa002', id: 'f1d3f738-a132-4863-b222-4f9cb25aa002',
}, },
swapEligibleAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa005',
},
cancelledAna: { cancelledAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa003', id: 'f1d3f738-a132-4863-b222-4f9cb25aa003',
}, },
@@ -223,6 +255,17 @@ export const V2DemoFixture = {
id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001', 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: { documents: {
governmentId: { governmentId: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000', id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000',

View File

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

View File

@@ -207,11 +207,38 @@ export const shiftDecisionSchema = z.object({
reason: z.string().max(1000).optional(), 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({ export const shiftSubmitApprovalSchema = z.object({
shiftId: z.string().uuid(), shiftId: z.string().uuid(),
note: z.string().max(2000).optional(), 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({ export const staffClockInSchema = z.object({
assignmentId: z.string().uuid().optional(), assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(), shiftId: z.string().uuid().optional(),

View File

@@ -10,7 +10,9 @@ import {
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
cancelShiftSwapRequest,
cancelClientOrder, cancelClientOrder,
createDispatchTeamMembership,
createEmergencyContact, createEmergencyContact,
createClientOneTimeOrder, createClientOneTimeOrder,
createClientPermanentOrder, createClientPermanentOrder,
@@ -24,6 +26,8 @@ import {
rateWorkerFromCoverage, rateWorkerFromCoverage,
registerClientPushToken, registerClientPushToken,
registerStaffPushToken, registerStaffPushToken,
removeDispatchTeamMembership,
resolveShiftSwapRequest,
requestShiftSwap, requestShiftSwap,
saveTaxFormDraft, saveTaxFormDraft,
setupStaffProfile, setupStaffProfile,
@@ -55,6 +59,8 @@ import {
clientPermanentOrderSchema, clientPermanentOrderSchema,
clientRecurringOrderSchema, clientRecurringOrderSchema,
coverageReviewSchema, coverageReviewSchema,
dispatchTeamMembershipCreateSchema,
dispatchTeamMembershipDeleteSchema,
emergencyContactCreateSchema, emergencyContactCreateSchema,
emergencyContactUpdateSchema, emergencyContactUpdateSchema,
hubAssignManagerSchema, hubAssignManagerSchema,
@@ -73,6 +79,8 @@ import {
shiftManagerCreateSchema, shiftManagerCreateSchema,
shiftApplySchema, shiftApplySchema,
shiftDecisionSchema, shiftDecisionSchema,
shiftSwapCancelSchema,
shiftSwapResolveSchema,
shiftSubmitApprovalSchema, shiftSubmitApprovalSchema,
staffClockInSchema, staffClockInSchema,
staffClockOutSchema, staffClockOutSchema,
@@ -90,7 +98,9 @@ const defaultHandlers = {
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
cancelShiftSwapRequest,
cancelClientOrder, cancelClientOrder,
createDispatchTeamMembership,
createEmergencyContact, createEmergencyContact,
createClientOneTimeOrder, createClientOneTimeOrder,
createClientPermanentOrder, createClientPermanentOrder,
@@ -104,6 +114,8 @@ const defaultHandlers = {
rateWorkerFromCoverage, rateWorkerFromCoverage,
registerClientPushToken, registerClientPushToken,
registerStaffPushToken, registerStaffPushToken,
removeDispatchTeamMembership,
resolveShiftSwapRequest,
requestShiftSwap, requestShiftSwap,
saveTaxFormDraft, saveTaxFormDraft,
setupStaffProfile, setupStaffProfile,
@@ -301,6 +313,41 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }), 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', { router.post(...mobileCommand('/staff/profile/setup', {
schema: staffProfileSetupSchema, schema: staffProfileSetupSchema,
policyAction: 'staff.profile.write', policyAction: 'staff.profile.write',

View File

@@ -15,6 +15,19 @@ import {
const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED']; const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED'];
const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED']; 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) { function toIsoOrNull(value) {
return value ? new Date(value).toISOString() : null; return value ? new Date(value).toISOString() : null;
@@ -37,6 +50,21 @@ function ensureArray(value) {
return Array.isArray(value) ? 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 }) { async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) {
const blocked = await client.query( const blocked = await client.query(
` `
@@ -849,6 +877,7 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId,
s.tenant_id, s.tenant_id,
s.business_id, s.business_id,
s.vendor_id, s.vendor_id,
s.clock_point_id,
s.status AS shift_status, s.status AS shift_status,
s.starts_at, s.starts_at,
s.ends_at, s.ends_at,
@@ -857,13 +886,26 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId,
sr.role_name, sr.role_name,
sr.workers_needed, sr.workers_needed,
sr.assigned_count, sr.assigned_count,
sr.pay_rate_cents sr.pay_rate_cents,
swap_request.id AS swap_request_id
FROM shifts s FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id 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 WHERE s.id = $1
AND s.tenant_id = $2 AND s.tenant_id = $2
AND ($3::uuid IS NULL OR sr.id = $3) 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 ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM applications a FROM applications a
@@ -887,6 +929,217 @@ async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId,
return result.rows[0]; 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) { async function requirePendingAssignmentForActor(client, tenantId, shiftId, actorUid) {
const result = await client.query( const result = await client.query(
` `
@@ -2499,6 +2752,12 @@ export async function applyForShift(actor, payload) {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); 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 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, { await ensureStaffNotBlockedByBusiness(client, {
tenantId: context.tenant.tenantId, tenantId: context.tenant.tenantId,
businessId: shiftRole.business_id, businessId: shiftRole.business_id,
@@ -2551,6 +2810,10 @@ export async function applyForShift(actor, payload) {
JSON.stringify({ JSON.stringify({
appliedBy: actor.uid, appliedBy: actor.uid,
instantBookRequested: payload.instantBook === true, instantBookRequested: payload.instantBook === true,
dispatchTeamType: dispatchMembership.teamType,
dispatchPriority: dispatchMembership.priority,
dispatchTeamMembershipId: dispatchMembership.membershipId,
dispatchTeamScopeHubId: dispatchMembership.scopedHubId,
}), }),
] ]
); );
@@ -2704,13 +2967,134 @@ export async function requestShiftSwap(actor, payload) {
const context = await requireStaffContext(actor.uid); const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); const assignmentResult = await client.query(
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, { 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, shiftId: payload.shiftId,
assignmentStatus: assignment.status, 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( await client.query(
` `
UPDATE assignments UPDATE assignments
@@ -2722,22 +3106,516 @@ export async function requestShiftSwap(actor, payload) {
[assignment.id, JSON.stringify({ [assignment.id, JSON.stringify({
swapRequestedAt: new Date().toISOString(), swapRequestedAt: new Date().toISOString(),
swapReason: payload.reason || null, swapReason: payload.reason || null,
swapRequestId,
swapExpiresAt: expiresAt.toISOString(),
})] })]
); );
await refreshShiftRoleCounts(client, assignment.shift_role_id); await refreshShiftRoleCounts(client, assignment.shift_role_id);
await refreshShiftCounts(client, assignment.shift_id); await refreshShiftCounts(client, assignment.shift_id);
await insertDomainEvent(client, { await insertDomainEvent(client, {
tenantId: context.tenant.tenantId, tenantId: context.tenant.tenantId,
aggregateType: 'assignment', aggregateType: 'shift_swap_request',
aggregateId: assignment.id, aggregateId: swapRequestId,
eventType: 'SHIFT_SWAP_REQUESTED', eventType: 'SHIFT_SWAP_REQUESTED',
actorUserId: actor.uid, 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 { return {
swapRequestId,
assignmentId: assignment.id, assignmentId: assignment.id,
shiftId: assignment.shift_id, shiftId: assignment.shift_id,
status: 'SWAP_REQUESTED', 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',
}; };
}); });
} }

View File

@@ -1,5 +1,5 @@
import { query, withTransaction } from './db.js'; import { query, withTransaction } from './db.js';
import { enqueueNotification } from './notification-outbox.js'; import { enqueueHubManagerAlert, enqueueNotification, enqueueUserAlert } from './notification-outbox.js';
import { import {
markPushTokenInvalid, markPushTokenInvalid,
resolveNotificationTargetTokens, resolveNotificationTargetTokens,
@@ -28,6 +28,82 @@ export function computeRetryDelayMinutes(attemptNumber) {
return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60); 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, { async function recordDeliveryAttempt(client, {
notificationId, notificationId,
devicePushTokenId = null, devicePushTokenId = null,
@@ -226,6 +302,183 @@ async function enqueueDueShiftReminders() {
return { enqueued }; 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) { async function settleNotification(notification, deliveryResults, maxAttempts) {
const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length; const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length;
const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length; const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length;
@@ -301,10 +554,13 @@ export async function dispatchPendingNotifications({
sender = createPushSender(), sender = createPushSender(),
} = {}) { } = {}) {
const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5); const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5);
const swapSummary = await processExpiredSwapRequests();
const reminderSummary = await enqueueDueShiftReminders(); const reminderSummary = await enqueueDueShiftReminders();
const claimed = await claimDueNotifications(limit); const claimed = await claimDueNotifications(limit);
const summary = { const summary = {
swapRequestsClaimed: swapSummary.claimed,
swapRequestsAutoCancelled: swapSummary.autoCancelled,
remindersEnqueued: reminderSummary.enqueued, remindersEnqueued: reminderSummary.enqueued,
claimed: claimed.length, claimed: claimed.length,
sent: 0, sent: 0,

View File

@@ -39,6 +39,16 @@ function createMobileHandlers() {
orderId: payload.orderId, orderId: payload.orderId,
status: 'CANCELLED', 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) => ({ createHub: async (_actor, payload) => ({
hubId: 'hub-1', hubId: 'hub-1',
name: payload.name, name: payload.name,
@@ -60,9 +70,18 @@ function createMobileHandlers() {
platform: payload.platform, platform: payload.platform,
notificationsEnabled: payload.notificationsEnabled ?? true, notificationsEnabled: payload.notificationsEnabled ?? true,
}), }),
removeDispatchTeamMembership: async (_actor, payload) => ({
membershipId: payload.membershipId,
status: 'INACTIVE',
}),
unregisterClientPushToken: async () => ({ unregisterClientPushToken: async () => ({
removedCount: 1, removedCount: 1,
}), }),
resolveShiftSwapRequest: async (_actor, payload) => ({
swapRequestId: payload.swapRequestId,
applicationId: payload.applicationId,
status: 'RESOLVED',
}),
applyForShift: async (_actor, payload) => ({ applyForShift: async (_actor, payload) => ({
shiftId: payload.shiftId, shiftId: payload.shiftId,
status: 'APPLIED', status: 'APPLIED',
@@ -390,3 +409,64 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id
assert.equal(res.body.timesheetId, 'timesheet-1'); assert.equal(res.body.timesheetId, 'timesheet-1');
assert.equal(res.body.submitted, true); 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');
});

View File

@@ -34,6 +34,8 @@ import {
listCostCenters, listCostCenters,
listCoreTeam, listCoreTeam,
listCoverageByDate, listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts, listCompletedShifts,
listEmergencyContacts, listEmergencyContacts,
listFaqCategories, listFaqCategories,
@@ -44,6 +46,7 @@ import {
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange, listOrderItemsByDateRange,
listPaymentsHistory, listPaymentsHistory,
listPendingAssignments, listPendingAssignments,
@@ -99,6 +102,8 @@ const defaultQueryService = {
listCostCenters, listCostCenters,
listCoreTeam, listCoreTeam,
listCoverageByDate, listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts, listCompletedShifts,
listEmergencyContacts, listEmergencyContacts,
listFaqCategories, listFaqCategories,
@@ -109,6 +114,7 @@ const defaultQueryService = {
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange, listOrderItemsByDateRange,
listPaymentsHistory, listPaymentsHistory,
listPendingAssignments, listPendingAssignments,
@@ -266,6 +272,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
} }
}); });
router.get('/client/coverage/swap-requests', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listSwapRequests(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-teams', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchTeams(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-candidates', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchCandidates(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try { try {
const items = await queryService.listHubs(req.actor.uid); const items = await queryService.listHubs(req.actor.uid);

View File

@@ -906,6 +906,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT SELECT
s.id AS "shiftId", s.id AS "shiftId",
sr.id AS "roleId", sr.id AS "roleId",
NULL::uuid AS "swapRequestId",
b.business_name AS "clientName", b.business_name AS "clientName",
sr.role_name AS "roleName", sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location, COALESCE(cp.label, s.location_name) AS location,
@@ -932,12 +933,40 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
) AS "totalRate", ) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook", 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 FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id JOIN shift_roles sr ON sr.shift_id = s.id
JOIN orders o ON o.id = s.order_id JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_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 WHERE s.tenant_id = $1
AND s.status = 'OPEN' AND s.status = 'OPEN'
AND sr.role_code = $4 AND sr.role_code = $4
@@ -954,6 +983,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT SELECT
s.id AS "shiftId", s.id AS "shiftId",
sr.id AS "roleId", sr.id AS "roleId",
ssr.id AS "swapRequestId",
b.business_name AS "clientName", b.business_name AS "clientName",
sr.role_name AS "roleName", sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location, COALESCE(cp.label, s.location_name) AS location,
@@ -980,14 +1010,45 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
) AS "totalRate", ) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook", FALSE AS "instantBook",
1::INTEGER AS "requiredWorkerCount" 1::INTEGER AS "requiredWorkerCount",
FROM assignments a 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 shifts s ON s.id = a.shift_id
JOIN shift_roles sr ON sr.id = a.shift_role_id JOIN shift_roles sr ON sr.id = a.shift_role_id
JOIN orders o ON o.id = s.order_id JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_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 WHERE a.tenant_id = $1
AND ssr.status = 'OPEN'
AND ssr.expires_at > NOW()
AND a.status = 'SWAP_REQUESTED' AND a.status = 'SWAP_REQUESTED'
AND a.staff_id <> $3 AND a.staff_id <> $3
AND sr.role_code = $4 AND sr.role_code = $4
@@ -1006,7 +1067,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
UNION ALL UNION ALL
SELECT * FROM swap_roles SELECT * FROM swap_roles
) items ) items
ORDER BY "startTime" ASC ORDER BY "dispatchPriority" ASC, "startTime" ASC
LIMIT $5 LIMIT $5
`, `,
[ [
@@ -1369,17 +1430,165 @@ export async function listStaffBenefitHistory(actorUid, { limit, offset } = {})
return result.rows; return result.rows;
} }
export async function listCoreTeam(actorUid) { export async function listSwapRequests(actorUid, { shiftId, status = 'OPEN', limit } = {}) {
const context = await requireClientContext(actorUid); const context = await requireClientContext(actorUid);
const result = await query( const safeLimit = parseLimit(limit, 20, 100);
const allowedStatuses = new Set(['OPEN', 'RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED']);
const normalizedStatus = allowedStatuses.has(`${status || 'OPEN'}`.toUpperCase())
? `${status || 'OPEN'}`.toUpperCase()
: 'OPEN';
const swapResult = await query(
` `
SELECT 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.id AS "staffId",
st.full_name AS "fullName", st.full_name AS "fullName",
st.primary_role AS "primaryRole", st.primary_role AS "primaryRole",
st.average_rating AS "averageRating", st.average_rating AS "averageRating",
st.rating_count AS "ratingCount", st.rating_count AS "ratingCount",
TRUE AS favorite COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
FROM shift_swap_requests srq
JOIN shifts s ON s.id = srq.shift_id
JOIN applications app ON app.shift_role_id = srq.shift_role_id
JOIN staffs st ON st.id = app.staff_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = srq.tenant_id
AND dtm.business_id = srq.business_id
AND dtm.staff_id = st.id
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE srq.id = ANY($1::uuid[])
AND app.status IN ('PENDING', 'CONFIRMED')
ORDER BY srq.created_at DESC, "dispatchPriority" ASC, st.average_rating DESC, app.created_at ASC
`,
[swapIds]
);
const candidatesBySwapId = new Map();
for (const row of candidateResult.rows) {
if (!candidatesBySwapId.has(row.swapRequestId)) {
candidatesBySwapId.set(row.swapRequestId, []);
}
candidatesBySwapId.get(row.swapRequestId).push(row);
}
return swapResult.rows.map((row) => ({
...row,
candidates: candidatesBySwapId.get(row.swapRequestId) || [],
candidateCount: (candidatesBySwapId.get(row.swapRequestId) || []).length,
}));
}
export async function listCoreTeam(actorUid) {
const context = await requireClientContext(actorUid);
const result = await query(
`
SELECT
dtm.id AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(sf.id IS NOT NULL, FALSE) AS favorite,
dtm.team_type AS "teamType"
FROM dispatch_team_memberships dtm
JOIN staffs st ON st.id = dtm.staff_id
LEFT JOIN staff_favorites sf
ON sf.staff_id = dtm.staff_id
AND sf.tenant_id = dtm.tenant_id
AND sf.business_id = dtm.business_id
WHERE dtm.tenant_id = $1
AND dtm.business_id = $2
AND dtm.status = 'ACTIVE'
AND dtm.team_type = 'CORE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
ORDER BY st.average_rating DESC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
if (result.rowCount > 0) {
return result.rows;
}
const favoritesFallback = await query(
`
SELECT
NULL::uuid AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
TRUE AS favorite,
'CORE'::text AS "teamType"
FROM staff_favorites sf FROM staff_favorites sf
JOIN staffs st ON st.id = sf.staff_id JOIN staffs st ON st.id = sf.staff_id
WHERE sf.tenant_id = $1 WHERE sf.tenant_id = $1
@@ -1388,6 +1597,143 @@ export async function listCoreTeam(actorUid) {
`, `,
[context.tenant.tenantId, context.business.businessId] [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; return result.rows;
} }

View File

@@ -38,6 +38,8 @@ function createMobileQueryService() {
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]), listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
listCoreTeam: async () => ([{ staffId: 'core-1' }]), listCoreTeam: async () => ([{ staffId: 'core-1' }]),
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]), listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listCoverageDispatchCandidates: async () => ([{ staffId: 'dispatch-1' }]),
listCoverageDispatchTeams: async () => ([{ membershipId: 'dispatch-team-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]), listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]), listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]), listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]),
@@ -61,6 +63,7 @@ function createMobileQueryService() {
listTaxForms: async () => ([{ formType: 'W4' }]), listTaxForms: async () => ([{ formType: 'W4' }]),
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]), listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]), listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
listSwapRequests: async () => ([{ swapRequestId: 'swap-1' }]),
listTodayShifts: async () => ([{ shiftId: 'today-1' }]), listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
listVendorRoles: async () => ([{ roleId: 'role-1' }]), listVendorRoles: async () => ([{ roleId: 'role-1' }]),
listVendors: async () => ([{ vendorId: 'vendor-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'); 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 () => { test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() }); const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app) const res = await request(app)

View File

@@ -1,12 +1,18 @@
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth'; 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 ownerUid = fixture.users.businessOwner.id;
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; const ownerEmail = fixture.users.businessOwner.email;
const staffPhone = process.env.V2_DEMO_STAFF_PHONE || '+15557654321'; 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 ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!';
function ensureAdminApp() { function ensureAdminApp() {
if (getApps().length === 0) { if (getApps().length === 0) {
@@ -19,42 +25,8 @@ function getAdminAuth() {
return getAuth(); 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) { async function getUserByPhoneNumber(phoneNumber) {
if (!phoneNumber) return null;
try { try {
return await getAdminAuth().getUserByPhoneNumber(phoneNumber); return await getAdminAuth().getUserByPhoneNumber(phoneNumber);
} catch (error) { } 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 auth = getAdminAuth();
const current = await auth.getUser(uid); const existingByEmail = await getUserByEmail(email);
const existingPhoneUser = await getUserByPhoneNumber(phoneNumber); if (existingByEmail && existingByEmail.uid !== uid) {
let deletedConflictingUid = null; await auth.deleteUser(existingByEmail.uid);
}
if (existingPhoneUser && existingPhoneUser.uid !== uid) { const existingByPhone = await getUserByPhoneNumber(phoneNumber);
deletedConflictingUid = existingPhoneUser.uid; if (existingByPhone && existingByPhone.uid !== uid) {
await auth.deleteUser(existingPhoneUser.uid); await auth.deleteUser(existingByPhone.uid);
} }
const updatePayload = {}; try {
if (current.displayName !== displayName) updatePayload.displayName = displayName; await auth.updateUser(uid, {
if (current.email !== email) updatePayload.email = email; email,
if (current.phoneNumber !== phoneNumber) updatePayload.phoneNumber = phoneNumber; password,
displayName,
if (Object.keys(updatePayload).length > 0) { ...(phoneNumber ? { phoneNumber } : {}),
await auth.updateUser(uid, updatePayload); 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 { return {
uid: reconciled.uid, uid: user.uid,
email: reconciled.email, email: user.email,
phoneNumber: reconciled.phoneNumber, phoneNumber: user.phoneNumber,
deletedConflictingUid, displayName: user.displayName,
created: true,
}; };
} }
async function main() { async function main() {
const owner = await ensureUser({ const owner = await ensureManagedUser({
uid: ownerUid,
email: ownerEmail, email: ownerEmail,
password: ownerPassword, password: ownerPassword,
displayName: 'Legendary Demo Owner V2', displayName: fixture.users.businessOwner.displayName,
}); });
const staff = await ensureUser({ const staff = await ensureManagedUser({
uid: staffUid,
email: staffEmail, email: staffEmail,
password: staffPassword, password: staffPassword,
displayName: 'Ana Barista V2', displayName: fixture.users.staffAna.displayName,
});
const reconciledStaff = await reconcileStaffPhoneIdentity({
uid: staff.uid,
email: staff.email,
displayName: staff.displayName,
phoneNumber: staffPhone, 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 // 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) => { main().catch((error) => {

View File

@@ -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 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 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 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 ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!';
function uniqueKey(prefix) { function uniqueKey(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -175,13 +177,22 @@ async function signInStaff() {
}); });
} }
async function signInStaffBen() {
return signInWithPassword({
email: staffBenEmail,
password: staffBenPassword,
});
}
async function main() { async function main() {
const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`; const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`;
const ownerSession = await signInClient(); const ownerSession = await signInClient();
const staffAuth = await signInStaff(); const staffAuth = await signInStaff();
const staffBenAuth = await signInStaffBen();
assert.ok(ownerSession.sessionToken); assert.ok(ownerSession.sessionToken);
assert.ok(staffAuth.idToken); assert.ok(staffAuth.idToken);
assert.ok(staffBenAuth.idToken);
assert.equal(ownerSession.business.businessId, fixture.business.id); assert.equal(ownerSession.business.businessId, fixture.business.id);
logStep('auth.client.sign-in.ok', { logStep('auth.client.sign-in.ok', {
tenantId: ownerSession.tenant.tenantId, tenantId: ownerSession.tenant.tenantId,
@@ -191,6 +202,10 @@ async function main() {
uid: staffAuth.localId, uid: staffAuth.localId,
email: staffEmail, email: staffEmail,
}); });
logStep('auth.staff-b.password-sign-in.ok', {
uid: staffBenAuth.localId,
email: staffBenEmail,
});
const authSession = await apiCall('/auth/session', { const authSession = await apiCall('/auth/session', {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
@@ -342,6 +357,13 @@ async function main() {
assert.ok(Array.isArray(coreTeam.items)); assert.ok(Array.isArray(coreTeam.items));
logStep('client.coverage.core-team.ok', { count: coreTeam.items.length }); 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}`, { const coverageIncidentsBefore = await apiCall(`/client/coverage/incidents?${reportWindow}`, {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
}); });
@@ -1197,7 +1219,7 @@ async function main() {
assert.equal(submittedCompletedShift.submitted, true); assert.equal(submittedCompletedShift.submitted, true);
logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift); logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift);
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/request-swap`, {
method: 'POST', method: 'POST',
token: staffAuth.idToken, token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-swap'), idempotencyKey: uniqueKey('staff-shift-swap'),
@@ -1207,6 +1229,54 @@ async function main() {
}); });
logStep('staff.shifts.request-swap.ok', requestedSwap); 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', { const blockedReview = await apiCall('/client/coverage/reviews', {
method: 'POST', method: 'POST',
token: ownerSession.sessionToken, token: ownerSession.sessionToken,

View File

@@ -24,10 +24,11 @@ What was validated live against the deployed stack:
- client coverage incident feed for geofence and override review - client coverage incident feed for geofence and override review
- client blocked-staff review and invited shift-manager creation - client blocked-staff review and invited shift-manager creation
- client hub, order, coverage review, device token, and late-worker cancellation flows - 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 - client invoice approve and dispute
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card - staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
- staff benefit history read model - staff benefit history read model
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request - staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, swap request, and completed-shift submission
- direct file upload helpers and verification job creation through the unified host - direct file upload helpers and verification job creation through the unified host
- client and staff sign-out - client and staff sign-out
@@ -109,6 +110,20 @@ Important operational rules:
- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed - 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` - incident review lives on `GET /client/coverage/incidents`
- confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel` - 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` - 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 ## 5) Route model

View File

@@ -80,8 +80,15 @@ Token refresh:
- `GET /client/coverage/core-team?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/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `GET /client/coverage/blocked-staff` - `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/reviews`
- `POST /client/coverage/late-workers/:assignmentId/cancel` - `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. Use `POST /client/coverage/reviews` when the business is rating a worker after coverage review.
@@ -98,6 +105,19 @@ Payload may include:
If `markAsBlocked` is `true`, backend blocks that worker for that business and rejects future apply or assign attempts until a later review sets `markAsBlocked: false`. 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 ### Orders
- `GET /client/orders/view` - `GET /client/orders/view`
@@ -178,10 +198,8 @@ Current swap behavior:
- backend records the swap request - backend records the swap request
- assignment moves to `SWAP_REQUESTED` - assignment moves to `SWAP_REQUESTED`
- shift becomes visible in the replacement pool - shift becomes visible in the replacement pool
- client/ops can review and resolve swap requests through the coverage endpoints
Current limitation: - if the swap request expires without coverage, backend auto-cancels it and alerts both the manager path and the original worker
- full manager-side swap resolution lifecycle is not yet a separate frontend contract
### Clock in / clock out ### Clock in / clock out

View File

@@ -99,9 +99,16 @@ Current backend behavior:
- stores the reason - stores the reason
- emits `SHIFT_SWAP_REQUESTED` - emits `SHIFT_SWAP_REQUESTED`
- exposes the shift in the replacement pool - exposes the shift in the replacement pool
- starts the swap-expiry window used by backend auto-cancellation
This is enough for the current staff UI. Manager/ops review happens through:
It is not yet the full manager-side swap resolution lifecycle.
- `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 ### Submit completed shift for approval

View File

@@ -45,6 +45,9 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `GET /client/coverage/core-team` - `GET /client/coverage/core-team`
- `GET /client/coverage/incidents` - `GET /client/coverage/incidents`
- `GET /client/coverage/blocked-staff` - `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/hubs`
- `GET /client/cost-centers` - `GET /client/cost-centers`
- `GET /client/vendors` - `GET /client/vendors`
@@ -80,6 +83,10 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `POST /client/billing/invoices/:invoiceId/dispute` - `POST /client/billing/invoices/:invoiceId/dispute`
- `POST /client/coverage/reviews` - `POST /client/coverage/reviews`
- `POST /client/coverage/late-workers/:assignmentId/cancel` - `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: Coverage-review request payload may also send:
@@ -94,6 +101,45 @@ Coverage-review request payload may also send:
If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`. 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: Shift-manager creation example:
```json ```json
@@ -257,6 +303,9 @@ These are exposed as direct unified aliases even though they are backed by `core
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. - `GET /client/coverage/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. - `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. - `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. - Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
- Push delivery is backed by: - Push delivery is backed by:
- SQL token registry in `device_push_tokens` - SQL token registry in `device_push_tokens`

View File

@@ -459,7 +459,7 @@ backend-configure-notification-scheduler-v2:
--time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \ --time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \
--uri="$$URL/tasks/dispatch-notifications" \ --uri="$$URL/tasks/dispatch-notifications" \
--http-method=POST \ --http-method=POST \
--headers=Content-Type=application/json \ --update-headers=Content-Type=application/json \
--message-body='{}' \ --message-body='{}' \
--oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \ --oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \
--oidc-token-audience="$$URL"; \ --oidc-token-audience="$$URL"; \