Merge branch 'dev' into 592-migrate-frontend-applications-to-v2-backend-and-database
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
388
docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md
Normal file
388
docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md
Normal 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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
183
docs/BACKEND/API_GUIDES/V2/staff-shifts.md
Normal file
183
docs/BACKEND/API_GUIDES/V2/staff-shifts.md
Normal 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/*`
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ backend-configure-notification-scheduler-v2:
|
|||||||
--time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \
|
--time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \
|
||||||
--uri="$$URL/tasks/dispatch-notifications" \
|
--uri="$$URL/tasks/dispatch-notifications" \
|
||||||
--http-method=POST \
|
--http-method=POST \
|
||||||
--headers=Content-Type=application/json \
|
--update-headers=Content-Type=application/json \
|
||||||
--message-body='{}' \
|
--message-body='{}' \
|
||||||
--oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \
|
--oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \
|
||||||
--oidc-token-audience="$$URL"; \
|
--oidc-token-audience="$$URL"; \
|
||||||
|
|||||||
Reference in New Issue
Block a user