Merge branch 'dev' of https://github.com/Oloodi/krow-workforce into feature/session-persistence-new

This commit is contained in:
2026-03-19 10:27:42 +05:30
190 changed files with 4827 additions and 196 deletions

View File

@@ -60,7 +60,7 @@ help:
@echo " make test-e2e-client Run Client Maestro E2E only" @echo " make test-e2e-client Run Client Maestro E2E only"
@echo " make test-e2e-staff Run Staff Maestro E2E only" @echo " make test-e2e-staff Run Staff Maestro E2E only"
@echo "" @echo ""
@echo " 🗄️ DATA CONNECT & BACKEND (backend/dataconnect)" @echo " 🗄️ DATA CONNECT & LEGACY V1 BACKEND (legacy/dataconnect-v1)"
@echo " ────────────────────────────────────────────────────────────────────" @echo " ────────────────────────────────────────────────────────────────────"
@echo " make dataconnect-init Initialize Firebase Data Connect" @echo " make dataconnect-init Initialize Firebase Data Connect"
@echo " make dataconnect-deploy [ENV=dev] Deploy Data Connect schemas (dev/staging)" @echo " make dataconnect-deploy [ENV=dev] Deploy Data Connect schemas (dev/staging)"

View File

@@ -96,7 +96,7 @@ To ensure a consistent experience across all compliance uploads (documents, cert
#### Data Connect Integration #### Data Connect Integration
- `StaffConnectorRepository` interface updated with `getStaffDocuments()` and `upsertStaffDocument()`. - `StaffConnectorRepository` interface updated with `getStaffDocuments()` and `upsertStaffDocument()`.
- `upsertStaffDocument` mutation in `backend/dataconnect/connector/staffDocument/mutations.gql` updated to accept `verificationId`. - `upsertStaffDocument` mutation in `legacy/dataconnect-v1/connector/staffDocument/mutations.gql` updated to accept `verificationId`.
- `getStaffDocumentByKey` and `listStaffDocumentsByStaffId` queries updated to include `verificationId`. - `getStaffDocumentByKey` and `listStaffDocumentsByStaffId` queries updated to include `verificationId`.
- SDK regenerated: `make dataconnect-generate-sdk ENV=dev`. - SDK regenerated: `make dataconnect-generate-sdk ENV=dev`.

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' }),
] ]
); );
@@ -256,6 +298,36 @@ async function main() {
[fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title] [fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title]
); );
await client.query(
`
INSERT INTO staff_benefit_history (
id, tenant_id, staff_id, benefit_id, benefit_type, title, status,
effective_at, ended_at, tracked_hours, target_hours, notes, metadata
)
VALUES
(
$1, $3, $4, $5, 'COMMUTER', $6, 'PENDING',
NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days', 18, 40,
'Hours were below threshold for payout window.',
'{"source":"seed-v2-demo","period":"previous"}'::jsonb
),
(
$2, $3, $4, $5, 'COMMUTER', $6, 'ACTIVE',
NOW() - INTERVAL '9 days', NULL, 32, 40,
'Current active commuter stipend tracking.',
'{"source":"seed-v2-demo","period":"current"}'::jsonb
)
`,
[
fixture.benefitHistory.commuterPending.id,
fixture.benefitHistory.commuterActive.id,
fixture.tenant.id,
fixture.staff.ana.id,
fixture.benefits.commuter.id,
fixture.benefits.commuter.title,
]
);
await client.query( await client.query(
` `
INSERT INTO emergency_contacts ( INSERT INTO emergency_contacts (
@@ -308,6 +380,29 @@ async function main() {
] ]
); );
await client.query(
`
INSERT INTO dispatch_team_memberships (
id, tenant_id, business_id, hub_id, staff_id, team_type, source, status, reason, effective_at, created_by_user_id, metadata
)
VALUES
($1, $4, $5, NULL, $6, 'CORE', 'MANUAL', 'ACTIVE', 'Seeded core team member', NOW() - INTERVAL '7 days', $7, '{"seeded":true}'::jsonb),
($2, $4, $5, $8, $9, 'CERTIFIED_LOCATION', 'MANUAL', 'ACTIVE', 'Seeded location-certified member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb),
($3, $4, $5, NULL, $9, 'MARKETPLACE', 'SYSTEM', 'ACTIVE', 'Seeded marketplace fallback member', NOW() - INTERVAL '2 days', $7, '{"seeded":true}'::jsonb)
`,
[
fixture.dispatchTeamMemberships.anaCore.id,
fixture.dispatchTeamMemberships.benCertifiedLocation.id,
fixture.dispatchTeamMemberships.benMarketplace.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.users.operationsManager.id,
fixture.clockPoint.id,
fixture.staff.ben.id,
]
);
await client.query( await client.query(
` `
INSERT INTO orders ( INSERT INTO orders (
@@ -415,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,
@@ -439,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,
@@ -482,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,
] ]
@@ -548,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,
@@ -560,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',
@@ -99,6 +115,14 @@ export const V2DemoFixture = {
title: 'Commuter Support', title: 'Commuter Support',
}, },
}, },
benefitHistory: {
commuterActive: {
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9001',
},
commuterPending: {
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9002',
},
},
orders: { orders: {
open: { open: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518001', id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
@@ -139,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',
@@ -163,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',
}, },
@@ -174,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: {
@@ -182,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',
}, },
@@ -215,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,41 @@
CREATE TABLE IF NOT EXISTS staff_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
reason TEXT,
issue_flags JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_blocks_business_staff
ON staff_blocks (business_id, staff_id);
CREATE INDEX IF NOT EXISTS idx_staff_blocks_business_created_at
ON staff_blocks (business_id, created_at DESC);
CREATE TABLE IF NOT EXISTS staff_benefit_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
benefit_id UUID REFERENCES staff_benefits(id) ON DELETE SET NULL,
benefit_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE',
effective_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
tracked_hours INTEGER NOT NULL DEFAULT 0,
target_hours INTEGER,
notes TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_staff_effective_at
ON staff_benefit_history (staff_id, effective_at DESC);
CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_tenant_benefit_type
ON staff_benefit_history (tenant_id, benefit_type, effective_at DESC);

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

@@ -97,6 +97,16 @@ export const hubAssignManagerSchema = z.object({
message: 'businessMembershipId or managerUserId is required', message: 'businessMembershipId or managerUserId is required',
}); });
export const shiftManagerCreateSchema = z.object({
hubId: z.string().uuid().optional(),
email: z.string().email(),
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
phone: z.string().min(7).max(40).optional(),
role: z.enum(['manager', 'viewer']).optional(),
metadata: z.record(z.any()).optional(),
});
export const invoiceApproveSchema = z.object({ export const invoiceApproveSchema = z.object({
invoiceId: z.string().uuid(), invoiceId: z.string().uuid(),
}); });
@@ -111,6 +121,7 @@ export const coverageReviewSchema = z.object({
assignmentId: z.string().uuid().optional(), assignmentId: z.string().uuid().optional(),
rating: z.number().int().min(1).max(5), rating: z.number().int().min(1).max(5),
markAsFavorite: z.boolean().optional(), markAsFavorite: z.boolean().optional(),
markAsBlocked: z.boolean().optional(),
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(), issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
feedback: z.string().max(5000).optional(), feedback: z.string().max(5000).optional(),
}); });
@@ -196,6 +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({
shiftId: z.string().uuid(),
note: z.string().max(2000).optional(),
});
export const dispatchTeamMembershipCreateSchema = z.object({
staffId: z.string().uuid(),
hubId: z.string().uuid().optional(),
teamType: z.enum(['CORE', 'CERTIFIED_LOCATION', 'MARKETPLACE']),
source: z.enum(['MANUAL', 'AUTOMATED', 'SYSTEM']).optional(),
reason: z.string().max(1000).optional(),
effectiveAt: z.string().datetime().optional(),
expiresAt: z.string().datetime().optional(),
metadata: z.record(z.any()).optional(),
});
export const dispatchTeamMembershipDeleteSchema = z.object({
membershipId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const staffClockInSchema = z.object({ 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,24 +10,30 @@ import {
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
cancelShiftSwapRequest,
cancelClientOrder, cancelClientOrder,
createDispatchTeamMembership,
createEmergencyContact, createEmergencyContact,
createClientOneTimeOrder, createClientOneTimeOrder,
createClientPermanentOrder, createClientPermanentOrder,
createClientRecurringOrder, createClientRecurringOrder,
createEditedOrderCopy, createEditedOrderCopy,
createHub, createHub,
createShiftManager,
declinePendingShift, declinePendingShift,
disputeInvoice, disputeInvoice,
quickSetStaffAvailability, quickSetStaffAvailability,
rateWorkerFromCoverage, rateWorkerFromCoverage,
registerClientPushToken, registerClientPushToken,
registerStaffPushToken, registerStaffPushToken,
removeDispatchTeamMembership,
resolveShiftSwapRequest,
requestShiftSwap, requestShiftSwap,
saveTaxFormDraft, saveTaxFormDraft,
setupStaffProfile, setupStaffProfile,
staffClockIn, staffClockIn,
staffClockOut, staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch, submitLocationStreamBatch,
submitTaxForm, submitTaxForm,
unregisterClientPushToken, unregisterClientPushToken,
@@ -53,6 +59,8 @@ import {
clientPermanentOrderSchema, clientPermanentOrderSchema,
clientRecurringOrderSchema, clientRecurringOrderSchema,
coverageReviewSchema, coverageReviewSchema,
dispatchTeamMembershipCreateSchema,
dispatchTeamMembershipDeleteSchema,
emergencyContactCreateSchema, emergencyContactCreateSchema,
emergencyContactUpdateSchema, emergencyContactUpdateSchema,
hubAssignManagerSchema, hubAssignManagerSchema,
@@ -68,8 +76,12 @@ import {
profileExperienceSchema, profileExperienceSchema,
pushTokenDeleteSchema, pushTokenDeleteSchema,
pushTokenRegisterSchema, pushTokenRegisterSchema,
shiftManagerCreateSchema,
shiftApplySchema, shiftApplySchema,
shiftDecisionSchema, shiftDecisionSchema,
shiftSwapCancelSchema,
shiftSwapResolveSchema,
shiftSubmitApprovalSchema,
staffClockInSchema, staffClockInSchema,
staffClockOutSchema, staffClockOutSchema,
staffLocationBatchSchema, staffLocationBatchSchema,
@@ -86,24 +98,30 @@ const defaultHandlers = {
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
cancelShiftSwapRequest,
cancelClientOrder, cancelClientOrder,
createDispatchTeamMembership,
createEmergencyContact, createEmergencyContact,
createClientOneTimeOrder, createClientOneTimeOrder,
createClientPermanentOrder, createClientPermanentOrder,
createClientRecurringOrder, createClientRecurringOrder,
createEditedOrderCopy, createEditedOrderCopy,
createHub, createHub,
createShiftManager,
declinePendingShift, declinePendingShift,
disputeInvoice, disputeInvoice,
quickSetStaffAvailability, quickSetStaffAvailability,
rateWorkerFromCoverage, rateWorkerFromCoverage,
registerClientPushToken, registerClientPushToken,
registerStaffPushToken, registerStaffPushToken,
removeDispatchTeamMembership,
resolveShiftSwapRequest,
requestShiftSwap, requestShiftSwap,
saveTaxFormDraft, saveTaxFormDraft,
setupStaffProfile, setupStaffProfile,
staffClockIn, staffClockIn,
staffClockOut, staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch, submitLocationStreamBatch,
submitTaxForm, submitTaxForm,
unregisterClientPushToken, unregisterClientPushToken,
@@ -257,6 +275,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
})); }));
router.post(...mobileCommand('/client/shift-managers', {
schema: shiftManagerCreateSchema,
policyAction: 'client.hubs.update',
resource: 'hub_manager',
handler: handlers.createShiftManager,
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', { router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
schema: invoiceApproveSchema, schema: invoiceApproveSchema,
policyAction: 'client.billing.write', policyAction: 'client.billing.write',
@@ -288,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',
@@ -402,6 +462,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
})); }));
router.post(...mobileCommand('/staff/shifts/:shiftId/submit-for-approval', {
schema: shiftSubmitApprovalSchema,
policyAction: 'staff.shifts.submit',
resource: 'shift',
handler: handlers.submitCompletedShiftForApproval,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.put(...mobileCommand('/staff/profile/personal-info', { router.put(...mobileCommand('/staff/profile/personal-info', {
schema: personalInfoUpdateSchema, schema: personalInfoUpdateSchema,
policyAction: 'staff.profile.write', policyAction: 'staff.profile.write',

View File

@@ -44,6 +44,30 @@ async function ensureActorUser(client, actor) {
); );
} }
async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) {
const blocked = await client.query(
`
SELECT id, reason, issue_flags
FROM staff_blocks
WHERE tenant_id = $1
AND business_id = $2
AND staff_id = $3
LIMIT 1
`,
[tenantId, businessId, staffId]
);
if (blocked.rowCount > 0) {
throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, {
businessId,
staffId,
blockId: blocked.rows[0].id,
reason: blocked.rows[0].reason || null,
issueFlags: Array.isArray(blocked.rows[0].issue_flags) ? blocked.rows[0].issue_flags : [],
});
}
}
async function insertDomainEvent(client, { async function insertDomainEvent(client, {
tenantId, tenantId,
aggregateType, aggregateType,
@@ -986,6 +1010,11 @@ export async function assignStaffToShift(actor, payload) {
} }
const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId); const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId);
await ensureStaffNotBlockedByBusiness(client, {
tenantId: shift.tenant_id,
businessId: shift.business_id,
staffId: workforce.staff_id,
});
let application = null; let application = null;
if (payload.applicationId) { if (payload.applicationId) {
application = await requireApplication(client, payload.tenantId, payload.applicationId); application = await requireApplication(client, payload.tenantId, payload.applicationId);

File diff suppressed because it is too large Load Diff

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,11 +39,28 @@ 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,
costCenterId: payload.costCenterId, costCenterId: payload.costCenterId,
}), }),
createShiftManager: async (_actor, payload) => ({
businessMembershipId: 'membership-1',
membershipStatus: 'INVITED',
invitedEmail: payload.email,
fullName: `${payload.firstName} ${payload.lastName}`,
managerAssignmentId: payload.hubId ? 'hub-manager-1' : null,
}),
approveInvoice: async (_actor, payload) => ({ approveInvoice: async (_actor, payload) => ({
invoiceId: payload.invoiceId, invoiceId: payload.invoiceId,
status: 'APPROVED', status: 'APPROVED',
@@ -53,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',
@@ -77,6 +103,12 @@ function createMobileHandlers() {
assignmentId: payload.assignmentId || 'assignment-1', assignmentId: payload.assignmentId || 'assignment-1',
status: 'CLOCK_OUT', status: 'CLOCK_OUT',
}), }),
submitCompletedShiftForApproval: async (_actor, payload) => ({
shiftId: payload.shiftId,
timesheetId: 'timesheet-1',
status: 'SUBMITTED',
submitted: true,
}),
submitLocationStreamBatch: async (_actor, payload) => ({ submitLocationStreamBatch: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1', assignmentId: payload.assignmentId || 'assignment-1',
pointCount: payload.points.length, pointCount: payload.points.length,
@@ -161,6 +193,25 @@ test('POST /commands/client/hubs returns injected hub response', async () => {
assert.equal(res.body.name, 'Google North Hub'); assert.equal(res.body.name, 'Google North Hub');
}); });
test('POST /commands/client/shift-managers creates an invited manager profile', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/shift-managers')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'shift-manager-create-1')
.send({
hubId: '11111111-1111-4111-8111-111111111111',
email: 'manager@example.com',
firstName: 'Shift',
lastName: 'Lead',
});
assert.equal(res.status, 200);
assert.equal(res.body.membershipStatus, 'INVITED');
assert.equal(res.body.invitedEmail, 'manager@example.com');
assert.equal(res.body.managerAssignmentId, 'hub-manager-1');
});
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => { test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app) const res = await request(app)
@@ -342,3 +393,80 @@ test('POST /commands/staff/profile/bank-accounts uppercases account type', async
assert.equal(res.body.accountType, 'CHECKING'); assert.equal(res.body.accountType, 'CHECKING');
assert.equal(res.body.last4, '7890'); assert.equal(res.body.last4, '7890');
}); });
test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/shifts/77777777-7777-4777-8777-777777777777/submit-for-approval')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'shift-submit-approval-1')
.send({
note: 'Worked full shift and ready for approval',
});
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, '77777777-7777-4777-8777-777777777777');
assert.equal(res.body.timesheetId, 'timesheet-1');
assert.equal(res.body.submitted, true);
});
test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/coverage/swap-requests/11111111-1111-4111-8111-111111111111/resolve')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'swap-resolve-1')
.send({
applicationId: '22222222-2222-4222-8222-222222222222',
});
assert.equal(res.status, 200);
assert.equal(res.body.swapRequestId, '11111111-1111-4111-8111-111111111111');
assert.equal(res.body.applicationId, '22222222-2222-4222-8222-222222222222');
assert.equal(res.body.status, 'RESOLVED');
});
test('POST /commands/client/coverage/swap-requests/:swapRequestId/cancel injects swap request id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/coverage/swap-requests/33333333-3333-4333-8333-333333333333/cancel')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'swap-cancel-1')
.send({
reason: 'No longer needed',
});
assert.equal(res.status, 200);
assert.equal(res.body.swapRequestId, '33333333-3333-4333-8333-333333333333');
assert.equal(res.body.status, 'CANCELLED');
});
test('POST /commands/client/coverage/dispatch-teams/memberships returns injected dispatch team membership response', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/coverage/dispatch-teams/memberships')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'dispatch-team-create-1')
.send({
staffId: '44444444-4444-4444-8444-444444444444',
hubId: '55555555-5555-4555-8555-555555555555',
teamType: 'CERTIFIED_LOCATION',
});
assert.equal(res.status, 200);
assert.equal(res.body.membershipId, 'dispatch-team-1');
assert.equal(res.body.teamType, 'CERTIFIED_LOCATION');
assert.equal(res.body.status, 'ACTIVE');
});
test('DELETE /commands/client/coverage/dispatch-teams/memberships/:membershipId injects membership id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.delete('/commands/client/coverage/dispatch-teams/memberships/66666666-6666-4666-8666-666666666666?reason=cleanup')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'dispatch-team-delete-1');
assert.equal(res.status, 200);
assert.equal(res.body.membershipId, '66666666-6666-4666-8666-666666666666');
assert.equal(res.body.status, 'INACTIVE');
});

View File

@@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js';
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js'; import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
import { invokeVertexModel } from '../services/llm.js'; import { invokeVertexModel } from '../services/llm.js';
import { requireTenantContext } from '../services/actor-context.js';
import { isDatabaseConfigured, query as dbQuery } from '../services/db.js';
import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js'; import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
import { import {
@@ -26,6 +28,8 @@ import {
} from '../services/verification-jobs.js'; } from '../services/verification-jobs.js';
import { import {
deleteCertificate, deleteCertificate,
finalizeCertificateUpload,
finalizeStaffDocumentUpload,
uploadCertificate, uploadCertificate,
uploadProfilePhoto, uploadProfilePhoto,
uploadStaffDocument, uploadStaffDocument,
@@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({
expiresAt: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(),
}); });
const finalizedDocumentUploadSchema = z.object({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const finalizedCertificateUploadSchema = certificateUploadMetaSchema.extend({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const rapidOrderProcessSchema = z.object({
text: z.string().trim().min(1).max(4000).optional(),
audioFileUri: z.string().startsWith('gs://').max(2048).optional(),
locale: z.string().trim().min(2).max(35).optional().default('en-US'),
promptHints: z.array(z.string().trim().min(1).max(80)).max(20).optional().default([]),
timezone: z.string().trim().min(1).max(80).optional(),
now: z.string().datetime({ offset: true }).optional(),
}).strict().superRefine((value, ctx) => {
if (!value.text && !value.audioFileUri) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'text or audioFileUri is required',
path: ['text'],
});
}
});
function mockSignedUrl(fileUri, expiresInSeconds) { function mockSignedUrl(fileUri, expiresInSeconds) {
const encoded = encodeURIComponent(fileUri); const encoded = encodeURIComponent(fileUri);
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
@@ -114,6 +147,72 @@ function enforceLlmRateLimit(uid) {
} }
} }
function normalizeRoleToken(value) {
return `${value || ''}`
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function loadRapidOrderRoleCatalog(actorUid) {
if (!isDatabaseConfigured()) {
return [];
}
let context;
try {
context = await requireTenantContext(actorUid);
} catch {
return [];
}
const result = await dbQuery(
`
SELECT
rc.id AS "roleId",
rc.code AS "roleCode",
rc.name AS "roleName",
COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents"
FROM roles_catalog rc
LEFT JOIN shift_roles sr ON sr.role_id = rc.id
LEFT JOIN shifts s ON s.id = sr.shift_id
WHERE rc.tenant_id = $1
AND rc.status = 'ACTIVE'
GROUP BY rc.id
ORDER BY rc.name ASC
`,
[context.tenant.tenantId]
);
return result.rows;
}
function enrichRapidOrderPositions(positions, roleCatalog) {
const catalog = roleCatalog.map((role) => ({
...role,
normalizedName: normalizeRoleToken(role.roleName),
normalizedCode: normalizeRoleToken(role.roleCode),
}));
return positions.map((position) => {
const normalizedRole = normalizeRoleToken(position.role);
const exact = catalog.find((role) => role.normalizedName === normalizedRole || role.normalizedCode === normalizedRole);
const fuzzy = exact || catalog.find((role) => (
role.normalizedName.includes(normalizedRole) || normalizedRole.includes(role.normalizedName)
));
return {
...position,
roleId: fuzzy?.roleId || null,
roleCode: fuzzy?.roleCode || null,
roleName: fuzzy?.roleName || position.role,
hourlyRateCents: fuzzy?.hourlyRateCents || 0,
matched: Boolean(fuzzy),
};
});
}
async function handleUploadFile(req, res, next) { async function handleUploadFile(req, res, next) {
try { try {
const file = req.file; const file = req.file;
@@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) {
timezone: payload.timezone, timezone: payload.timezone,
now: payload.now, now: payload.now,
}); });
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({ return res.status(200).json({
...result, ...result,
parsed: {
...result.parsed,
positions: enrichRapidOrderPositions(result.parsed.positions, roleCatalog),
},
catalog: {
roles: roleCatalog,
},
latencyMs: Date.now() - startedAt,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleRapidOrderProcess(req, res, next) {
try {
const payload = parseBody(rapidOrderProcessSchema, req.body || {});
enforceLlmRateLimit(req.actor.uid);
let transcript = payload.text || null;
if (!transcript && payload.audioFileUri) {
validateFileUriAccess({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
if (requireRapidAudioFileExists() && !useMockUpload()) {
await ensureFileExistsForActor({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
}
const transcribed = await transcribeRapidOrderAudio({
audioFileUri: payload.audioFileUri,
locale: payload.locale,
promptHints: payload.promptHints,
});
transcript = transcribed.transcript;
}
const startedAt = Date.now();
const parsed = await parseRapidOrderText({
text: transcript,
locale: payload.locale,
timezone: payload.timezone,
now: payload.now,
});
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({
transcript,
parsed: {
...parsed.parsed,
positions: enrichRapidOrderPositions(parsed.parsed.positions, roleCatalog),
},
missingFields: parsed.missingFields,
warnings: parsed.warnings,
confidence: parsed.confidence,
catalog: {
roles: roleCatalog,
},
model: parsed.model,
latencyMs: Date.now() - startedAt, latencyMs: Date.now() - startedAt,
requestId: req.requestId, requestId: req.requestId,
}); });
@@ -341,14 +505,25 @@ async function handleProfilePhotoUpload(req, res, next) {
async function handleDocumentUpload(req, res, next) { async function handleDocumentUpload(req, res, next) {
try { try {
const file = req.file; const file = req.file;
if (!file) { if (file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'document',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} }
const result = await uploadStaffDocument({
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid, actorUid: req.actor.uid,
documentId: req.params.documentId, documentId: req.params.documentId,
file,
routeType: 'document', routeType: 'document',
verificationId: payload.verificationId,
}); });
return res.status(200).json({ return res.status(200).json({
...result, ...result,
@@ -362,14 +537,25 @@ async function handleDocumentUpload(req, res, next) {
async function handleAttireUpload(req, res, next) { async function handleAttireUpload(req, res, next) {
try { try {
const file = req.file; const file = req.file;
if (!file) { if (file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'attire',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} }
const result = await uploadStaffDocument({
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid, actorUid: req.actor.uid,
documentId: req.params.documentId, documentId: req.params.documentId,
file,
routeType: 'attire', routeType: 'attire',
verificationId: payload.verificationId,
}); });
return res.status(200).json({ return res.status(200).json({
...result, ...result,
@@ -383,13 +569,22 @@ async function handleAttireUpload(req, res, next) {
async function handleCertificateUpload(req, res, next) { async function handleCertificateUpload(req, res, next) {
try { try {
const file = req.file; const file = req.file;
if (!file) { if (file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({
actorUid: req.actor.uid,
file,
payload,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} }
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({ const payload = parseBody(finalizedCertificateUploadSchema, req.body || {});
const result = await finalizeCertificateUpload({
actorUid: req.actor.uid, actorUid: req.actor.uid,
file,
payload, payload,
}); });
return res.status(200).json({ return res.status(200).json({
@@ -464,9 +659,12 @@ export function createCoreRouter() {
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe); router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse); router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
router.post('/rapid-orders/process', requireAuth, requirePolicy('core.rapid-order.process', 'model'), handleRapidOrderProcess);
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload); router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload); router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
router.put('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleDocumentUpload);
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload); router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
router.put('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleAttireUpload);
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload); router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete); router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);

View File

@@ -2,7 +2,7 @@ import { AppError } from '../lib/errors.js';
import { requireStaffContext } from './actor-context.js'; import { requireStaffContext } from './actor-context.js';
import { generateReadSignedUrl, uploadToGcs } from './storage.js'; import { generateReadSignedUrl, uploadToGcs } from './storage.js';
import { query, withTransaction } from './db.js'; import { query, withTransaction } from './db.js';
import { createVerificationJob } from './verification-jobs.js'; import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
function safeName(value) { function safeName(value) {
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_'); return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) {
} }
} }
function normalizeDocumentStatusFromVerification(status) {
switch (`${status || ''}`.toUpperCase()) {
case 'AUTO_PASS':
case 'APPROVED':
return 'VERIFIED';
case 'AUTO_FAIL':
case 'REJECTED':
return 'REJECTED';
default:
return 'PENDING';
}
}
async function resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId,
allowedTypes,
}) {
if (!verificationId) {
throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400);
}
const verification = await getVerificationJob(verificationId, actorUid);
if (subjectId && verification.subjectId && verification.subjectId !== subjectId) {
throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, {
verificationId,
subjectId,
verificationSubjectId: verification.subjectId,
});
}
if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) {
throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, {
verificationId,
verificationType: verification.type,
allowedTypes,
});
}
return {
verification,
fileUri: verification.fileUri,
status: normalizeDocumentStatusFromVerification(verification.status),
};
}
export async function uploadProfilePhoto({ actorUid, file }) { export async function uploadProfilePhoto({ actorUid, file }) {
const context = await requireStaffContext(actorUid); const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({ const uploaded = await uploadActorFile({
@@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
}; };
} }
export async function finalizeStaffDocumentUpload({
actorUid,
documentId,
routeType,
verificationId,
}) {
const context = await requireStaffContext(actorUid);
const document = await requireDocument(
context.tenant.tenantId,
documentId,
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId: documentId,
allowedTypes: routeType === 'attire'
? ['attire']
: ['government_id', 'document', 'tax_form'],
});
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO staff_documents (
tenant_id,
staff_id,
document_id,
file_uri,
status,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (staff_id, document_id) DO UPDATE
SET file_uri = EXCLUDED.file_uri,
status = EXCLUDED.status,
verification_job_id = EXCLUDED.verification_job_id,
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
`,
[
context.tenant.tenantId,
context.staff.staffId,
document.id,
finalized.fileUri,
finalized.status,
finalized.verification.verificationId,
JSON.stringify({
verificationStatus: finalized.verification.status,
routeType,
finalizedFromVerification: true,
}),
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
documentId: document.id,
documentType: document.document_type,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function uploadCertificate({ actorUid, file, payload }) { export async function uploadCertificate({ actorUid, file, payload }) {
const context = await requireStaffContext(actorUid); const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({ const uploaded = await uploadActorFile({
@@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) {
}; };
} }
export async function finalizeCertificateUpload({ actorUid, payload }) {
const context = await requireStaffContext(actorUid);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId: payload.verificationId,
subjectId: payload.certificateType,
allowedTypes: ['certification'],
});
const certificateResult = await withTransaction(async (client) => {
const existing = await client.query(
`
SELECT id
FROM certificates
WHERE tenant_id = $1
AND staff_id = $2
AND certificate_type = $3
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
`,
[context.tenant.tenantId, context.staff.staffId, payload.certificateType]
);
const metadata = JSON.stringify({
name: payload.name,
issuer: payload.issuer || null,
verificationStatus: finalized.verification.status,
finalizedFromVerification: true,
});
if (existing.rowCount > 0) {
return client.query(
`
UPDATE certificates
SET certificate_number = $2,
expires_at = $3,
status = $4,
file_uri = $5,
verification_job_id = $6,
metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[
existing.rows[0].id,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
}
return client.query(
`
INSERT INTO certificates (
tenant_id,
staff_id,
certificate_type,
certificate_number,
issued_at,
expires_at,
status,
file_uri,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb)
RETURNING id
`,
[
context.tenant.tenantId,
context.staff.staffId,
payload.certificateType,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
certificateId: certificateResult.rows[0].id,
certificateType: payload.certificateType,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function deleteCertificate({ actorUid, certificateType }) { export async function deleteCertificate({ actorUid, certificateType }) {
const context = await requireStaffContext(actorUid); const context = await requireStaffContext(actorUid);
const result = await query( const result = await query(

View File

@@ -267,6 +267,25 @@ test('POST /core/rapid-orders/parse rejects unknown fields', async () => {
assert.equal(res.body.code, 'VALIDATION_ERROR'); assert.equal(res.body.code, 'VALIDATION_ERROR');
}); });
test('POST /core/rapid-orders/process accepts text-only flow', async () => {
const app = createApp();
const res = await request(app)
.post('/core/rapid-orders/process')
.set('Authorization', 'Bearer test-token')
.send({
text: 'Need 2 servers ASAP for 4 hours',
locale: 'en-US',
timezone: 'America/New_York',
now: '2026-02-27T12:00:00.000Z',
});
assert.equal(res.status, 200);
assert.equal(typeof res.body.transcript, 'string');
assert.equal(res.body.parsed.orderType, 'ONE_TIME');
assert.equal(Array.isArray(res.body.parsed.positions), true);
assert.equal(Array.isArray(res.body.catalog.roles), true);
});
test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => { test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => {
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
const app = createApp(); const app = createApp();

View File

@@ -28,11 +28,14 @@ import {
listAssignedShifts, listAssignedShifts,
listBusinessAccounts, listBusinessAccounts,
listBusinessTeamMembers, listBusinessTeamMembers,
listBlockedStaff,
listCancelledShifts, listCancelledShifts,
listCertificates, listCertificates,
listCostCenters, listCostCenters,
listCoreTeam, listCoreTeam,
listCoverageByDate, listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts, listCompletedShifts,
listEmergencyContacts, listEmergencyContacts,
listFaqCategories, listFaqCategories,
@@ -43,6 +46,7 @@ import {
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange, listOrderItemsByDateRange,
listPaymentsHistory, listPaymentsHistory,
listPendingAssignments, listPendingAssignments,
@@ -53,6 +57,7 @@ import {
listStaffAvailability, listStaffAvailability,
listStaffBankAccounts, listStaffBankAccounts,
listStaffBenefits, listStaffBenefits,
listStaffBenefitHistory,
listTodayShifts, listTodayShifts,
listVendorRoles, listVendorRoles,
listVendors, listVendors,
@@ -91,11 +96,14 @@ const defaultQueryService = {
listAssignedShifts, listAssignedShifts,
listBusinessAccounts, listBusinessAccounts,
listBusinessTeamMembers, listBusinessTeamMembers,
listBlockedStaff,
listCancelledShifts, listCancelledShifts,
listCertificates, listCertificates,
listCostCenters, listCostCenters,
listCoreTeam, listCoreTeam,
listCoverageByDate, listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts, listCompletedShifts,
listEmergencyContacts, listEmergencyContacts,
listFaqCategories, listFaqCategories,
@@ -106,6 +114,7 @@ const defaultQueryService = {
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange, listOrderItemsByDateRange,
listPaymentsHistory, listPaymentsHistory,
listPendingAssignments, listPendingAssignments,
@@ -116,6 +125,7 @@ const defaultQueryService = {
listStaffAvailability, listStaffAvailability,
listStaffBankAccounts, listStaffBankAccounts,
listStaffBenefits, listStaffBenefits,
listStaffBenefitHistory,
listTodayShifts, listTodayShifts,
listVendorRoles, listVendorRoles,
listVendors, listVendors,
@@ -253,6 +263,42 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
} }
}); });
router.get('/client/coverage/blocked-staff', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listBlockedStaff(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/swap-requests', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listSwapRequests(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-teams', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchTeams(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-candidates', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchCandidates(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { 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);
@@ -622,6 +668,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
} }
}); });
router.get('/staff/profile/benefits/history', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBenefitHistory(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try { try {
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query); const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);

View File

@@ -52,6 +52,13 @@ function metadataBoolean(metadata, key, fallback = false) {
return fallback; return fallback;
} }
function membershipDisplayName(row) {
const firstName = row?.firstName || row?.metadata?.firstName || null;
const lastName = row?.lastName || row?.metadata?.lastName || null;
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
return fullName || row?.name || row?.displayName || row?.email || row?.invitedEmail || null;
}
function getProfileCompletionFromMetadata(staffRow) { function getProfileCompletionFromMetadata(staffRow) {
const metadata = staffRow?.metadata || {}; const metadata = staffRow?.metadata || {};
const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/);
@@ -171,17 +178,32 @@ export async function listRecentReorders(actorUid, limit) {
o.id, o.id,
o.title, o.title,
o.starts_at AS "date", o.starts_at AS "date",
COALESCE(cp.label, o.location_name) AS "hubName", MAX(COALESCE(cp.label, o.location_name)) AS "hubName",
COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount", MAX(b.business_name) AS "clientName",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType" COALESCE(SUM(sr.workers_needed), 0)::INTEGER AS "positionCount",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
COALESCE(ROUND(AVG(sr.bill_rate_cents))::INTEGER, 0) AS "hourlyRateCents",
COALESCE(
SUM(
sr.bill_rate_cents
* sr.workers_needed
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
),
0
)::BIGINT AS "totalPriceCents",
COALESCE(
SUM(GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)),
0
)::NUMERIC(12,2) AS hours
FROM orders o FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN shifts s ON s.order_id = o.id LEFT JOIN shifts s ON s.order_id = o.id
LEFT JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN shift_roles sr ON sr.shift_id = s.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
WHERE o.tenant_id = $1 WHERE o.tenant_id = $1
AND o.business_id = $2 AND o.business_id = $2
AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED') AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED')
GROUP BY o.id, cp.label GROUP BY o.id
ORDER BY o.starts_at DESC NULLS LAST ORDER BY o.starts_at DESC NULLS LAST
LIMIT $3 LIMIT $3
`, `,
@@ -498,10 +520,20 @@ export async function listHubManagers(actorUid, hubId) {
hm.id AS "managerAssignmentId", hm.id AS "managerAssignmentId",
bm.id AS "businessMembershipId", bm.id AS "businessMembershipId",
u.id AS "managerId", u.id AS "managerId",
COALESCE(u.display_name, u.email) AS name u.display_name AS "displayName",
u.email,
bm.invited_email AS "invitedEmail",
bm.membership_status AS "membershipStatus",
bm.metadata,
COALESCE(
NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''),
u.display_name,
u.email,
bm.invited_email
) AS name
FROM hub_managers hm FROM hub_managers hm
JOIN business_memberships bm ON bm.id = hm.business_membership_id JOIN business_memberships bm ON bm.id = hm.business_membership_id
JOIN users u ON u.id = bm.user_id LEFT JOIN users u ON u.id = bm.user_id
WHERE hm.tenant_id = $1 WHERE hm.tenant_id = $1
AND hm.hub_id = $2 AND hm.hub_id = $2
ORDER BY name ASC ORDER BY name ASC
@@ -520,15 +552,33 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
sr.id AS "itemId", sr.id AS "itemId",
o.id AS "orderId", o.id AS "orderId",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
o.title AS "eventName",
b.business_name AS "clientName",
sr.role_name AS title,
sr.role_name AS "roleName", sr.role_name AS "roleName",
s.starts_at AS date, to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
s.starts_at AS "startsAt", s.starts_at AS "startsAt",
s.ends_at AS "endsAt", s.ends_at AS "endsAt",
to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI') AS "startTime",
to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') AS "endTime",
sr.workers_needed AS "requiredWorkerCount", sr.workers_needed AS "requiredWorkerCount",
sr.assigned_count AS "filledCount", sr.assigned_count AS "filledCount",
sr.bill_rate_cents AS "hourlyRateCents", sr.bill_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.bill_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)::NUMERIC(12,2) AS hours,
(sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents", (sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents",
ROUND(
(
sr.bill_rate_cents
* sr.workers_needed
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalValue",
COALESCE(cp.label, s.location_name) AS "locationName", COALESCE(cp.label, s.location_name) AS "locationName",
COALESCE(s.location_address, cp.address) AS "locationAddress",
hm.business_membership_id AS "hubManagerId",
COALESCE(u.display_name, u.email) AS "hubManagerName",
s.status, s.status,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -544,14 +594,34 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
FROM shift_roles sr FROM shift_roles sr
JOIN shifts s ON s.id = sr.shift_id JOIN shifts s ON s.id = sr.shift_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 = o.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 assignments a ON a.shift_role_id = sr.id LEFT JOIN assignments a ON a.shift_role_id = sr.id
LEFT JOIN staffs st ON st.id = a.staff_id LEFT JOIN staffs st ON st.id = a.staff_id
LEFT JOIN LATERAL (
SELECT business_membership_id
FROM hub_managers
WHERE tenant_id = o.tenant_id
AND hub_id = s.clock_point_id
ORDER BY created_at ASC
LIMIT 1
) hm ON TRUE
LEFT JOIN business_memberships bm ON bm.id = hm.business_membership_id
LEFT JOIN users u ON u.id = bm.user_id
WHERE o.tenant_id = $1 WHERE o.tenant_id = $1
AND o.business_id = $2 AND o.business_id = $2
AND s.starts_at >= $3::timestamptz AND s.starts_at >= $3::timestamptz
AND s.starts_at <= $4::timestamptz AND s.starts_at <= $4::timestamptz
GROUP BY sr.id, o.id, s.id, cp.label GROUP BY
sr.id,
o.id,
s.id,
cp.label,
cp.address,
b.business_name,
hm.business_membership_id,
u.display_name,
u.email
ORDER BY s.starts_at ASC, sr.role_name ASC ORDER BY s.starts_at ASC, sr.role_name ASC
`, `,
[context.tenant.tenantId, context.business.businessId, range.start, range.end] [context.tenant.tenantId, context.business.businessId, range.start, range.end]
@@ -633,6 +703,23 @@ export async function listTodayShifts(actorUid) {
COALESCE(s.title, sr.role_name || ' shift') AS title, COALESCE(s.title, sr.role_name || ' shift') AS title,
b.business_name AS "clientName", b.business_name AS "clientName",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
sr.role_name AS "roleName", sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location, COALESCE(cp.label, s.location_name) AS location,
COALESCE(s.location_address, cp.address) AS "locationAddress", COALESCE(s.location_address, cp.address) AS "locationAddress",
@@ -656,7 +743,7 @@ export async function listTodayShifts(actorUid) {
AND a.staff_id = $2 AND a.staff_id = $2
AND s.starts_at >= $3::timestamptz AND s.starts_at >= $3::timestamptz
AND s.starts_at < $4::timestamptz AND s.starts_at < $4::timestamptz
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC
`, `,
[context.tenant.tenantId, context.staff.staffId, from, to] [context.tenant.tenantId, context.staff.staffId, from, to]
@@ -767,24 +854,43 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
SELECT SELECT
a.id AS "assignmentId", a.id AS "assignmentId",
s.id AS "shiftId", s.id AS "shiftId",
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,
s.starts_at AS date, s.starts_at AS date,
s.starts_at AS "startTime", s.starts_at AS "startTime",
s.ends_at AS "endTime", s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents", sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
a.status a.status
FROM assignments a FROM assignments a
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
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE a.tenant_id = $1 WHERE a.tenant_id = $1
AND a.staff_id = $2 AND a.staff_id = $2
AND s.starts_at >= $3::timestamptz AND s.starts_at >= $3::timestamptz
AND s.starts_at <= $4::timestamptz AND s.starts_at <= $4::timestamptz
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
ORDER BY s.starts_at ASC ORDER BY s.starts_at ASC
`, `,
[context.tenant.tenantId, context.staff.staffId, range.start, range.end] [context.tenant.tenantId, context.staff.staffId, range.start, range.end]
@@ -800,19 +906,67 @@ 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",
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,
s.starts_at AS date, s.starts_at AS date,
s.starts_at AS "startTime", s.starts_at AS "startTime",
s.ends_at AS "endTime", s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents", sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", 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
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
@@ -829,21 +983,72 @@ 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",
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,
s.starts_at AS date, s.starts_at AS date,
s.starts_at AS "startTime", s.starts_at AS "startTime",
s.ends_at AS "endTime", s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents", sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", 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
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
@@ -862,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
`, `,
[ [
@@ -911,8 +1116,11 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
s.id AS "shiftId", s.id AS "shiftId",
s.title, s.title,
o.description, o.description,
b.business_name AS "clientName",
COALESCE(cp.label, s.location_name) AS location, COALESCE(cp.label, s.location_name) AS location,
s.location_address AS address, COALESCE(s.location_address, cp.address) AS address,
COALESCE(s.latitude, cp.latitude) AS latitude,
COALESCE(s.longitude, cp.longitude) AS longitude,
s.starts_at AS date, s.starts_at AS date,
s.starts_at AS "startTime", s.starts_at AS "startTime",
s.ends_at AS "endTime", s.ends_at AS "endTime",
@@ -923,6 +1131,23 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
sr.id AS "roleId", sr.id AS "roleId",
sr.role_name AS "roleName", sr.role_name AS "roleName",
sr.pay_rate_cents AS "hourlyRateCents", sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
sr.workers_needed AS "requiredCount", sr.workers_needed AS "requiredCount",
sr.assigned_count AS "confirmedCount", sr.assigned_count AS "confirmedCount",
@@ -930,6 +1155,7 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
app.status AS "applicationStatus" app.status AS "applicationStatus"
FROM shifts s FROM shifts s
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 shift_roles sr ON sr.shift_id = s.id JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3 LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3
@@ -981,12 +1207,41 @@ export async function listCompletedShifts(actorUid) {
a.id AS "assignmentId", a.id AS "assignmentId",
s.id AS "shiftId", s.id AS "shiftId",
s.title, s.title,
b.business_name AS "clientName",
COALESCE(cp.label, s.location_name) AS location, COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS date, to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(ts.status, 'PENDING') AS "timesheetStatus",
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
COALESCE(
ts.gross_pay_cents,
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::BIGINT
) AS "totalRateCents",
ROUND(
COALESCE(
ts.gross_pay_cents,
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::BIGINT
)::numeric / 100,
2
) AS "totalRate",
COALESCE(rp.status, 'PENDING') AS "paymentStatus" COALESCE(rp.status, 'PENDING') AS "paymentStatus"
FROM assignments a FROM assignments a
JOIN shifts s ON s.id = a.shift_id JOIN shifts s ON s.id = a.shift_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN timesheets ts ON ts.assignment_id = a.id LEFT JOIN timesheets ts ON ts.assignment_id = a.id
LEFT JOIN recent_payments rp ON rp.assignment_id = a.id LEFT JOIN recent_payments rp ON rp.assignment_id = a.id
@@ -1003,19 +1258,22 @@ export async function listCompletedShifts(actorUid) {
export async function getProfileSectionsStatus(actorUid) { export async function getProfileSectionsStatus(actorUid) {
const context = await requireStaffContext(actorUid); const context = await requireStaffContext(actorUid);
const completion = getProfileCompletionFromMetadata(context.staff); const completion = getProfileCompletionFromMetadata(context.staff);
const [documents, certificates, benefits] = await Promise.all([ const [documents, certificates, benefits, attire, taxForms] = await Promise.all([
listProfileDocuments(actorUid), listProfileDocuments(actorUid),
listCertificates(actorUid), listCertificates(actorUid),
listStaffBenefits(actorUid), listStaffBenefits(actorUid),
listAttireChecklist(actorUid),
listTaxForms(actorUid),
]); ]);
return { return {
personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations, personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations,
emergencyContactCompleted: completion.fields.emergencyContact, emergencyContactCompleted: completion.fields.emergencyContact,
experienceCompleted: completion.fields.skills && completion.fields.industries, experienceCompleted: completion.fields.skills && completion.fields.industries,
attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'), attireCompleted: attire.every((item) => item.status === 'VERIFIED'),
taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'), taxFormsCompleted: taxForms.every((item) => item.status === 'VERIFIED' || item.status === 'SUBMITTED'),
benefitsConfigured: benefits.length > 0, benefitsConfigured: benefits.length > 0,
certificateCount: certificates.length, certificateCount: certificates.length,
documentCount: documents.length,
}; };
} }
@@ -1054,6 +1312,7 @@ export async function listProfileDocuments(actorUid) {
d.id AS "documentId", d.id AS "documentId",
d.document_type AS "documentType", d.document_type AS "documentType",
d.name, d.name,
COALESCE(d.metadata->>'description', '') AS description,
sd.id AS "staffDocumentId", sd.id AS "staffDocumentId",
sd.file_uri AS "fileUri", sd.file_uri AS "fileUri",
COALESCE(sd.status, 'NOT_UPLOADED') AS status, COALESCE(sd.status, 'NOT_UPLOADED') AS status,
@@ -1065,7 +1324,7 @@ export async function listProfileDocuments(actorUid) {
AND sd.tenant_id = d.tenant_id AND sd.tenant_id = d.tenant_id
AND sd.staff_id = $2 AND sd.staff_id = $2
WHERE d.tenant_id = $1 WHERE d.tenant_id = $1
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM') AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID')
ORDER BY d.name ASC ORDER BY d.name ASC
`, `,
[context.tenant.tenantId, context.staff.staffId] [context.tenant.tenantId, context.staff.staffId]
@@ -1142,7 +1401,343 @@ export async function listStaffBenefits(actorUid) {
return result.rows; return result.rows;
} }
export async function listStaffBenefitHistory(actorUid, { limit, offset } = {}) {
const context = await requireStaffContext(actorUid);
const safeLimit = parseLimit(limit, 20, 100);
const safeOffset = Number.isFinite(Number(offset)) && Number(offset) >= 0 ? Number(offset) : 0;
const result = await query(
`
SELECT
id AS "historyId",
benefit_id AS "benefitId",
benefit_type AS "benefitType",
title,
status,
effective_at AS "effectiveAt",
ended_at AS "endedAt",
tracked_hours AS "trackedHours",
target_hours AS "targetHours",
notes,
metadata
FROM staff_benefit_history
WHERE tenant_id = $1
AND staff_id = $2
ORDER BY effective_at DESC, created_at DESC
LIMIT $3 OFFSET $4
`,
[context.tenant.tenantId, context.staff.staffId, safeLimit, safeOffset]
);
return result.rows;
}
export async function listSwapRequests(actorUid, { shiftId, status = 'OPEN', limit } = {}) {
const context = await requireClientContext(actorUid);
const safeLimit = parseLimit(limit, 20, 100);
const allowedStatuses = new Set(['OPEN', 'RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED']);
const normalizedStatus = allowedStatuses.has(`${status || 'OPEN'}`.toUpperCase())
? `${status || 'OPEN'}`.toUpperCase()
: 'OPEN';
const swapResult = await query(
`
SELECT
srq.id AS "swapRequestId",
srq.shift_id AS "shiftId",
srq.shift_role_id AS "roleId",
srq.original_assignment_id AS "originalAssignmentId",
srq.original_staff_id AS "originalStaffId",
srq.status,
srq.reason,
srq.expires_at AS "expiresAt",
srq.resolved_at AS "resolvedAt",
s.title AS "shiftTitle",
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(cp.label, s.location_name) AS location,
COALESCE(cp.address, s.location_address) AS address,
b.business_name AS "clientName",
st.full_name AS "originalStaffName",
sr.role_name AS "roleName"
FROM shift_swap_requests srq
JOIN shifts s ON s.id = srq.shift_id
JOIN shift_roles sr ON sr.id = srq.shift_role_id
JOIN staffs st ON st.id = srq.original_staff_id
JOIN businesses b ON b.id = srq.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE srq.tenant_id = $1
AND srq.business_id = $2
AND ($3::uuid IS NULL OR srq.shift_id = $3)
AND srq.status = $4
ORDER BY srq.created_at DESC
LIMIT $5
`,
[context.tenant.tenantId, context.business.businessId, shiftId || null, normalizedStatus, safeLimit]
);
if (swapResult.rowCount === 0) {
return [];
}
const swapIds = swapResult.rows.map((row) => row.swapRequestId);
const candidateResult = await query(
`
SELECT
srq.id AS "swapRequestId",
app.id AS "applicationId",
app.status AS "applicationStatus",
app.created_at AS "appliedAt",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
FROM shift_swap_requests srq
JOIN shifts s ON s.id = srq.shift_id
JOIN applications app ON app.shift_role_id = srq.shift_role_id
JOIN staffs st ON st.id = app.staff_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = srq.tenant_id
AND dtm.business_id = srq.business_id
AND dtm.staff_id = st.id
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE srq.id = ANY($1::uuid[])
AND app.status IN ('PENDING', 'CONFIRMED')
ORDER BY srq.created_at DESC, "dispatchPriority" ASC, st.average_rating DESC, app.created_at ASC
`,
[swapIds]
);
const candidatesBySwapId = new Map();
for (const row of candidateResult.rows) {
if (!candidatesBySwapId.has(row.swapRequestId)) {
candidatesBySwapId.set(row.swapRequestId, []);
}
candidatesBySwapId.get(row.swapRequestId).push(row);
}
return swapResult.rows.map((row) => ({
...row,
candidates: candidatesBySwapId.get(row.swapRequestId) || [],
candidateCount: (candidatesBySwapId.get(row.swapRequestId) || []).length,
}));
}
export async function listCoreTeam(actorUid) { export async function listCoreTeam(actorUid) {
const context = await requireClientContext(actorUid);
const result = await query(
`
SELECT
dtm.id AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(sf.id IS NOT NULL, FALSE) AS favorite,
dtm.team_type AS "teamType"
FROM dispatch_team_memberships dtm
JOIN staffs st ON st.id = dtm.staff_id
LEFT JOIN staff_favorites sf
ON sf.staff_id = dtm.staff_id
AND sf.tenant_id = dtm.tenant_id
AND sf.business_id = dtm.business_id
WHERE dtm.tenant_id = $1
AND dtm.business_id = $2
AND dtm.status = 'ACTIVE'
AND dtm.team_type = 'CORE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
ORDER BY st.average_rating DESC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
if (result.rowCount > 0) {
return result.rows;
}
const favoritesFallback = await query(
`
SELECT
NULL::uuid AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
TRUE AS favorite,
'CORE'::text AS "teamType"
FROM staff_favorites sf
JOIN staffs st ON st.id = sf.staff_id
WHERE sf.tenant_id = $1
AND sf.business_id = $2
ORDER BY st.average_rating DESC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
return favoritesFallback.rows;
}
export async function listCoverageDispatchTeams(actorUid, { hubId, teamType } = {}) {
const context = await requireClientContext(actorUid);
const normalizedTeamType = teamType ? `${teamType}`.toUpperCase() : null;
const result = await query(
`
SELECT
dtm.id AS "membershipId",
dtm.staff_id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
dtm.team_type AS "teamType",
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS "dispatchPriority",
dtm.source,
dtm.status,
dtm.reason,
dtm.effective_at AS "effectiveAt",
dtm.expires_at AS "expiresAt",
dtm.hub_id AS "hubId",
cp.label AS "hubLabel"
FROM dispatch_team_memberships dtm
JOIN staffs st ON st.id = dtm.staff_id
LEFT JOIN clock_points cp ON cp.id = dtm.hub_id
WHERE dtm.tenant_id = $1
AND dtm.business_id = $2
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND ($3::uuid IS NULL OR dtm.hub_id = $3)
AND ($4::text IS NULL OR dtm.team_type = $4)
ORDER BY "dispatchPriority" ASC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId, hubId || null, normalizedTeamType]
);
return result.rows;
}
export async function listCoverageDispatchCandidates(actorUid, { shiftId, roleId, limit } = {}) {
const context = await requireClientContext(actorUid);
if (!shiftId) {
throw new AppError('VALIDATION_ERROR', 'shiftId is required', 400, { field: 'shiftId' });
}
const safeLimit = parseLimit(limit, 25, 100);
const result = await query(
`
WITH target_role AS (
SELECT
s.id AS shift_id,
s.tenant_id,
s.business_id,
s.clock_point_id,
sr.id AS shift_role_id,
sr.role_id,
sr.role_code,
sr.role_name
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
WHERE s.tenant_id = $1
AND s.business_id = $2
AND s.id = $3
AND ($4::uuid IS NULL OR sr.id = $4)
ORDER BY sr.created_at ASC
LIMIT 1
)
SELECT
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority",
dispatch.hub_id AS "dispatchHubId"
FROM target_role tr
JOIN staffs st
ON st.tenant_id = tr.tenant_id
AND st.status = 'ACTIVE'
LEFT JOIN staff_blocks sb
ON sb.tenant_id = tr.tenant_id
AND sb.business_id = tr.business_id
AND sb.staff_id = st.id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
dtm.hub_id,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = tr.tenant_id
AND dtm.business_id = tr.business_id
AND dtm.staff_id = st.id
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = tr.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = tr.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE sb.id IS NULL
AND (
st.primary_role = tr.role_code
OR EXISTS (
SELECT 1
FROM staff_roles str
WHERE str.staff_id = st.id
AND str.role_id = tr.role_id
)
)
AND NOT EXISTS (
SELECT 1
FROM assignments a
WHERE a.shift_id = tr.shift_id
AND a.staff_id = st.id
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
)
ORDER BY "dispatchPriority" ASC, st.average_rating DESC, st.full_name ASC
LIMIT $5
`,
[context.tenant.tenantId, context.business.businessId, shiftId, roleId || null, safeLimit]
);
return result.rows;
}
export async function listBlockedStaff(actorUid) {
const context = await requireClientContext(actorUid); const context = await requireClientContext(actorUid);
const result = await query( const result = await query(
` `
@@ -1150,14 +1745,14 @@ export async function listCoreTeam(actorUid) {
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", sb.reason,
st.rating_count AS "ratingCount", sb.issue_flags AS "issueFlags",
TRUE AS favorite sb.created_at AS "blockedAt"
FROM staff_favorites sf FROM staff_blocks sb
JOIN staffs st ON st.id = sf.staff_id JOIN staffs st ON st.id = sb.staff_id
WHERE sf.tenant_id = $1 WHERE sb.tenant_id = $1
AND sf.business_id = $2 AND sb.business_id = $2
ORDER BY st.average_rating DESC, st.full_name ASC ORDER BY sb.created_at DESC, st.full_name ASC
`, `,
[context.tenant.tenantId, context.business.businessId] [context.tenant.tenantId, context.business.businessId]
); );
@@ -1224,19 +1819,29 @@ export async function listBusinessTeamMembers(actorUid) {
SELECT SELECT
bm.id AS "businessMembershipId", bm.id AS "businessMembershipId",
u.id AS "userId", u.id AS "userId",
COALESCE(u.display_name, u.email) AS name, u.display_name AS "displayName",
u.email, u.email,
bm.business_role AS role bm.invited_email AS "invitedEmail",
bm.business_role AS role,
bm.membership_status AS "membershipStatus",
bm.metadata
FROM business_memberships bm FROM business_memberships bm
JOIN users u ON u.id = bm.user_id LEFT JOIN users u ON u.id = bm.user_id
WHERE bm.tenant_id = $1 WHERE bm.tenant_id = $1
AND bm.business_id = $2 AND bm.business_id = $2
AND bm.membership_status = 'ACTIVE' AND bm.membership_status IN ('ACTIVE', 'INVITED')
ORDER BY name ASC ORDER BY COALESCE(u.display_name, u.email, bm.invited_email) ASC
`, `,
[context.tenant.tenantId, context.business.businessId] [context.tenant.tenantId, context.business.businessId]
); );
return result.rows; return result.rows.map((row) => ({
businessMembershipId: row.businessMembershipId,
userId: row.userId,
name: membershipDisplayName(row),
email: row.email || row.invitedEmail || null,
role: row.role,
membershipStatus: row.membershipStatus,
}));
} }
export async function getReportSummary(actorUid, { startDate, endDate }) { export async function getReportSummary(actorUid, { startDate, endDate }) {
@@ -1645,9 +2250,12 @@ export async function listTaxForms(actorUid) {
SELECT SELECT
d.id AS "documentId", d.id AS "documentId",
d.name AS "formType", d.name AS "formType",
COALESCE(d.metadata->>'description', '') AS description,
sd.id AS "staffDocumentId", sd.id AS "staffDocumentId",
sd.file_uri AS "fileUri",
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields,
sd.expires_at AS "expiresAt"
FROM documents d FROM documents d
LEFT JOIN staff_documents sd LEFT JOIN staff_documents sd
ON sd.document_id = d.id ON sd.document_id = d.id

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)}`;
@@ -37,6 +39,7 @@ async function apiCall(path, {
idempotencyKey, idempotencyKey,
body, body,
expectedStatus = 200, expectedStatus = 200,
allowFailure = false,
} = {}) { } = {}) {
const headers = {}; const headers = {};
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
@@ -49,6 +52,12 @@ async function apiCall(path, {
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
const payload = await readJson(response); const payload = await readJson(response);
if (allowFailure) {
return {
statusCode: response.status,
body: payload,
};
}
if (response.status !== expectedStatus) { if (response.status !== expectedStatus) {
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
} }
@@ -87,6 +96,70 @@ async function uploadFile(path, token, {
return payload; return payload;
} }
async function finalizeVerifiedUpload({
token,
uploadCategory,
filename,
contentType,
content,
finalizePath,
finalizeMethod = 'PUT',
verificationType,
subjectId,
rules = {},
finalizeBody = {},
}) {
const uploaded = await uploadFile('/upload-file', token, {
filename,
contentType,
content,
fields: {
visibility: 'private',
category: uploadCategory,
},
});
const signed = await apiCall('/create-signed-url', {
method: 'POST',
token,
body: {
fileUri: uploaded.fileUri,
expiresInSeconds: 300,
},
});
const verification = await apiCall('/verifications', {
method: 'POST',
token,
body: {
type: verificationType,
subjectType: 'worker',
subjectId,
fileUri: uploaded.fileUri,
rules,
},
expectedStatus: 202,
});
const finalized = await apiCall(finalizePath, {
method: finalizeMethod,
token,
body: {
...finalizeBody,
verificationId: verification.verificationId,
fileUri: signed.signedUrl,
photoUrl: signed.signedUrl,
},
});
return {
uploaded,
signed,
verification,
finalized,
};
}
async function signInClient() { async function signInClient() {
return apiCall('/auth/client/sign-in', { return apiCall('/auth/client/sign-in', {
method: 'POST', method: 'POST',
@@ -104,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,
@@ -120,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,
@@ -210,6 +296,10 @@ async function main() {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
}); });
assert.ok(Array.isArray(clientReorders.items)); assert.ok(Array.isArray(clientReorders.items));
if (clientReorders.items[0]) {
assert.equal(typeof clientReorders.items[0].hourlyRateCents, 'number');
assert.equal(typeof clientReorders.items[0].totalPriceCents, 'number');
}
logStep('client.reorders.ok', { count: clientReorders.items.length }); logStep('client.reorders.ok', { count: clientReorders.items.length });
const billingAccounts = await apiCall('/client/billing/accounts', { const billingAccounts = await apiCall('/client/billing/accounts', {
@@ -267,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,
}); });
@@ -313,10 +410,37 @@ async function main() {
assert.ok(Array.isArray(teamMembers.items)); assert.ok(Array.isArray(teamMembers.items));
logStep('client.team-members.ok', { count: teamMembers.items.length }); logStep('client.team-members.ok', { count: teamMembers.items.length });
const createdShiftManager = await apiCall('/client/shift-managers', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('create-shift-manager'),
body: {
hubId: fixture.clockPoint.id,
email: `smoke.manager.${Date.now()}@krowd.com`,
firstName: 'Smoke',
lastName: 'Manager',
phone: '+15550009999',
},
});
assert.ok(createdShiftManager.businessMembershipId);
assert.equal(createdShiftManager.membershipStatus, 'INVITED');
assert.ok(createdShiftManager.managerAssignmentId);
logStep('client.shift-manager.create.ok', createdShiftManager);
const teamMembersAfterCreate = await apiCall('/client/team-members', {
token: ownerSession.sessionToken,
});
assert.ok(teamMembersAfterCreate.items.some((item) => item.businessMembershipId === createdShiftManager.businessMembershipId));
logStep('client.team-members.after-create.ok', { count: teamMembersAfterCreate.items.length });
const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, { const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
}); });
assert.ok(Array.isArray(viewedOrders.items)); assert.ok(Array.isArray(viewedOrders.items));
if (viewedOrders.items[0]) {
assert.ok(viewedOrders.items[0].clientName);
assert.equal(typeof viewedOrders.items[0].hourlyRate, 'number');
}
logStep('client.orders.view.ok', { count: viewedOrders.items.length }); logStep('client.orders.view.ok', { count: viewedOrders.items.length });
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
@@ -519,7 +643,7 @@ async function main() {
assert.ok(createdPermanentOrder.orderId); assert.ok(createdPermanentOrder.orderId);
logStep('client.orders.create-permanent.ok', createdPermanentOrder); logStep('client.orders.create-permanent.ok', createdPermanentOrder);
const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, { const editedOrderCopy = await apiCall(`/client/orders/${createdRecurringOrder.orderId}/edit`, {
method: 'POST', method: 'POST',
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('order-edit'), idempotencyKey: uniqueKey('order-edit'),
@@ -528,6 +652,7 @@ async function main() {
}, },
}); });
assert.ok(editedOrderCopy.orderId); assert.ok(editedOrderCopy.orderId);
assert.notEqual(editedOrderCopy.orderId, createdRecurringOrder.orderId);
logStep('client.orders.edit-copy.ok', editedOrderCopy); logStep('client.orders.edit-copy.ok', editedOrderCopy);
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, { const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
@@ -538,6 +663,7 @@ async function main() {
reason: 'Smoke cancel validation', reason: 'Smoke cancel validation',
}, },
}); });
assert.equal(cancelledOrder.futureOnly, true);
logStep('client.orders.cancel.ok', cancelledOrder); logStep('client.orders.cancel.ok', cancelledOrder);
const coverageReview = await apiCall('/client/coverage/reviews', { const coverageReview = await apiCall('/client/coverage/reviews', {
@@ -609,6 +735,14 @@ async function main() {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
assert.ok(Array.isArray(staffDashboard.recommendedShifts)); assert.ok(Array.isArray(staffDashboard.recommendedShifts));
if (staffDashboard.todaysShifts[0]) {
assert.ok(staffDashboard.todaysShifts[0].clientName);
assert.equal(typeof staffDashboard.todaysShifts[0].totalRate, 'number');
}
if (staffDashboard.recommendedShifts[0]) {
assert.ok(staffDashboard.recommendedShifts[0].clientName);
assert.equal(typeof staffDashboard.recommendedShifts[0].totalRate, 'number');
}
logStep('staff.dashboard.ok', { logStep('staff.dashboard.ok', {
todaysShifts: staffDashboard.todaysShifts.length, todaysShifts: staffDashboard.todaysShifts.length,
recommendedShifts: staffDashboard.recommendedShifts.length, recommendedShifts: staffDashboard.recommendedShifts.length,
@@ -672,7 +806,9 @@ async function main() {
}); });
const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id) const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id)
|| openShifts.items[0]; || openShifts.items[0];
const blockedApplyCandidate = openShifts.items.find((shift) => shift.shiftId !== openShift.shiftId);
assert.ok(openShift); assert.ok(openShift);
assert.ok(blockedApplyCandidate);
logStep('staff.shifts.open.ok', { count: openShifts.items.length }); logStep('staff.shifts.open.ok', { count: openShifts.items.length });
const pendingShifts = await apiCall('/staff/shifts/pending', { const pendingShifts = await apiCall('/staff/shifts/pending', {
@@ -693,12 +829,22 @@ async function main() {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
assert.ok(Array.isArray(completedShifts.items)); assert.ok(Array.isArray(completedShifts.items));
if (completedShifts.items[0]) {
assert.ok(completedShifts.items[0].clientName);
assert.ok(completedShifts.items[0].date);
assert.ok(completedShifts.items[0].startTime);
assert.ok(completedShifts.items[0].endTime);
assert.equal(typeof completedShifts.items[0].hourlyRate, 'number');
assert.equal(typeof completedShifts.items[0].totalRate, 'number');
}
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length }); logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, { const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
assert.equal(shiftDetail.shiftId, openShift.shiftId); assert.equal(shiftDetail.shiftId, openShift.shiftId);
assert.equal(typeof shiftDetail.latitude, 'number');
assert.equal(typeof shiftDetail.longitude, 'number');
logStep('staff.shifts.detail.ok', shiftDetail); logStep('staff.shifts.detail.ok', shiftDetail);
const profileSections = await apiCall('/staff/profile/sections', { const profileSections = await apiCall('/staff/profile/sections', {
@@ -727,6 +873,7 @@ async function main() {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
assert.ok(Array.isArray(profileDocumentsBefore.items)); assert.ok(Array.isArray(profileDocumentsBefore.items));
assert.ok(profileDocumentsBefore.items.every((item) => item.documentType !== 'ATTIRE'));
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length }); logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
const attireChecklistBefore = await apiCall('/staff/profile/attire', { const attireChecklistBefore = await apiCall('/staff/profile/attire', {
@@ -765,6 +912,13 @@ async function main() {
assert.ok(Array.isArray(benefits.items)); assert.ok(Array.isArray(benefits.items));
logStep('staff.profile.benefits.ok', { count: benefits.items.length }); logStep('staff.profile.benefits.ok', { count: benefits.items.length });
const benefitHistory = await apiCall('/staff/profile/benefits/history?limit=10', {
token: staffAuth.idToken,
});
assert.ok(Array.isArray(benefitHistory.items));
assert.ok(benefitHistory.items.length >= 1);
logStep('staff.profile.benefits.history.ok', { count: benefitHistory.items.length });
const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, { const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
@@ -1054,7 +1208,18 @@ async function main() {
assert.ok(clockOut.securityProofId); assert.ok(clockOut.securityProofId);
logStep('staff.clock-out.ok', clockOut); logStep('staff.clock-out.ok', clockOut);
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { const submittedCompletedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/submit-for-approval`, {
method: 'POST',
token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-submit-approval'),
body: {
note: 'Smoke approval submission',
},
});
assert.equal(submittedCompletedShift.submitted, true);
logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift);
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/request-swap`, {
method: 'POST', method: 'POST',
token: staffAuth.idToken, token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-swap'), idempotencyKey: uniqueKey('staff-shift-swap'),
@@ -1064,6 +1229,107 @@ 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', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('coverage-block'),
body: {
staffId: fixture.staff.ana.id,
assignmentId: fixture.assignments.completedAna.id,
rating: 2,
markAsBlocked: true,
markAsFavorite: false,
issueFlags: ['LATE_CLOCK_IN'],
feedback: 'Smoke blocked staff test',
},
});
assert.equal(blockedReview.markAsBlocked, true);
logStep('client.coverage.block.ok', blockedReview);
const blockedStaff = await apiCall('/client/coverage/blocked-staff', {
token: ownerSession.sessionToken,
});
assert.ok(blockedStaff.items.some((item) => item.staffId === fixture.staff.ana.id));
logStep('client.coverage.blocked-staff.ok', { count: blockedStaff.items.length });
const blockedApplyAttempt = await apiCall(`/staff/shifts/${blockedApplyCandidate.shiftId}/apply`, {
method: 'POST',
token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-apply-blocked'),
body: {
roleId: blockedApplyCandidate.roleId,
},
allowFailure: true,
});
assert.equal(blockedApplyAttempt.statusCode, 409);
assert.equal(blockedApplyAttempt.body?.code, 'STAFF_BLOCKED');
logStep('staff.shifts.apply-blocked.ok', blockedApplyAttempt.body);
const unblockedReview = await apiCall('/client/coverage/reviews', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('coverage-unblock'),
body: {
staffId: fixture.staff.ana.id,
assignmentId: fixture.assignments.completedAna.id,
rating: 5,
markAsBlocked: false,
markAsFavorite: true,
issueFlags: [],
feedback: 'Smoke unblock cleanup',
},
});
assert.equal(unblockedReview.markAsBlocked, false);
logStep('client.coverage.unblock.ok', unblockedReview);
const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, { const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, {
filename: 'profile-photo.jpg', filename: 'profile-photo.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
@@ -1072,35 +1338,63 @@ async function main() {
assert.ok(uploadedProfilePhoto.fileUri); assert.ok(uploadedProfilePhoto.fileUri);
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto); logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, { const uploadedGovId = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-document',
filename: 'government-id.jpg', filename: 'government-id.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
content: Buffer.from('fake-government-id'), content: Buffer.from('fake-government-id'),
finalizePath: `/staff/profile/documents/${fixture.documents.governmentId.id}/upload`,
finalizeMethod: 'PUT',
verificationType: 'government_id',
subjectId: fixture.documents.governmentId.id,
rules: {
documentId: fixture.documents.governmentId.id,
},
}); });
assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id); assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id);
logStep('staff.profile.document.upload.ok', uploadedGovId); logStep('staff.profile.document.upload.ok', uploadedGovId.finalized);
const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, { const uploadedAttire = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-attire',
filename: 'black-shirt.jpg', filename: 'black-shirt.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
content: Buffer.from('fake-black-shirt'), content: Buffer.from('fake-black-shirt'),
finalizePath: `/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`,
finalizeMethod: 'PUT',
verificationType: 'attire',
subjectId: fixture.documents.attireBlackShirt.id,
rules: {
dressCode: 'Black shirt',
},
}); });
assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id); assert.equal(uploadedAttire.finalized.documentId, fixture.documents.attireBlackShirt.id);
logStep('staff.profile.attire.upload.ok', uploadedAttire); logStep('staff.profile.attire.upload.ok', uploadedAttire.finalized);
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`; const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, { const uploadedCertificate = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-certificate',
filename: 'certificate.pdf', filename: 'certificate.pdf',
contentType: 'application/pdf', contentType: 'application/pdf',
content: Buffer.from('fake-certificate'), content: Buffer.from('fake-certificate'),
fields: { finalizePath: '/staff/profile/certificates',
finalizeMethod: 'POST',
verificationType: 'certification',
subjectId: certificateType,
rules: {
certificateName: 'Alcohol Service Permit',
certificateIssuer: 'Demo Issuer',
},
finalizeBody: {
certificateType, certificateType,
name: 'Alcohol Service Permit', name: 'Alcohol Service Permit',
issuer: 'Demo Issuer', issuer: 'Demo Issuer',
}, },
}); });
assert.equal(uploadedCertificate.certificateType, certificateType); assert.equal(uploadedCertificate.finalized.certificateType, certificateType);
logStep('staff.profile.certificate.upload.ok', uploadedCertificate); logStep('staff.profile.certificate.upload.ok', uploadedCertificate.finalized);
const profileDocumentsAfter = await apiCall('/staff/profile/documents', { const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
token: staffAuth.idToken, token: staffAuth.idToken,

View File

@@ -20,14 +20,15 @@ const DIRECT_CORE_ALIASES = [
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/process$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` }, { methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
{ {
methods: new Set(['POST']), methods: new Set(['POST', 'PUT']),
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/, pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`, targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
}, },
{ {
methods: new Set(['POST']), methods: new Set(['POST', 'PUT']),
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/, pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`, targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
}, },

View File

@@ -182,3 +182,56 @@ test('proxy forwards direct core upload aliases to core api', async () => {
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload'); assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
}); });
test('proxy forwards PUT document upload aliases to core api', async () => {
process.env.QUERY_API_BASE_URL = 'https://query.example';
process.env.CORE_API_BASE_URL = 'https://core.example';
process.env.COMMAND_API_BASE_URL = 'https://command.example';
let seenUrl = null;
let seenMethod = null;
const app = createApp({
fetchImpl: async (url, init = {}) => {
seenUrl = `${url}`;
seenMethod = init.method;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const res = await request(app)
.put('/staff/profile/documents/doc-1/upload')
.set('Authorization', 'Bearer test-token')
.send({ verificationId: 'verification-1' });
assert.equal(res.status, 200);
assert.equal(seenMethod, 'PUT');
assert.equal(seenUrl, 'https://core.example/core/staff/documents/doc-1/upload');
});
test('proxy forwards rapid order process alias to core api', async () => {
process.env.QUERY_API_BASE_URL = 'https://query.example';
process.env.CORE_API_BASE_URL = 'https://core.example';
process.env.COMMAND_API_BASE_URL = 'https://command.example';
let seenUrl = null;
const app = createApp({
fetchImpl: async (url) => {
seenUrl = `${url}`;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const res = await request(app)
.post('/rapid-orders/process')
.set('Authorization', 'Bearer test-token')
.send({ text: 'Need 2 servers ASAP for 4 hours' });
assert.equal(res.status, 200);
assert.equal(seenUrl, 'https://core.example/core/rapid-orders/process');
});

View File

@@ -4,7 +4,8 @@
> **Status: LEGACY / ARCHIVED REFERENCE** > **Status: LEGACY / ARCHIVED REFERENCE**
> This document is based on a historical export from the Base44 platform. > This document is based on a historical export from the Base44 platform.
> It is maintained in this repository solely for reference purposes during the rebuild and is **not** to be considered the definitive or active API specification for the production system. > It is maintained in this repository solely for reference purposes during the rebuild and is **not** to be considered the definitive or active API specification for the production system.
> The actual data schemas and operations are now defined directly within `backend/dataconnect/`. > The historical V1 Data Connect schemas and operations now live in `legacy/dataconnect-v1/`.
> The active V2 backend source of truth is the unified/backend service stack under `backend/command-api`, `backend/query-api`, `backend/core-api`, and `backend/unified-api`.
**Original Version:** 3.0 **Original Version:** 3.0
**Original Date:** 2025-11-20 **Original Date:** 2025-11-20

View File

@@ -926,7 +926,7 @@ Via modular `profile_sections/` architecture:
| Legacy Client Mobile | `_legacy/apps/krow_client_context.md` | Flutter (context doc) | | Legacy Client Mobile | `_legacy/apps/krow_client_context.md` | Flutter (context doc) |
| Legacy Worker (Legacy Staff) Mobile | `_legacy/apps/krow_staff_context.md` | Flutter (context doc) | | Legacy Worker (Legacy Staff) Mobile | `_legacy/apps/krow_staff_context.md` | Flutter (context doc) |
| Legacy Backend | `_legacy/apps/php_backend_context.md` | PHP/Laravel | | Legacy Backend | `_legacy/apps/php_backend_context.md` | PHP/Laravel |
| New Backend | `backend/dataconnect/` | Firebase Data Connect | | Legacy V1 Data Connect Backend | `legacy/dataconnect-v1/` | Firebase Data Connect |
--- ---
@@ -974,7 +974,7 @@ For each feature in Sprint 3:
2. **Write Schema Incrementally** 2. **Write Schema Incrementally**
```gql ```gql
# backend/dataconnect/schema/event.gql # legacy/dataconnect-v1/schema/event.gql
type Event @table { type Event @table {
id: UUID! @default(expr: "uuidV4()") id: UUID! @default(expr: "uuidV4()")
name: String! name: String!
@@ -989,7 +989,7 @@ For each feature in Sprint 3:
3. **Define Queries/Mutations** 3. **Define Queries/Mutations**
```gql ```gql
# backend/dataconnect/queries/events.gql # legacy/dataconnect-v1/queries/events.gql
query ListEvents($status: EventStatus) @auth(level: USER) { query ListEvents($status: EventStatus) @auth(level: USER) {
events(where: { status: { eq: $status } }) { events(where: { status: { eq: $status } }) {
id, name, status, date id, name, status, date
@@ -1308,7 +1308,7 @@ Business (1) ──┬── (1) BusinessSetting
| Legacy Client context | `_legacy/apps/krow_client_context.md` | | Legacy Client context | `_legacy/apps/krow_client_context.md` |
| Legacy Staff context | `_legacy/apps/krow_staff_context.md` | | Legacy Staff context | `_legacy/apps/krow_staff_context.md` |
| Architecture diagrams | `internal/launchpad/assets/diagrams/` | | Architecture diagrams | `internal/launchpad/assets/diagrams/` |
| Data Connect schemas | `backend/dataconnect/` | | Data Connect schemas | `legacy/dataconnect-v1/` |
--- ---

View File

@@ -22,10 +22,13 @@ What was validated live against the deployed stack:
- staff auth bootstrap - staff auth bootstrap
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports - client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
- 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 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 availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request - staff benefit history read model
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, swap request, and completed-shift submission
- direct file upload helpers and verification job creation through the unified host - direct file upload helpers and verification job creation through the unified host
- client and staff sign-out - client and staff sign-out
@@ -107,6 +110,20 @@ Important operational rules:
- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed - 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
@@ -143,6 +160,9 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend
- [Authentication](./authentication.md) - [Authentication](./authentication.md)
- [Unified API](./unified-api.md) - [Unified API](./unified-api.md)
- [Mobile Coding Agent Spec](./mobile-coding-agent-spec.md)
- [Mobile Frontend Implementation Spec](./mobile-frontend-implementation-spec.md)
- [Staff Shifts](./staff-shifts.md)
- [Core API](./core-api.md) - [Core API](./core-api.md)
- [Command API](./command-api.md) - [Command API](./command-api.md)
- [Query API](./query-api.md) - [Query API](./query-api.md)

View File

@@ -0,0 +1,388 @@
# Mobile Coding Agent Spec
This document is the frontend handoff spec for an AI coding agent working on the mobile applications against the v2 backend.
Use this as the primary implementation brief.
Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app`
Supporting docs:
- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/authentication.md`
- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/unified-api.md`
- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md`
- `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/staff-shifts.md`
## 1) Non-negotiable rules
- Use the unified base URL only.
- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend.
- Send `Authorization: Bearer <firebase-id-token>` on protected routes.
- Send `Idempotency-Key` on every write route.
- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects.
- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`.
## 2) What is implemented now
Safe to build against now:
- client auth/session
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, reports
- client coverage reviews, worker rating, block/unblock, late-worker cancellation
- client swap review and dispatch-team management
- staff auth/session
- staff dashboard, availability, payments, shifts, profile, tax forms, bank accounts, benefits, documents, attire, certificates
- staff clock-in, clock-out, location streaming, swap request, completed-shift submission
- upload, signed URL, verification, and rapid-order processing
Not part of this implementation spec:
- backend `/auth/refresh`
- SMS or email notification fallback
- AI-driven report insights
- AI-driven personalized shift matching
- full NFC attestation enforcement
- chat backend
## 3) Auth implementation
### Client app
Use:
- `POST /auth/client/sign-in`
- `POST /auth/client/sign-up`
- `GET /auth/session`
- `POST /auth/client/sign-out`
Do not build a separate refresh route.
Token refresh remains on the Firebase client SDK side.
### Staff app
Use:
- `POST /auth/staff/phone/start`
- `POST /auth/staff/phone/verify`
- `GET /auth/session`
- `POST /auth/staff/sign-out`
Important:
- `POST /auth/staff/phone/start` can return `mode = CLIENT_FIREBASE_SDK`
- when that happens, the app must complete Firebase phone verification on-device
- after that, the app must call `POST /auth/staff/phone/verify` with the Firebase `idToken`
Do not assume staff auth is a fully backend-managed OTP flow.
## 4) Core data model assumptions
- `order`: client-facing staffing request
- `shift`: a scheduled work instance under an order
- `shiftRole`: a role slot inside a shift
- `application`: worker applies to a `shiftRole`
- `assignment`: worker is actually attached to a shift
Rules:
- `GET /staff/shifts/open` returns opportunities, not assignments
- `GET /staff/shifts/assigned` returns active assigned shifts
- `GET /client/orders/view` is the timeline/read model for client
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only
## 5) Client app screen mapping
### Home
- `GET /client/session`
- `GET /client/dashboard`
- `GET /client/reorders`
### Billing
- `GET /client/billing/accounts`
- `GET /client/billing/invoices/pending`
- `GET /client/billing/invoices/history`
- `GET /client/billing/current-bill`
- `GET /client/billing/savings`
- `GET /client/billing/spend-breakdown`
- `POST /client/billing/invoices/:invoiceId/approve`
- `POST /client/billing/invoices/:invoiceId/dispute`
### Coverage
- `GET /client/coverage?date=YYYY-MM-DD`
- `GET /client/coverage/stats?date=YYYY-MM-DD`
- `GET /client/coverage/core-team?date=YYYY-MM-DD`
- `GET /client/coverage/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `GET /client/coverage/blocked-staff`
- `GET /client/coverage/swap-requests?status=OPEN`
- `GET /client/coverage/dispatch-teams`
- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid`
- `POST /client/coverage/reviews`
- `POST /client/coverage/late-workers/:assignmentId/cancel`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`
- `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
Coverage review payload:
```json
{
"assignmentId": "uuid",
"rating": 4,
"comment": "Strong performance on the shift",
"markAsBlocked": false
}
```
Rules:
- worker rating happens through `POST /client/coverage/reviews`
- the same endpoint also supports `markAsFavorite` to add or remove a worker from business favorites
- blocking a worker is done through the same endpoint using `markAsBlocked`
- dispatch ranking order is:
1. `CORE`
2. `CERTIFIED_LOCATION`
3. `MARKETPLACE`
Swap management flow:
1. worker requests swap
2. backend moves original assignment to `SWAP_REQUESTED`
3. replacement workers see the shift in `GET /staff/shifts/open`
4. client/ops reads `GET /client/coverage/swap-requests`
5. client/ops reads `GET /client/coverage/dispatch-candidates`
6. client/ops resolves or cancels the swap request
7. if unresolved and expired, backend auto-cancels it
### Orders
- `GET /client/orders/view`
- `GET /client/orders/:orderId/reorder-preview`
- `POST /client/orders/one-time`
- `POST /client/orders/recurring`
- `POST /client/orders/permanent`
- `POST /client/orders/:orderId/edit`
- `POST /client/orders/:orderId/cancel`
- `POST /rapid-orders/process`
Rules:
- use `POST /rapid-orders/process` for the single-call rapid-order flow
- recent orders should expect total price and hourly rate fields
- order edit and cancel only affect future shifts
### Hubs and managers
- `GET /client/hubs`
- `GET /client/cost-centers`
- `GET /client/hubs/:hubId/managers`
- `GET /client/team-members`
- `POST /client/shift-managers`
- `POST /client/hubs`
- `PUT /client/hubs/:hubId`
- `DELETE /client/hubs/:hubId`
- `POST /client/hubs/:hubId/assign-nfc`
- `POST /client/hubs/:hubId/managers`
Rules:
- `POST /client/shift-managers` creates an invited manager identity
- if `hubId` is present, backend links the manager to that hub too
### Reports
- `GET /client/reports/summary?date=YYYY-MM-DD`
- `GET /client/reports/daily-ops?date=YYYY-MM-DD`
- `GET /client/reports/spend?date=YYYY-MM-DD`
- `GET /client/reports/coverage?date=YYYY-MM-DD`
- `GET /client/reports/forecast?date=YYYY-MM-DD`
- `GET /client/reports/performance?date=YYYY-MM-DD`
- `GET /client/reports/no-show?date=YYYY-MM-DD`
Important:
- these are operational reports
- this is not the same as the separate AI insights research issue
## 6) Staff app screen mapping
### Home
- `GET /staff/session`
- `GET /staff/dashboard`
- `GET /staff/profile-completion`
### Availability
- `GET /staff/availability`
- `PUT /staff/availability`
- `POST /staff/availability/quick-set`
### Find shifts
- `GET /staff/shifts/open`
- `POST /staff/shifts/:shiftId/apply`
Rule:
- use `roleId` from the open-shifts response
### My shifts
- `GET /staff/shifts/pending`
- `GET /staff/shifts/assigned`
- `GET /staff/shifts/cancelled`
- `GET /staff/shifts/completed`
- `GET /staff/shifts/:shiftId`
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
Staff shift detail and list rules:
- assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime`
- shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate`
- completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate`
### Clock in / clock out
- `GET /staff/clock-in/shifts/today`
- `GET /staff/clock-in/status`
- `POST /staff/clock-in`
- `POST /staff/clock-out`
- `POST /staff/location-streams`
Clock-in payload rules:
- if using NFC, send `nfcTagId`
- if using geo, send `latitude`, `longitude`, `accuracyMeters`
- send `overrideReason` only when geo override is allowed
- send `proofNonce` and `proofTimestamp` on attendance writes
- send `attestationProvider` and `attestationToken` only if the device has them
Clock-in read rules:
`GET /staff/clock-in/shifts/today` returns fields including:
- `clientName`
- `hourlyRate`
- `totalRate`
- `latitude`
- `longitude`
- `clockInMode`
- `allowClockInOverride`
- `geofenceRadiusMeters`
- `nfcTagId`
Policy values:
- `NFC_REQUIRED`
- `GEO_REQUIRED`
- `EITHER`
### Payments
- `GET /staff/payments/summary`
- `GET /staff/payments/history`
- `GET /staff/payments/chart`
### Profile
- `GET /staff/profile/sections`
- `GET /staff/profile/personal-info`
- `GET /staff/profile/industries`
- `GET /staff/profile/skills`
- `GET /staff/profile/documents`
- `GET /staff/profile/attire`
- `GET /staff/profile/tax-forms`
- `GET /staff/profile/emergency-contacts`
- `GET /staff/profile/certificates`
- `GET /staff/profile/bank-accounts`
- `GET /staff/profile/benefits`
- `GET /staff/profile/benefits/history`
- `GET /staff/profile/time-card`
- `GET /staff/profile/privacy`
- `PUT /staff/profile/personal-info`
- `PUT /staff/profile/experience`
- `PUT /staff/profile/locations`
- `POST /staff/profile/emergency-contacts`
- `PUT /staff/profile/emergency-contacts/:contactId`
- `PUT /staff/profile/tax-forms/:formType`
- `POST /staff/profile/tax-forms/:formType/submit`
- `POST /staff/profile/bank-accounts`
- `PUT /staff/profile/privacy`
Profile data rules:
- `GET /staff/profile/documents` returns documents only
- `GET /staff/profile/attire` returns attire only
- `GET /staff/profile/tax-forms` returns tax forms only
- `GET /staff/profile/certificates` returns certificates only
- benefit summary and benefit history are separate endpoints
### FAQ
- `GET /staff/faqs`
- `GET /staff/faqs/search?q=...`
## 7) Upload flows
### General upload pattern
For documents, attire, and certificates:
1. `POST /upload-file`
2. `POST /create-signed-url`
3. upload bytes to storage
4. `POST /verifications`
5. finalize using the appropriate staff route
Staff upload routes:
- `POST /staff/profile/photo`
- `POST /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/documents/:documentId/upload`
- `POST /staff/profile/attire/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
- `DELETE /staff/profile/certificates/:certificateId`
Rules:
- backend treats verification-linked file state as the source of truth
- frontend may still send `fileUri` or `photoUrl`, but verification linkage wins
## 8) What the coding agent should not assume
- do not invent a backend refresh route
- do not assume swap is staff-only; there is now a client/ops review side
- do not assume documents and attire share the same read endpoint
- do not assume backend direct CRUD on internal services
- do not assume AI reports, SMS fallback, or full NFC attestation are available
## 9) Suggested implementation order for the coding agent
1. auth/session flows
2. client home + orders + coverage
3. staff home + shifts + clock-in
4. profile sections and upload flows
5. reports and billing polish
6. swap review and dispatch-team management
## 10) Definition of done for frontend integration
Frontend implementation is aligned when:
- every screen calls the unified v2 routes only
- every write sends an `Idempotency-Key`
- staff shift apply uses `roleId` from open shifts
- clock-in respects `clockInMode`
- swap request uses the staff endpoint and swap review uses the client coverage endpoints
- documents, attire, certificates, and tax forms use their correct route families

View File

@@ -0,0 +1,305 @@
# Mobile Frontend Implementation Spec
This is the shortest path for frontend to implement the v2 mobile clients against the unified backend.
Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app`
Use this doc together with:
- [Authentication](./authentication.md)
- [Unified API](./unified-api.md)
- [Staff Shifts](./staff-shifts.md)
## 1) Global rules
- Use unified routes only.
- Send `Authorization: Bearer <firebase-id-token>` on protected routes.
- Send `Idempotency-Key` on all write routes.
- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend.
## 2) Core model frontend should assume
- `order` is the client-facing request for staffing.
- `shift` is the concrete scheduled unit of work under an order.
- `shiftRole` is the role slot inside a shift that staff apply to.
- `assignment` is the worker-to-shift record once a worker is attached.
Important consequences:
- `GET /staff/shifts/open` returns open shift-role opportunities.
- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response.
- `GET /client/orders/view` is the timeline/read model for the client app.
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts.
## 3) Auth implementation
### Client app
- sign in with `POST /auth/client/sign-in`
- sign up with `POST /auth/client/sign-up`
- hydrate session with `GET /auth/session`
- sign out with `POST /auth/client/sign-out`
### Staff app
- start phone auth with `POST /auth/staff/phone/start`
- complete phone auth with `POST /auth/staff/phone/verify`
- hydrate session with `GET /auth/session`
- sign out with `POST /auth/staff/sign-out`
Token refresh:
- keep using Firebase client SDK refresh behavior
- there is no backend `/auth/refresh` route
## 4) Client app screen mapping
### Home / dashboard
- `GET /client/session`
- `GET /client/dashboard`
- `GET /client/reorders`
### Billing / payments
- `GET /client/billing/accounts`
- `GET /client/billing/invoices/pending`
- `GET /client/billing/invoices/history`
- `GET /client/billing/current-bill`
- `GET /client/billing/savings`
- `GET /client/billing/spend-breakdown`
- `POST /client/billing/invoices/:invoiceId/approve`
- `POST /client/billing/invoices/:invoiceId/dispute`
### Coverage
- `GET /client/coverage?date=YYYY-MM-DD`
- `GET /client/coverage/stats?date=YYYY-MM-DD`
- `GET /client/coverage/core-team?date=YYYY-MM-DD`
- `GET /client/coverage/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `GET /client/coverage/blocked-staff`
- `GET /client/coverage/swap-requests?status=OPEN`
- `GET /client/coverage/dispatch-teams`
- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid`
- `POST /client/coverage/reviews`
- `POST /client/coverage/late-workers/:assignmentId/cancel`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`
- `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
Use `POST /client/coverage/reviews` when the business is rating a worker after coverage review.
Payload may include:
```json
{
"staffId": "uuid",
"assignmentId": "uuid",
"rating": 4,
"feedback": "Strong performance on the shift",
"markAsFavorite": true,
"markAsBlocked": false
}
```
If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. If `markAsBlocked` is `true`, backend blocks that worker for that business and rejects future apply or assign attempts until a later review sets `markAsBlocked: false`.
Swap-management rule:
- use `GET /client/coverage/swap-requests` as the client review feed
- use `GET /client/coverage/dispatch-candidates` for the ranked replacement list
- use `POST /client/coverage/swap-requests/:swapRequestId/resolve` when ops selects a replacement
- use `POST /client/coverage/swap-requests/:swapRequestId/cancel` when ops wants to close the swap request without replacement
Dispatch-priority rule:
1. `CORE`
2. `CERTIFIED_LOCATION`
3. `MARKETPLACE`
### Orders
- `GET /client/orders/view`
- `GET /client/orders/:orderId/reorder-preview`
- `POST /client/orders/one-time`
- `POST /client/orders/recurring`
- `POST /client/orders/permanent`
- `POST /client/orders/:orderId/edit`
- `POST /client/orders/:orderId/cancel`
Rapid-order flow:
- use `POST /rapid-orders/process` for the single-call transcribe-and-parse flow
### Hubs and managers
- `GET /client/hubs`
- `GET /client/cost-centers`
- `GET /client/hubs/:hubId/managers`
- `GET /client/team-members`
- `POST /client/shift-managers`
- `POST /client/hubs`
- `PUT /client/hubs/:hubId`
- `DELETE /client/hubs/:hubId`
- `POST /client/hubs/:hubId/assign-nfc`
- `POST /client/hubs/:hubId/managers`
`POST /client/shift-managers` is the fastest path to create an invited manager identity for a business. If `hubId` is provided, backend also links that manager to the hub.
### Reports
- `GET /client/reports/summary?date=YYYY-MM-DD`
- `GET /client/reports/daily-ops?date=YYYY-MM-DD`
- `GET /client/reports/spend?date=YYYY-MM-DD`
- `GET /client/reports/coverage?date=YYYY-MM-DD`
- `GET /client/reports/forecast?date=YYYY-MM-DD`
- `GET /client/reports/performance?date=YYYY-MM-DD`
- `GET /client/reports/no-show?date=YYYY-MM-DD`
## 5) Staff app screen mapping
### Home / dashboard
- `GET /staff/session`
- `GET /staff/dashboard`
- `GET /staff/profile-completion`
### Availability
- `GET /staff/availability`
- `PUT /staff/availability`
- `POST /staff/availability/quick-set`
### Find shifts
- `GET /staff/shifts/open`
- `POST /staff/shifts/:shiftId/apply`
Rule:
- send the `roleId` from the open-shifts response
- this is the concrete `shift_roles.id`
### My shifts
- `GET /staff/shifts/pending`
- `GET /staff/shifts/assigned`
- `GET /staff/shifts/cancelled`
- `GET /staff/shifts/completed`
- `GET /staff/shifts/:shiftId`
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
Current swap behavior:
- backend records the swap request
- assignment moves to `SWAP_REQUESTED`
- shift becomes visible in the replacement pool
- client/ops can review and resolve swap requests through the coverage endpoints
- if the swap request expires without coverage, backend auto-cancels it and alerts both the manager path and the original worker
### Clock in / clock out
- `GET /staff/clock-in/shifts/today`
- `GET /staff/clock-in/status`
- `POST /staff/clock-in`
- `POST /staff/clock-out`
- `POST /staff/location-streams`
Frontend should respect:
- `clockInMode`
- `allowClockInOverride`
- `latitude`
- `longitude`
- `geofenceRadiusMeters`
- `nfcTagId`
Clock-in proof rules:
- use `nfcTagId` for NFC clocking
- use `latitude`, `longitude`, and `accuracyMeters` for geolocation clocking
- send `overrideReason` only when a geofence override is allowed
- send `proofNonce` and `proofTimestamp` on attendance writes
### Payments
- `GET /staff/payments/summary`
- `GET /staff/payments/history`
- `GET /staff/payments/chart`
### Profile
- `GET /staff/profile/sections`
- `GET /staff/profile/personal-info`
- `GET /staff/profile/industries`
- `GET /staff/profile/skills`
- `GET /staff/profile/documents`
- `GET /staff/profile/attire`
- `GET /staff/profile/tax-forms`
- `GET /staff/profile/emergency-contacts`
- `GET /staff/profile/certificates`
- `GET /staff/profile/bank-accounts`
- `GET /staff/profile/benefits`
- `GET /staff/profile/benefits/history`
- `GET /staff/profile/time-card`
- `GET /staff/profile/privacy`
- `PUT /staff/profile/personal-info`
- `PUT /staff/profile/experience`
- `PUT /staff/profile/locations`
- `POST /staff/profile/emergency-contacts`
- `PUT /staff/profile/emergency-contacts/:contactId`
- `PUT /staff/profile/tax-forms/:formType`
- `POST /staff/profile/tax-forms/:formType/submit`
- `POST /staff/profile/bank-accounts`
- `PUT /staff/profile/privacy`
Document model rule:
- `GET /staff/profile/documents` returns only documents
- `GET /staff/profile/attire` returns attire items
- `GET /staff/profile/tax-forms` returns tax-form rows
- `GET /staff/profile/certificates` returns certificates
### FAQ
- `GET /staff/faqs`
- `GET /staff/faqs/search?q=...`
## 6) Upload implementation
For documents, attire, and certificates:
1. `POST /upload-file`
2. `POST /create-signed-url`
3. upload file bytes to storage with the signed URL
4. `POST /verifications`
5. finalize with:
- `PUT /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
Use the verification-linked file as the source of truth.
## 7) What frontend should not assume
- do not assume order edit mutates past shifts
- do not assume swap resolution is complete beyond the request step
- do not assume raw `/query/*` or `/commands/*` routes are stable for app integration
- do not assume blocked workers can still apply to future shifts for that business
## 8) Demo reset
To reset dev demo data:
```bash
source ~/.nvm/nvm.sh
nvm use 23.5.0
cd backend/command-api
npm run seed:v2-demo
```

View File

@@ -0,0 +1,183 @@
# Staff Shifts V2
This document is the frontend handoff for the `staff/shifts/*` routes on the unified v2 API.
Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app`
## Read routes
- `GET /staff/shifts/assigned`
- `GET /staff/shifts/open`
- `GET /staff/shifts/pending`
- `GET /staff/shifts/cancelled`
- `GET /staff/shifts/completed`
- `GET /staff/shifts/:shiftId`
## Write routes
- `POST /staff/shifts/:shiftId/apply`
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
All write routes require:
- `Authorization: Bearer <firebase-id-token>`
- `Idempotency-Key: <unique-per-action>`
## Shift lifecycle
### Find shifts
`GET /staff/shifts/open`
- use this for the worker marketplace feed
- the worker applies to a concrete shift role
- send the `roleId` returned by the open-shifts response
- `roleId` here means `shift_roles.id`, not the role catalog id
Apply request example:
```json
{
"roleId": "uuid",
"instantBook": false
}
```
### Pending shifts
`GET /staff/shifts/pending`
- use `POST /staff/shifts/:shiftId/accept` to accept
- use `POST /staff/shifts/:shiftId/decline` to decline
### Assigned shifts
`GET /staff/shifts/assigned`
Each item now includes:
- `clientName`
- `hourlyRate`
- `totalRate`
- `startTime`
- `endTime`
### Shift detail
`GET /staff/shifts/:shiftId`
Each detail response now includes:
- `clientName`
- `latitude`
- `longitude`
- `hourlyRate`
- `totalRate`
Use this as the source of truth for the shift detail screen.
### Request swap
`POST /staff/shifts/:shiftId/request-swap`
Example:
```json
{
"reason": "Need coverage for a family emergency"
}
```
Current backend behavior:
- marks the assignment as `SWAP_REQUESTED`
- stores the reason
- emits `SHIFT_SWAP_REQUESTED`
- exposes the shift in the replacement pool
- starts the swap-expiry window used by backend auto-cancellation
Manager/ops review happens through:
- `GET /client/coverage/swap-requests`
- `GET /client/coverage/dispatch-candidates`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`
If the swap request expires without coverage, backend auto-cancels it and alerts the manager path plus the original worker.
### Submit completed shift for approval
`POST /staff/shifts/:shiftId/submit-for-approval`
Use this after the worker has clocked out.
Example:
```json
{
"note": "Worked full shift and all tasks were completed"
}
```
Current backend behavior:
- only allows shifts in `CHECKED_OUT` or `COMPLETED`
- creates or updates the assignment timesheet
- sets the timesheet to `SUBMITTED` unless it is already `APPROVED` or `PAID`
- emits `TIMESHEET_SUBMITTED_FOR_APPROVAL`
Example response:
```json
{
"assignmentId": "uuid",
"shiftId": "uuid",
"timesheetId": "uuid",
"status": "SUBMITTED",
"submitted": true
}
```
## Completed shifts
`GET /staff/shifts/completed`
Each item now includes:
- `date`
- `clientName`
- `startTime`
- `endTime`
- `hourlyRate`
- `totalRate`
- `timesheetStatus`
- `paymentStatus`
## Clock-in support fields
`GET /staff/clock-in/shifts/today`
Each item now includes:
- `clientName`
- `hourlyRate`
- `totalRate`
- `latitude`
- `longitude`
- `clockInMode`
- `allowClockInOverride`
## Frontend rule
Use the unified routes only.
Do not build new mobile work on:
- `/query/*`
- `/commands/*`
- `/core/*`

View File

@@ -44,6 +44,10 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `GET /client/coverage/stats` - `GET /client/coverage/stats`
- `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/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`
@@ -69,6 +73,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `POST /client/orders/permanent` - `POST /client/orders/permanent`
- `POST /client/orders/:orderId/edit` - `POST /client/orders/:orderId/edit`
- `POST /client/orders/:orderId/cancel` - `POST /client/orders/:orderId/cancel`
- `POST /client/shift-managers`
- `POST /client/hubs` - `POST /client/hubs`
- `PUT /client/hubs/:hubId` - `PUT /client/hubs/:hubId`
- `DELETE /client/hubs/:hubId` - `DELETE /client/hubs/:hubId`
@@ -78,6 +83,79 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `POST /client/billing/invoices/:invoiceId/dispute` - `POST /client/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:
```json
{
"staffId": "uuid",
"assignmentId": "uuid",
"rating": 2,
"feedback": "Worker left the shift early without approval",
"markAsFavorite": false,
"issueFlags": ["LEFT_EARLY"],
"markAsBlocked": true
}
```
If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`.
Swap-review routes:
- `GET /client/coverage/swap-requests?status=OPEN`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`
Resolve example:
```json
{
"applicationId": "uuid",
"note": "Dispatch selected the strongest replacement candidate"
}
```
Dispatch-team routes:
- `GET /client/coverage/dispatch-teams`
- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid`
- `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
Dispatch-team membership example:
```json
{
"staffId": "uuid",
"hubId": "uuid",
"teamType": "CORE",
"notes": "Preferred lead barista for this location"
}
```
Dispatch priority order is:
1. `CORE`
2. `CERTIFIED_LOCATION`
3. `MARKETPLACE`
Shift-manager creation example:
```json
{
"firstName": "Nora",
"lastName": "Lead",
"email": "nora.lead@example.com",
"phone": "+15550001234",
"hubId": "uuid"
}
```
The manager is created as an invited business membership. If `hubId` is present, backend also links the manager to that hub.
## 3) Staff routes ## 3) Staff routes
@@ -109,6 +187,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `GET /staff/profile/certificates` - `GET /staff/profile/certificates`
- `GET /staff/profile/bank-accounts` - `GET /staff/profile/bank-accounts`
- `GET /staff/profile/benefits` - `GET /staff/profile/benefits`
- `GET /staff/profile/benefits/history`
- `GET /staff/profile/time-card` - `GET /staff/profile/time-card`
- `GET /staff/profile/privacy` - `GET /staff/profile/privacy`
- `GET /staff/faqs` - `GET /staff/faqs`
@@ -153,6 +232,7 @@ Example `GET /staff/clock-in/shifts/today` item:
- `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline` - `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap` - `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
- `PUT /staff/profile/personal-info` - `PUT /staff/profile/personal-info`
- `PUT /staff/profile/experience` - `PUT /staff/profile/experience`
- `PUT /staff/profile/locations` - `PUT /staff/profile/locations`
@@ -174,6 +254,7 @@ These are exposed as direct unified aliases even though they are backed by `core
- `POST /invoke-llm` - `POST /invoke-llm`
- `POST /rapid-orders/transcribe` - `POST /rapid-orders/transcribe`
- `POST /rapid-orders/parse` - `POST /rapid-orders/parse`
- `POST /rapid-orders/process`
- `POST /verifications` - `POST /verifications`
- `GET /verifications/:verificationId` - `GET /verifications/:verificationId`
- `POST /verifications/:verificationId/review` - `POST /verifications/:verificationId/review`
@@ -183,7 +264,9 @@ These are exposed as direct unified aliases even though they are backed by `core
- `POST /staff/profile/photo` - `POST /staff/profile/photo`
- `POST /staff/profile/documents/:documentId/upload` - `POST /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/documents/:documentId/upload`
- `POST /staff/profile/attire/:documentId/upload` - `POST /staff/profile/attire/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates` - `POST /staff/profile/certificates`
- `DELETE /staff/profile/certificates/:certificateId` - `DELETE /staff/profile/certificates/:certificateId`
@@ -191,7 +274,22 @@ These are exposed as direct unified aliases even though they are backed by `core
- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id. - `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend. - `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
- Document routes now return only document rows. They do not mix in attire items anymore.
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
- Staff benefit activity should come from `GET /staff/profile/benefits/history`; the summary card should keep using `GET /staff/profile/benefits`.
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. - File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
- The frontend upload contract for documents, attire, and certificates is:
1. `POST /upload-file`
2. `POST /create-signed-url`
3. `POST /verifications`
4. finalize with:
- `PUT /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
- Finalization requires `verificationId`. Frontend may still send `fileUri` or `photoUrl`, but the backend treats the verification-linked file as the source of truth.
- `POST /rapid-orders/process` is the single-call route for "transcribe + parse".
- `POST /client/orders/:orderId/edit` builds a replacement order from future shifts only.
- `POST /client/orders/:orderId/cancel` cancels future shifts only on the mobile surface and leaves historical shifts intact.
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`. - Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`. - Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
- `clockInMode` values are: - `clockInMode` values are:
@@ -206,7 +304,11 @@ These are exposed as direct unified aliases even though they are backed by `core
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. - `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. - `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
- `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business.
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time. - `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

@@ -1,5 +1,9 @@
# Data Connect Connectors Pattern # Data Connect Connectors Pattern
> [!WARNING]
> This document describes the legacy V1 Data Connect connector pattern.
> For current backend work, use the V2 unified API docs under `docs/BACKEND/API_GUIDES/V2/`.
## Overview ## Overview
This document describes the **Data Connect Connectors** pattern implemented in the KROW mobile app. This pattern centralizes all backend query logic by mirroring backend connector structure in the mobile data layer. This document describes the **Data Connect Connectors** pattern implemented in the KROW mobile app. This pattern centralizes all backend query logic by mirroring backend connector structure in the mobile data layer.
@@ -45,9 +49,9 @@ apps/mobile/packages/data_connect/lib/src/connectors/
└── ... └── ...
``` ```
**Maps to backend structure:** **Maps to legacy backend structure:**
``` ```
backend/dataconnect/connector/ legacy/dataconnect-v1/connector/
├── staff/ ├── staff/
├── order/ ├── order/
├── user/ ├── user/
@@ -260,7 +264,7 @@ When backend adds new connector (e.g., `order`):
- `staff_main` - Guards bottom nav items requiring profile completion - `staff_main` - Guards bottom nav items requiring profile completion
**Backend Queries Used**: **Backend Queries Used**:
- `backend/dataconnect/connector/staff/queries/profile_completion.gql` - `legacy/dataconnect-v1/connector/staff/queries/profile_completion.gql`
### Shifts Connector ### Shifts Connector
@@ -271,15 +275,15 @@ When backend adds new connector (e.g., `order`):
- `applyForShifts()` - Handles shift application with error tracking - `applyForShifts()` - Handles shift application with error tracking
**Backend Queries Used**: **Backend Queries Used**:
- `backend/dataconnect/connector/shifts/queries/list_shift_roles_by_vendor.gql` - `legacy/dataconnect-v1/connector/shifts/queries/list_shift_roles_by_vendor.gql`
- `backend/dataconnect/connector/shifts/mutations/apply_for_shifts.gql` - `legacy/dataconnect-v1/connector/shifts/mutations/apply_for_shifts.gql`
## Future Expansion ## Future Expansion
As the app grows, additional connectors will be added: As the app grows, additional connectors will be added:
- `order_connector_repository` (queries from `backend/dataconnect/connector/order/`) - `order_connector_repository` (queries from `legacy/dataconnect-v1/connector/order/`)
- `user_connector_repository` (queries from `backend/dataconnect/connector/user/`) - `user_connector_repository` (queries from `legacy/dataconnect-v1/connector/user/`)
- `emergency_contact_connector_repository` (queries from `backend/dataconnect/connector/emergencyContact/`) - `emergency_contact_connector_repository` (queries from `legacy/dataconnect-v1/connector/emergencyContact/`)
- etc. - etc.
Each following the same Clean Architecture pattern implemented for Staff Connector. Each following the same Clean Architecture pattern implemented for Staff Connector.

View File

@@ -79,10 +79,10 @@
], ],
"emulators": { "emulators": {
"dataconnect": { "dataconnect": {
"dataDir": "backend/dataconnect/.dataconnect/pgliteData" "dataDir": "legacy/dataconnect-v1/.dataconnect/pgliteData"
} }
}, },
"dataconnect": { "dataconnect": {
"source": "backend/dataconnect" "source": "legacy/dataconnect-v1"
} }
} }

View File

@@ -0,0 +1,23 @@
# Legacy Data Connect V1
This directory contains the archived V1 Firebase Data Connect source:
- GraphQL schemas
- connector queries and mutations
- seed and clean scripts
- Data Connect service YAML files
Status:
- Treat this directory as legacy infrastructure.
- Do not use it as the source of truth for new backend work.
- The active backend architecture is V2 and lives under:
- `backend/command-api`
- `backend/query-api`
- `backend/core-api`
- `backend/unified-api`
Why this was moved:
- The old V1 GraphQL source was being picked up by AI tooling and confusing current V2 implementation work.
- Relocating it under `legacy/` keeps the V1 toolchain available without presenting it as the active backend.

Some files were not shown because too many files have changed in this diff Show More