feat(api): complete unified v2 mobile surface
This commit is contained in:
@@ -44,6 +44,14 @@ async function main() {
|
|||||||
const completedEndsAt = hoursFromNow(-20);
|
const completedEndsAt = hoursFromNow(-20);
|
||||||
const checkedInAt = hoursFromNow(-27.5);
|
const checkedInAt = hoursFromNow(-27.5);
|
||||||
const checkedOutAt = hoursFromNow(-20.25);
|
const checkedOutAt = hoursFromNow(-20.25);
|
||||||
|
const assignedStartsAt = hoursFromNow(2);
|
||||||
|
const assignedEndsAt = hoursFromNow(10);
|
||||||
|
const availableStartsAt = hoursFromNow(30);
|
||||||
|
const availableEndsAt = hoursFromNow(38);
|
||||||
|
const cancelledStartsAt = hoursFromNow(20);
|
||||||
|
const cancelledEndsAt = hoursFromNow(28);
|
||||||
|
const noShowStartsAt = hoursFromNow(-18);
|
||||||
|
const noShowEndsAt = hoursFromNow(-10);
|
||||||
const invoiceDueAt = hoursFromNow(72);
|
const invoiceDueAt = hoursFromNow(72);
|
||||||
|
|
||||||
await upsertUser(client, fixture.users.businessOwner);
|
await upsertUser(client, fixture.users.businessOwner);
|
||||||
@@ -248,6 +256,16 @@ 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 emergency_contacts (
|
||||||
|
id, tenant_id, staff_id, full_name, phone, relationship_type, is_primary, metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, 'Maria Barista', '+15550007777', 'SIBLING', TRUE, '{"seeded":true}'::jsonb)
|
||||||
|
`,
|
||||||
|
[fixture.emergencyContacts.primary.id, fixture.tenant.id, fixture.staff.ana.id]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO clock_points (
|
INSERT INTO clock_points (
|
||||||
@@ -319,6 +337,34 @@ async function main() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO orders (
|
||||||
|
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
|
||||||
|
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states',
|
||||||
|
'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12,
|
||||||
|
'{"slice":"active","orderType":"ONE_TIME"}'::jsonb
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
fixture.orders.active.id,
|
||||||
|
fixture.tenant.id,
|
||||||
|
fixture.business.id,
|
||||||
|
fixture.vendor.id,
|
||||||
|
fixture.orders.active.number,
|
||||||
|
fixture.orders.active.title,
|
||||||
|
assignedStartsAt,
|
||||||
|
availableEndsAt,
|
||||||
|
fixture.clockPoint.address,
|
||||||
|
fixture.clockPoint.latitude,
|
||||||
|
fixture.clockPoint.longitude,
|
||||||
|
fixture.users.operationsManager.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO shifts (
|
INSERT INTO shifts (
|
||||||
@@ -357,6 +403,51 @@ async function main() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO shifts (
|
||||||
|
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
|
||||||
|
location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 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, 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, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb),
|
||||||
|
($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
fixture.shifts.available.id,
|
||||||
|
fixture.tenant.id,
|
||||||
|
fixture.orders.active.id,
|
||||||
|
fixture.business.id,
|
||||||
|
fixture.vendor.id,
|
||||||
|
fixture.clockPoint.id,
|
||||||
|
fixture.shifts.available.code,
|
||||||
|
fixture.shifts.available.title,
|
||||||
|
availableStartsAt,
|
||||||
|
availableEndsAt,
|
||||||
|
fixture.clockPoint.address,
|
||||||
|
fixture.clockPoint.latitude,
|
||||||
|
fixture.clockPoint.longitude,
|
||||||
|
fixture.clockPoint.geofenceRadiusMeters,
|
||||||
|
fixture.shifts.assigned.id,
|
||||||
|
fixture.shifts.assigned.code,
|
||||||
|
fixture.shifts.assigned.title,
|
||||||
|
assignedStartsAt,
|
||||||
|
assignedEndsAt,
|
||||||
|
fixture.shifts.cancelled.id,
|
||||||
|
fixture.shifts.cancelled.code,
|
||||||
|
fixture.shifts.cancelled.title,
|
||||||
|
cancelledStartsAt,
|
||||||
|
cancelledEndsAt,
|
||||||
|
fixture.shifts.noShow.id,
|
||||||
|
fixture.shifts.noShow.code,
|
||||||
|
fixture.shifts.noShow.title,
|
||||||
|
noShowStartsAt,
|
||||||
|
noShowEndsAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO shift_roles (
|
INSERT INTO shift_roles (
|
||||||
@@ -377,6 +468,32 @@ async function main() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO shift_roles (
|
||||||
|
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb),
|
||||||
|
($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb),
|
||||||
|
($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb),
|
||||||
|
($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
fixture.shiftRoles.availableBarista.id,
|
||||||
|
fixture.shifts.available.id,
|
||||||
|
fixture.shiftRoles.assignedBarista.id,
|
||||||
|
fixture.shifts.assigned.id,
|
||||||
|
fixture.shiftRoles.cancelledBarista.id,
|
||||||
|
fixture.shifts.cancelled.id,
|
||||||
|
fixture.roles.barista.id,
|
||||||
|
fixture.roles.barista.code,
|
||||||
|
fixture.roles.barista.name,
|
||||||
|
fixture.shiftRoles.noShowBarista.id,
|
||||||
|
fixture.shifts.noShow.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO applications (
|
INSERT INTO applications (
|
||||||
@@ -417,6 +534,36 @@ async function main() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO assignments (
|
||||||
|
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
|
||||||
|
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb),
|
||||||
|
($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb),
|
||||||
|
($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
fixture.assignments.assignedAna.id,
|
||||||
|
fixture.tenant.id,
|
||||||
|
fixture.business.id,
|
||||||
|
fixture.vendor.id,
|
||||||
|
fixture.shifts.assigned.id,
|
||||||
|
fixture.shiftRoles.assignedBarista.id,
|
||||||
|
fixture.workforce.ana.id,
|
||||||
|
fixture.staff.ana.id,
|
||||||
|
fixture.assignments.cancelledAna.id,
|
||||||
|
fixture.shifts.cancelled.id,
|
||||||
|
fixture.shiftRoles.cancelledBarista.id,
|
||||||
|
fixture.assignments.noShowAna.id,
|
||||||
|
fixture.shifts.noShow.id,
|
||||||
|
fixture.shiftRoles.noShowBarista.id,
|
||||||
|
noShowStartsAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO attendance_events (
|
INSERT INTO attendance_events (
|
||||||
@@ -486,50 +633,69 @@ async function main() {
|
|||||||
`
|
`
|
||||||
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
|
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, 'CERTIFICATION', $3, $6, '{"seeded":true}'::jsonb),
|
($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb),
|
||||||
($4, $2, 'ATTIRE', $5, $6, '{"seeded":true}'::jsonb),
|
($4, $2, 'CERTIFICATION', $5, $10, '{"seeded":true}'::jsonb),
|
||||||
($7, $2, 'TAX_FORM', $8, $6, '{"seeded":true}'::jsonb)
|
($6, $2, 'ATTIRE', $7, $10, '{"seeded":true,"description":"Upload a photo of your black shirt","required":true}'::jsonb),
|
||||||
|
($8, $2, 'TAX_FORM', $9, $10, '{"seeded":true}'::jsonb),
|
||||||
|
($11, $2, 'TAX_FORM', $12, $10, '{"seeded":true}'::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
fixture.documents.foodSafety.id,
|
fixture.documents.governmentId.id,
|
||||||
fixture.tenant.id,
|
fixture.tenant.id,
|
||||||
|
fixture.documents.governmentId.name,
|
||||||
|
fixture.documents.foodSafety.id,
|
||||||
fixture.documents.foodSafety.name,
|
fixture.documents.foodSafety.name,
|
||||||
fixture.documents.attireBlackShirt.id,
|
fixture.documents.attireBlackShirt.id,
|
||||||
fixture.documents.attireBlackShirt.name,
|
fixture.documents.attireBlackShirt.name,
|
||||||
|
fixture.documents.taxFormI9.id,
|
||||||
|
fixture.documents.taxFormI9.name,
|
||||||
fixture.roles.barista.code,
|
fixture.roles.barista.code,
|
||||||
fixture.documents.taxFormW9.id,
|
fixture.documents.taxFormW4.id,
|
||||||
fixture.documents.taxFormW9.name,
|
fixture.documents.taxFormW4.name,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO staff_documents (id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata)
|
INSERT INTO staff_documents (
|
||||||
|
id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata
|
||||||
|
)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb),
|
($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb),
|
||||||
($7, $2, $3, $8, $9, 'VERIFIED', NULL, '{"seeded":true}'::jsonb),
|
($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
|
||||||
($10, $2, $3, $11, $12, 'VERIFIED', NULL, '{"seeded":true}'::jsonb)
|
($11, $2, $3, $12, $13, 'VERIFIED', NULL, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
|
||||||
|
($14, $2, $3, $15, $16, 'VERIFIED', NULL, '{"seeded":true,"formStatus":"SUBMITTED","fields":{"ssnLast4":"1234","filingStatus":"single"}}'::jsonb),
|
||||||
|
($17, $2, $3, $18, $19, 'PENDING', NULL, '{"seeded":true,"formStatus":"DRAFT","fields":{"section1Complete":true}}'::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
fixture.staffDocuments.foodSafety.id,
|
fixture.staffDocuments.governmentId.id,
|
||||||
fixture.tenant.id,
|
fixture.tenant.id,
|
||||||
fixture.staff.ana.id,
|
fixture.staff.ana.id,
|
||||||
|
fixture.documents.governmentId.id,
|
||||||
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/government-id-front.jpg`,
|
||||||
|
hoursFromNow(24 * 365),
|
||||||
|
fixture.staffDocuments.foodSafety.id,
|
||||||
fixture.documents.foodSafety.id,
|
fixture.documents.foodSafety.id,
|
||||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
||||||
hoursFromNow(24 * 180),
|
hoursFromNow(24 * 180),
|
||||||
fixture.staffDocuments.attireBlackShirt.id,
|
fixture.staffDocuments.attireBlackShirt.id,
|
||||||
fixture.documents.attireBlackShirt.id,
|
fixture.documents.attireBlackShirt.id,
|
||||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`,
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`,
|
||||||
fixture.staffDocuments.taxFormW9.id,
|
fixture.staffDocuments.taxFormW4.id,
|
||||||
fixture.documents.taxFormW9.id,
|
fixture.documents.taxFormW4.id,
|
||||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w9-form.pdf`,
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w4-form.pdf`,
|
||||||
|
fixture.staffDocuments.taxFormI9.id,
|
||||||
|
fixture.documents.taxFormI9.id,
|
||||||
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/i9-form.pdf`,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO certificates (id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, metadata)
|
INSERT INTO certificates (
|
||||||
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', '{"seeded":true}'::jsonb)
|
id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', $6, $7::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
fixture.certificates.foodSafety.id,
|
fixture.certificates.foodSafety.id,
|
||||||
@@ -537,6 +703,13 @@ async function main() {
|
|||||||
fixture.staff.ana.id,
|
fixture.staff.ana.id,
|
||||||
hoursFromNow(-24 * 30),
|
hoursFromNow(-24 * 30),
|
||||||
hoursFromNow(24 * 180),
|
hoursFromNow(24 * 180),
|
||||||
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-safety-certificate.pdf`,
|
||||||
|
JSON.stringify({
|
||||||
|
seeded: true,
|
||||||
|
name: 'Food Safety Certificate',
|
||||||
|
issuer: 'ServSafe',
|
||||||
|
verificationStatus: 'APPROVED',
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export const V2DemoFixture = {
|
|||||||
number: 'ORD-V2-COMP-1002',
|
number: 'ORD-V2-COMP-1002',
|
||||||
title: 'Completed catering shift',
|
title: 'Completed catering shift',
|
||||||
},
|
},
|
||||||
|
active: {
|
||||||
|
id: 'b6132d7a-45c3-4879-b349-46b2fd518003',
|
||||||
|
number: 'ORD-V2-ACT-1003',
|
||||||
|
title: 'Live staffing operations',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
shifts: {
|
shifts: {
|
||||||
open: {
|
open: {
|
||||||
@@ -120,6 +125,26 @@ export const V2DemoFixture = {
|
|||||||
code: 'SHIFT-V2-COMP-1',
|
code: 'SHIFT-V2-COMP-1',
|
||||||
title: 'Completed catering shift',
|
title: 'Completed catering shift',
|
||||||
},
|
},
|
||||||
|
available: {
|
||||||
|
id: '6e7dadad-99e4-45bb-b0da-7bb617954003',
|
||||||
|
code: 'SHIFT-V2-OPEN-2',
|
||||||
|
title: 'Available lunch shift',
|
||||||
|
},
|
||||||
|
assigned: {
|
||||||
|
id: '6e7dadad-99e4-45bb-b0da-7bb617954004',
|
||||||
|
code: 'SHIFT-V2-ASSIGNED-1',
|
||||||
|
title: 'Assigned espresso shift',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
id: '6e7dadad-99e4-45bb-b0da-7bb617954005',
|
||||||
|
code: 'SHIFT-V2-CANCELLED-1',
|
||||||
|
title: 'Cancelled hospitality shift',
|
||||||
|
},
|
||||||
|
noShow: {
|
||||||
|
id: '6e7dadad-99e4-45bb-b0da-7bb617954006',
|
||||||
|
code: 'SHIFT-V2-NOSHOW-1',
|
||||||
|
title: 'No-show breakfast shift',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
shiftRoles: {
|
shiftRoles: {
|
||||||
openBarista: {
|
openBarista: {
|
||||||
@@ -128,6 +153,18 @@ export const V2DemoFixture = {
|
|||||||
completedBarista: {
|
completedBarista: {
|
||||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002',
|
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002',
|
||||||
},
|
},
|
||||||
|
availableBarista: {
|
||||||
|
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b003',
|
||||||
|
},
|
||||||
|
assignedBarista: {
|
||||||
|
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004',
|
||||||
|
},
|
||||||
|
cancelledBarista: {
|
||||||
|
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005',
|
||||||
|
},
|
||||||
|
noShowBarista: {
|
||||||
|
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b006',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
applications: {
|
applications: {
|
||||||
openAna: {
|
openAna: {
|
||||||
@@ -138,6 +175,15 @@ export const V2DemoFixture = {
|
|||||||
completedAna: {
|
completedAna: {
|
||||||
id: 'f1d3f738-a132-4863-b222-4f9cb25aa001',
|
id: 'f1d3f738-a132-4863-b222-4f9cb25aa001',
|
||||||
},
|
},
|
||||||
|
assignedAna: {
|
||||||
|
id: 'f1d3f738-a132-4863-b222-4f9cb25aa002',
|
||||||
|
},
|
||||||
|
cancelledAna: {
|
||||||
|
id: 'f1d3f738-a132-4863-b222-4f9cb25aa003',
|
||||||
|
},
|
||||||
|
noShowAna: {
|
||||||
|
id: 'f1d3f738-a132-4863-b222-4f9cb25aa004',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timesheets: {
|
timesheets: {
|
||||||
completedAna: {
|
completedAna: {
|
||||||
@@ -166,6 +212,10 @@ export const V2DemoFixture = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
documents: {
|
documents: {
|
||||||
|
governmentId: {
|
||||||
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000',
|
||||||
|
name: 'Government ID',
|
||||||
|
},
|
||||||
foodSafety: {
|
foodSafety: {
|
||||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
|
||||||
name: 'Food Handler Card',
|
name: 'Food Handler Card',
|
||||||
@@ -174,27 +224,42 @@ export const V2DemoFixture = {
|
|||||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
|
||||||
name: 'Black Shirt',
|
name: 'Black Shirt',
|
||||||
},
|
},
|
||||||
taxFormW9: {
|
taxFormI9: {
|
||||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
|
||||||
name: 'W-9 Tax Form',
|
name: 'I-9',
|
||||||
|
},
|
||||||
|
taxFormW4: {
|
||||||
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004',
|
||||||
|
name: 'W-4',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
staffDocuments: {
|
staffDocuments: {
|
||||||
|
governmentId: {
|
||||||
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95000',
|
||||||
|
},
|
||||||
foodSafety: {
|
foodSafety: {
|
||||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
|
||||||
},
|
},
|
||||||
attireBlackShirt: {
|
attireBlackShirt: {
|
||||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
|
||||||
},
|
},
|
||||||
taxFormW9: {
|
taxFormI9: {
|
||||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
|
||||||
},
|
},
|
||||||
|
taxFormW4: {
|
||||||
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95004',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
certificates: {
|
certificates: {
|
||||||
foodSafety: {
|
foodSafety: {
|
||||||
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
|
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emergencyContacts: {
|
||||||
|
primary: {
|
||||||
|
id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001',
|
||||||
|
},
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
businessPrimary: {
|
businessPrimary: {
|
||||||
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',
|
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',
|
||||||
|
|||||||
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS emergency_contacts (
|
||||||
|
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,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
relationship_type TEXT NOT NULL,
|
||||||
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_emergency_contacts_staff
|
||||||
|
ON emergency_contacts (staff_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contacts_primary_staff
|
||||||
|
ON emergency_contacts (staff_id)
|
||||||
|
WHERE is_primary = TRUE;
|
||||||
|
|
||||||
|
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', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW'));
|
||||||
|
|
||||||
|
ALTER TABLE verification_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS subject_type TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS subject_id TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE staff_documents
|
||||||
|
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE certificates
|
||||||
|
ADD COLUMN IF NOT EXISTS file_uri TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_verification_jobs_owner
|
||||||
|
ON verification_jobs (owner_user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_verification_jobs_subject
|
||||||
|
ON verification_jobs (subject_type, subject_id, created_at DESC);
|
||||||
@@ -5,6 +5,7 @@ import { requestContext } from './middleware/request-context.js';
|
|||||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createCommandsRouter } from './routes/commands.js';
|
import { createCommandsRouter } from './routes/commands.js';
|
||||||
|
import { createMobileCommandsRouter } from './routes/mobile.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function createApp(options = {}) {
|
|||||||
|
|
||||||
app.use(healthRouter);
|
app.use(healthRouter);
|
||||||
app.use('/commands', createCommandsRouter(options.commandHandlers));
|
app.use('/commands', createCommandsRouter(options.commandHandlers));
|
||||||
|
app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers));
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
301
backend/command-api/src/contracts/commands/mobile.js
Normal file
301
backend/command-api/src/contracts/commands/mobile.js
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const timeSlotSchema = z.object({
|
||||||
|
start: z.string().min(1).max(20),
|
||||||
|
end: z.string().min(1).max(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const preferredLocationSchema = z.object({
|
||||||
|
label: z.string().min(1).max(160),
|
||||||
|
city: z.string().max(120).optional(),
|
||||||
|
state: z.string().max(80).optional(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
|
radiusMiles: z.number().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
|
||||||
|
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
|
||||||
|
|
||||||
|
const shiftPositionSchema = z.object({
|
||||||
|
roleId: z.string().uuid().optional(),
|
||||||
|
roleCode: z.string().min(1).max(120).optional(),
|
||||||
|
roleName: z.string().min(1).max(160).optional(),
|
||||||
|
workerCount: z.number().int().positive().optional(),
|
||||||
|
workersNeeded: z.number().int().positive().optional(),
|
||||||
|
startTime: hhmmSchema,
|
||||||
|
endTime: hhmmSchema,
|
||||||
|
hourlyRateCents: z.number().int().nonnegative().optional(),
|
||||||
|
payRateCents: z.number().int().nonnegative().optional(),
|
||||||
|
billRateCents: z.number().int().nonnegative().optional(),
|
||||||
|
lunchBreakMinutes: z.number().int().nonnegative().optional(),
|
||||||
|
paidBreak: z.boolean().optional(),
|
||||||
|
instantBook: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (!value.roleId && !value.roleCode && !value.roleName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'roleId, roleCode, or roleName is required',
|
||||||
|
path: ['roleId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOrderCreateSchema = z.object({
|
||||||
|
hubId: z.string().uuid(),
|
||||||
|
vendorId: z.string().uuid().optional(),
|
||||||
|
eventName: z.string().min(2).max(160),
|
||||||
|
timezone: z.string().min(1).max(80).optional(),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
notes: z.string().max(5000).optional(),
|
||||||
|
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||||
|
positions: z.array(shiftPositionSchema).min(1),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hubCreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(160),
|
||||||
|
fullAddress: z.string().max(300).optional(),
|
||||||
|
placeId: z.string().max(255).optional(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
|
street: z.string().max(160).optional(),
|
||||||
|
city: z.string().max(120).optional(),
|
||||||
|
state: z.string().max(80).optional(),
|
||||||
|
country: z.string().max(80).optional(),
|
||||||
|
zipCode: z.string().max(40).optional(),
|
||||||
|
costCenterId: z.string().uuid().optional(),
|
||||||
|
geofenceRadiusMeters: z.number().int().positive().optional(),
|
||||||
|
nfcTagId: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hubUpdateSchema = hubCreateSchema.extend({
|
||||||
|
hubId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hubDeleteSchema = z.object({
|
||||||
|
hubId: z.string().uuid(),
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hubAssignNfcSchema = z.object({
|
||||||
|
hubId: z.string().uuid(),
|
||||||
|
nfcTagId: z.string().min(1).max(255),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hubAssignManagerSchema = z.object({
|
||||||
|
hubId: z.string().uuid(),
|
||||||
|
businessMembershipId: z.string().uuid().optional(),
|
||||||
|
managerUserId: z.string().min(1).optional(),
|
||||||
|
}).refine((value) => value.businessMembershipId || value.managerUserId, {
|
||||||
|
message: 'businessMembershipId or managerUserId is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const invoiceApproveSchema = z.object({
|
||||||
|
invoiceId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const invoiceDisputeSchema = z.object({
|
||||||
|
invoiceId: z.string().uuid(),
|
||||||
|
reason: z.string().min(3).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const coverageReviewSchema = z.object({
|
||||||
|
staffId: z.string().uuid(),
|
||||||
|
assignmentId: z.string().uuid().optional(),
|
||||||
|
rating: z.number().int().min(1).max(5),
|
||||||
|
markAsFavorite: z.boolean().optional(),
|
||||||
|
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
||||||
|
feedback: z.string().max(5000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelLateWorkerSchema = z.object({
|
||||||
|
assignmentId: z.string().uuid(),
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({
|
||||||
|
orderDate: isoDateSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({
|
||||||
|
startDate: isoDateSchema,
|
||||||
|
endDate: isoDateSchema,
|
||||||
|
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({
|
||||||
|
startDate: isoDateSchema,
|
||||||
|
endDate: isoDateSchema.optional(),
|
||||||
|
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||||
|
horizonDays: z.number().int().min(7).max(180).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientOrderEditSchema = z.object({
|
||||||
|
orderId: z.string().uuid(),
|
||||||
|
orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(),
|
||||||
|
hubId: z.string().uuid().optional(),
|
||||||
|
vendorId: z.string().uuid().optional(),
|
||||||
|
eventName: z.string().min(2).max(160).optional(),
|
||||||
|
orderDate: isoDateSchema.optional(),
|
||||||
|
startDate: isoDateSchema.optional(),
|
||||||
|
endDate: isoDateSchema.optional(),
|
||||||
|
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||||
|
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||||
|
timezone: z.string().min(1).max(80).optional(),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
notes: z.string().max(5000).optional(),
|
||||||
|
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||||
|
positions: z.array(shiftPositionSchema).min(1).optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
const keys = Object.keys(value).filter((key) => key !== 'orderId');
|
||||||
|
if (keys.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'At least one field must be provided to create an edited order copy',
|
||||||
|
path: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientOrderCancelSchema = z.object({
|
||||||
|
orderId: z.string().uuid(),
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const availabilityDayUpdateSchema = z.object({
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']),
|
||||||
|
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const availabilityQuickSetSchema = z.object({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']),
|
||||||
|
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const shiftApplySchema = z.object({
|
||||||
|
shiftId: z.string().uuid(),
|
||||||
|
roleId: z.string().uuid().optional(),
|
||||||
|
instantBook: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const shiftDecisionSchema = z.object({
|
||||||
|
shiftId: z.string().uuid(),
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffClockInSchema = z.object({
|
||||||
|
assignmentId: z.string().uuid().optional(),
|
||||||
|
shiftId: z.string().uuid().optional(),
|
||||||
|
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||||
|
sourceReference: z.string().max(255).optional(),
|
||||||
|
nfcTagId: z.string().max(255).optional(),
|
||||||
|
deviceId: z.string().max(255).optional(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
|
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||||
|
capturedAt: z.string().datetime().optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
rawPayload: z.record(z.any()).optional(),
|
||||||
|
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||||
|
message: 'assignmentId or shiftId is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffClockOutSchema = z.object({
|
||||||
|
assignmentId: z.string().uuid().optional(),
|
||||||
|
shiftId: z.string().uuid().optional(),
|
||||||
|
applicationId: z.string().uuid().optional(),
|
||||||
|
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||||
|
sourceReference: z.string().max(255).optional(),
|
||||||
|
nfcTagId: z.string().max(255).optional(),
|
||||||
|
deviceId: z.string().max(255).optional(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
|
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||||
|
capturedAt: z.string().datetime().optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
breakMinutes: z.number().int().nonnegative().optional(),
|
||||||
|
rawPayload: z.record(z.any()).optional(),
|
||||||
|
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
|
||||||
|
message: 'assignmentId, shiftId, or applicationId is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffProfileSetupSchema = z.object({
|
||||||
|
fullName: z.string().min(2).max(160),
|
||||||
|
bio: z.string().max(5000).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
phoneNumber: z.string().min(6).max(40),
|
||||||
|
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||||
|
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||||
|
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||||
|
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||||
|
primaryRole: z.string().max(120).optional(),
|
||||||
|
tenantId: z.string().uuid().optional(),
|
||||||
|
vendorId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const personalInfoUpdateSchema = z.object({
|
||||||
|
firstName: z.string().min(1).max(80).optional(),
|
||||||
|
lastName: z.string().min(1).max(80).optional(),
|
||||||
|
bio: z.string().max(5000).optional(),
|
||||||
|
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||||
|
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
phone: z.string().min(6).max(40).optional(),
|
||||||
|
displayName: z.string().min(2).max(160).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileExperienceSchema = z.object({
|
||||||
|
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||||
|
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||||
|
primaryRole: z.string().max(120).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const preferredLocationsUpdateSchema = z.object({
|
||||||
|
preferredLocations: z.array(preferredLocationSchema).max(20),
|
||||||
|
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emergencyContactCreateSchema = z.object({
|
||||||
|
fullName: z.string().min(2).max(160),
|
||||||
|
phone: z.string().min(6).max(40),
|
||||||
|
relationshipType: z.string().min(1).max(120),
|
||||||
|
isPrimary: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({
|
||||||
|
contactId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taxFormFieldsSchema = z.record(z.any());
|
||||||
|
|
||||||
|
export const taxFormDraftSchema = z.object({
|
||||||
|
formType: z.enum(['I9', 'W4']),
|
||||||
|
fields: taxFormFieldsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taxFormSubmitSchema = z.object({
|
||||||
|
formType: z.enum(['I9', 'W4']),
|
||||||
|
fields: taxFormFieldsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bankAccountCreateSchema = z.object({
|
||||||
|
bankName: z.string().min(2).max(160),
|
||||||
|
accountNumber: z.string().min(4).max(34),
|
||||||
|
routingNumber: z.string().min(4).max(20),
|
||||||
|
accountType: z.string()
|
||||||
|
.transform((value) => value.trim().toUpperCase())
|
||||||
|
.pipe(z.enum(['CHECKING', 'SAVINGS'])),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const privacyUpdateSchema = z.object({
|
||||||
|
profileVisible: z.boolean(),
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const roleSchema = z.object({
|
const roleSchema = z.object({
|
||||||
|
roleId: z.string().uuid().optional(),
|
||||||
roleCode: z.string().min(1).max(100),
|
roleCode: z.string().min(1).max(100),
|
||||||
roleName: z.string().min(1).max(120),
|
roleName: z.string().min(1).max(120),
|
||||||
workersNeeded: z.number().int().positive(),
|
workersNeeded: z.number().int().positive(),
|
||||||
|
|||||||
412
backend/command-api/src/routes/mobile.js
Normal file
412
backend/command-api/src/routes/mobile.js
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||||
|
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||||
|
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
|
||||||
|
import {
|
||||||
|
addStaffBankAccount,
|
||||||
|
approveInvoice,
|
||||||
|
applyForShift,
|
||||||
|
assignHubManager,
|
||||||
|
assignHubNfc,
|
||||||
|
cancelLateWorker,
|
||||||
|
cancelClientOrder,
|
||||||
|
createEmergencyContact,
|
||||||
|
createClientOneTimeOrder,
|
||||||
|
createClientPermanentOrder,
|
||||||
|
createClientRecurringOrder,
|
||||||
|
createEditedOrderCopy,
|
||||||
|
createHub,
|
||||||
|
declinePendingShift,
|
||||||
|
disputeInvoice,
|
||||||
|
quickSetStaffAvailability,
|
||||||
|
rateWorkerFromCoverage,
|
||||||
|
requestShiftSwap,
|
||||||
|
saveTaxFormDraft,
|
||||||
|
setupStaffProfile,
|
||||||
|
staffClockIn,
|
||||||
|
staffClockOut,
|
||||||
|
submitTaxForm,
|
||||||
|
updateEmergencyContact,
|
||||||
|
updateHub,
|
||||||
|
updatePersonalInfo,
|
||||||
|
updatePreferredLocations,
|
||||||
|
updatePrivacyVisibility,
|
||||||
|
updateProfileExperience,
|
||||||
|
updateStaffAvailabilityDay,
|
||||||
|
deleteHub,
|
||||||
|
acceptPendingShift,
|
||||||
|
} from '../services/mobile-command-service.js';
|
||||||
|
import {
|
||||||
|
availabilityDayUpdateSchema,
|
||||||
|
availabilityQuickSetSchema,
|
||||||
|
bankAccountCreateSchema,
|
||||||
|
cancelLateWorkerSchema,
|
||||||
|
clientOneTimeOrderSchema,
|
||||||
|
clientOrderCancelSchema,
|
||||||
|
clientOrderEditSchema,
|
||||||
|
clientPermanentOrderSchema,
|
||||||
|
clientRecurringOrderSchema,
|
||||||
|
coverageReviewSchema,
|
||||||
|
emergencyContactCreateSchema,
|
||||||
|
emergencyContactUpdateSchema,
|
||||||
|
hubAssignManagerSchema,
|
||||||
|
hubAssignNfcSchema,
|
||||||
|
hubCreateSchema,
|
||||||
|
hubDeleteSchema,
|
||||||
|
hubUpdateSchema,
|
||||||
|
invoiceApproveSchema,
|
||||||
|
invoiceDisputeSchema,
|
||||||
|
personalInfoUpdateSchema,
|
||||||
|
preferredLocationsUpdateSchema,
|
||||||
|
privacyUpdateSchema,
|
||||||
|
profileExperienceSchema,
|
||||||
|
shiftApplySchema,
|
||||||
|
shiftDecisionSchema,
|
||||||
|
staffClockInSchema,
|
||||||
|
staffClockOutSchema,
|
||||||
|
staffProfileSetupSchema,
|
||||||
|
taxFormDraftSchema,
|
||||||
|
taxFormSubmitSchema,
|
||||||
|
} from '../contracts/commands/mobile.js';
|
||||||
|
|
||||||
|
const defaultHandlers = {
|
||||||
|
acceptPendingShift,
|
||||||
|
addStaffBankAccount,
|
||||||
|
approveInvoice,
|
||||||
|
applyForShift,
|
||||||
|
assignHubManager,
|
||||||
|
assignHubNfc,
|
||||||
|
cancelLateWorker,
|
||||||
|
cancelClientOrder,
|
||||||
|
createEmergencyContact,
|
||||||
|
createClientOneTimeOrder,
|
||||||
|
createClientPermanentOrder,
|
||||||
|
createClientRecurringOrder,
|
||||||
|
createEditedOrderCopy,
|
||||||
|
createHub,
|
||||||
|
declinePendingShift,
|
||||||
|
disputeInvoice,
|
||||||
|
quickSetStaffAvailability,
|
||||||
|
rateWorkerFromCoverage,
|
||||||
|
requestShiftSwap,
|
||||||
|
saveTaxFormDraft,
|
||||||
|
setupStaffProfile,
|
||||||
|
staffClockIn,
|
||||||
|
staffClockOut,
|
||||||
|
submitTaxForm,
|
||||||
|
updateEmergencyContact,
|
||||||
|
updateHub,
|
||||||
|
updatePersonalInfo,
|
||||||
|
updatePreferredLocations,
|
||||||
|
updatePrivacyVisibility,
|
||||||
|
updateProfileExperience,
|
||||||
|
updateStaffAvailabilityDay,
|
||||||
|
deleteHub,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body || {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIdempotentCommand(req, res, work) {
|
||||||
|
const route = `${req.baseUrl}${req.route.path}`;
|
||||||
|
const compositeKey = buildIdempotencyKey({
|
||||||
|
userId: req.actor.uid,
|
||||||
|
route,
|
||||||
|
idempotencyKey: req.idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await readIdempotentResult(compositeKey);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(existing.statusCode).json(existing.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await work();
|
||||||
|
const responsePayload = {
|
||||||
|
...payload,
|
||||||
|
idempotencyKey: req.idempotencyKey,
|
||||||
|
requestId: req.requestId,
|
||||||
|
};
|
||||||
|
const persisted = await writeIdempotentResult({
|
||||||
|
compositeKey,
|
||||||
|
userId: req.actor.uid,
|
||||||
|
route,
|
||||||
|
idempotencyKey: req.idempotencyKey,
|
||||||
|
payload: responsePayload,
|
||||||
|
statusCode: 200,
|
||||||
|
});
|
||||||
|
return res.status(persisted.statusCode).json(persisted.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) {
|
||||||
|
return [
|
||||||
|
route,
|
||||||
|
requireAuth,
|
||||||
|
requireIdempotencyKey,
|
||||||
|
requirePolicy(policyAction, resource),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = typeof paramShape === 'function'
|
||||||
|
? paramShape(req)
|
||||||
|
: req.body;
|
||||||
|
const payload = parseBody(schema, body);
|
||||||
|
return await runIdempotentCommand(req, res, () => handler(req.actor, payload));
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/orders/one-time', {
|
||||||
|
schema: clientOneTimeOrderSchema,
|
||||||
|
policyAction: 'orders.create',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.createClientOneTimeOrder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/orders/recurring', {
|
||||||
|
schema: clientRecurringOrderSchema,
|
||||||
|
policyAction: 'orders.create',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.createClientRecurringOrder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/orders/permanent', {
|
||||||
|
schema: clientPermanentOrderSchema,
|
||||||
|
policyAction: 'orders.create',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.createClientPermanentOrder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/orders/:orderId/edit', {
|
||||||
|
schema: clientOrderEditSchema,
|
||||||
|
policyAction: 'orders.update',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.createEditedOrderCopy,
|
||||||
|
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/orders/:orderId/cancel', {
|
||||||
|
schema: clientOrderCancelSchema,
|
||||||
|
policyAction: 'orders.cancel',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.cancelClientOrder,
|
||||||
|
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/hubs', {
|
||||||
|
schema: hubCreateSchema,
|
||||||
|
policyAction: 'client.hubs.create',
|
||||||
|
resource: 'hub',
|
||||||
|
handler: handlers.createHub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/client/hubs/:hubId', {
|
||||||
|
schema: hubUpdateSchema,
|
||||||
|
policyAction: 'client.hubs.update',
|
||||||
|
resource: 'hub',
|
||||||
|
handler: handlers.updateHub,
|
||||||
|
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete(...mobileCommand('/client/hubs/:hubId', {
|
||||||
|
schema: hubDeleteSchema,
|
||||||
|
policyAction: 'client.hubs.delete',
|
||||||
|
resource: 'hub',
|
||||||
|
handler: handlers.deleteHub,
|
||||||
|
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', {
|
||||||
|
schema: hubAssignNfcSchema,
|
||||||
|
policyAction: 'client.hubs.update',
|
||||||
|
resource: 'hub',
|
||||||
|
handler: handlers.assignHubNfc,
|
||||||
|
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/hubs/:hubId/managers', {
|
||||||
|
schema: hubAssignManagerSchema,
|
||||||
|
policyAction: 'client.hubs.update',
|
||||||
|
resource: 'hub',
|
||||||
|
handler: handlers.assignHubManager,
|
||||||
|
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
|
||||||
|
schema: invoiceApproveSchema,
|
||||||
|
policyAction: 'client.billing.write',
|
||||||
|
resource: 'invoice',
|
||||||
|
handler: handlers.approveInvoice,
|
||||||
|
paramShape: (req) => ({ invoiceId: req.params.invoiceId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', {
|
||||||
|
schema: invoiceDisputeSchema,
|
||||||
|
policyAction: 'client.billing.write',
|
||||||
|
resource: 'invoice',
|
||||||
|
handler: handlers.disputeInvoice,
|
||||||
|
paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/coverage/reviews', {
|
||||||
|
schema: coverageReviewSchema,
|
||||||
|
policyAction: 'client.coverage.write',
|
||||||
|
resource: 'staff_review',
|
||||||
|
handler: handlers.rateWorkerFromCoverage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', {
|
||||||
|
schema: cancelLateWorkerSchema,
|
||||||
|
policyAction: 'client.coverage.write',
|
||||||
|
resource: 'assignment',
|
||||||
|
handler: handlers.cancelLateWorker,
|
||||||
|
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/profile/setup', {
|
||||||
|
schema: staffProfileSetupSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.setupStaffProfile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/clock-in', {
|
||||||
|
schema: staffClockInSchema,
|
||||||
|
policyAction: 'attendance.clock-in',
|
||||||
|
resource: 'attendance',
|
||||||
|
handler: handlers.staffClockIn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/clock-out', {
|
||||||
|
schema: staffClockOutSchema,
|
||||||
|
policyAction: 'attendance.clock-out',
|
||||||
|
resource: 'attendance',
|
||||||
|
handler: handlers.staffClockOut,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/availability', {
|
||||||
|
schema: availabilityDayUpdateSchema,
|
||||||
|
policyAction: 'staff.availability.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updateStaffAvailabilityDay,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/availability/quick-set', {
|
||||||
|
schema: availabilityQuickSetSchema,
|
||||||
|
policyAction: 'staff.availability.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.quickSetStaffAvailability,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/shifts/:shiftId/apply', {
|
||||||
|
schema: shiftApplySchema,
|
||||||
|
policyAction: 'staff.shifts.apply',
|
||||||
|
resource: 'shift',
|
||||||
|
handler: handlers.applyForShift,
|
||||||
|
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
|
||||||
|
schema: shiftDecisionSchema,
|
||||||
|
policyAction: 'staff.shifts.accept',
|
||||||
|
resource: 'shift',
|
||||||
|
handler: handlers.acceptPendingShift,
|
||||||
|
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/shifts/:shiftId/decline', {
|
||||||
|
schema: shiftDecisionSchema,
|
||||||
|
policyAction: 'staff.shifts.decline',
|
||||||
|
resource: 'shift',
|
||||||
|
handler: handlers.declinePendingShift,
|
||||||
|
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', {
|
||||||
|
schema: shiftDecisionSchema,
|
||||||
|
policyAction: 'staff.shifts.swap',
|
||||||
|
resource: 'shift',
|
||||||
|
handler: handlers.requestShiftSwap,
|
||||||
|
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/personal-info', {
|
||||||
|
schema: personalInfoUpdateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updatePersonalInfo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/experience', {
|
||||||
|
schema: profileExperienceSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updateProfileExperience,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/locations', {
|
||||||
|
schema: preferredLocationsUpdateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updatePreferredLocations,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/profile/emergency-contacts', {
|
||||||
|
schema: emergencyContactCreateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.createEmergencyContact,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', {
|
||||||
|
schema: emergencyContactUpdateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updateEmergencyContact,
|
||||||
|
paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/tax-forms/:formType', {
|
||||||
|
schema: taxFormDraftSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff_document',
|
||||||
|
handler: handlers.saveTaxFormDraft,
|
||||||
|
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', {
|
||||||
|
schema: taxFormSubmitSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff_document',
|
||||||
|
handler: handlers.submitTaxForm,
|
||||||
|
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/profile/bank-accounts', {
|
||||||
|
schema: bankAccountCreateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'account',
|
||||||
|
handler: handlers.addStaffBankAccount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put(...mobileCommand('/staff/profile/privacy', {
|
||||||
|
schema: privacyUpdateSchema,
|
||||||
|
policyAction: 'staff.profile.write',
|
||||||
|
resource: 'staff',
|
||||||
|
handler: handlers.updatePrivacyVisibility,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
111
backend/command-api/src/services/actor-context.js
Normal file
111
backend/command-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { query } from './db.js';
|
||||||
|
|
||||||
|
export async function loadActorContext(uid) {
|
||||||
|
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT tm.id AS "membershipId",
|
||||||
|
tm.tenant_id AS "tenantId",
|
||||||
|
tm.base_role AS role,
|
||||||
|
t.name AS "tenantName",
|
||||||
|
t.slug AS "tenantSlug"
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT bm.id AS "membershipId",
|
||||||
|
bm.business_id AS "businessId",
|
||||||
|
bm.business_role AS role,
|
||||||
|
b.business_name AS "businessName",
|
||||||
|
b.slug AS "businessSlug",
|
||||||
|
bm.tenant_id AS "tenantId"
|
||||||
|
FROM business_memberships bm
|
||||||
|
JOIN businesses b ON b.id = bm.business_id
|
||||||
|
WHERE bm.user_id = $1
|
||||||
|
AND bm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY bm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT vm.id AS "membershipId",
|
||||||
|
vm.vendor_id AS "vendorId",
|
||||||
|
vm.vendor_role AS role,
|
||||||
|
v.company_name AS "vendorName",
|
||||||
|
v.slug AS "vendorSlug",
|
||||||
|
vm.tenant_id AS "tenantId"
|
||||||
|
FROM vendor_memberships vm
|
||||||
|
JOIN vendors v ON v.id = vm.vendor_id
|
||||||
|
WHERE vm.user_id = $1
|
||||||
|
AND vm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY vm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT s.id AS "staffId",
|
||||||
|
s.tenant_id AS "tenantId",
|
||||||
|
s.full_name AS "fullName",
|
||||||
|
s.email,
|
||||||
|
s.phone,
|
||||||
|
s.primary_role AS "primaryRole",
|
||||||
|
s.onboarding_status AS "onboardingStatus",
|
||||||
|
s.status,
|
||||||
|
s.metadata,
|
||||||
|
w.id AS "workforceId",
|
||||||
|
w.vendor_id AS "vendorId",
|
||||||
|
w.workforce_number AS "workforceNumber"
|
||||||
|
FROM staffs s
|
||||||
|
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResult.rows[0] || null,
|
||||||
|
tenant: tenantResult.rows[0] || null,
|
||||||
|
business: businessResult.rows[0] || null,
|
||||||
|
vendor: vendorResult.rows[0] || null,
|
||||||
|
staff: staffResult.rows[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireClientContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant || !context.business) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireStaffContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant || !context.staff) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -562,6 +562,7 @@ export async function createOrder(actor, payload) {
|
|||||||
`
|
`
|
||||||
INSERT INTO shift_roles (
|
INSERT INTO shift_roles (
|
||||||
shift_id,
|
shift_id,
|
||||||
|
role_id,
|
||||||
role_code,
|
role_code,
|
||||||
role_name,
|
role_name,
|
||||||
workers_needed,
|
workers_needed,
|
||||||
@@ -570,10 +571,11 @@ export async function createOrder(actor, payload) {
|
|||||||
bill_rate_cents,
|
bill_rate_cents,
|
||||||
metadata
|
metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, 0, $5, $6, $7::jsonb)
|
VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
shift.id,
|
shift.id,
|
||||||
|
roleInput.roleId || null,
|
||||||
roleInput.roleCode,
|
roleInput.roleCode,
|
||||||
roleInput.roleName,
|
roleInput.roleName,
|
||||||
roleInput.workersNeeded,
|
roleInput.workersNeeded,
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Pool } from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool, types } = pg;
|
||||||
|
|
||||||
|
function parseNumericDatabaseValue(value) {
|
||||||
|
if (value == null) return value;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||||
|
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
|
|||||||
2348
backend/command-api/src/services/mobile-command-service.js
Normal file
2348
backend/command-api/src/services/mobile-command-service.js
Normal file
File diff suppressed because it is too large
Load Diff
233
backend/command-api/test/mobile-routes.test.js
Normal file
233
backend/command-api/test/mobile-routes.test.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import test, { beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js';
|
||||||
|
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||||
|
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
__resetIdempotencyStoreForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMobileHandlers() {
|
||||||
|
return {
|
||||||
|
createClientOneTimeOrder: async (_actor, payload) => ({
|
||||||
|
orderId: 'order-1',
|
||||||
|
orderType: 'ONE_TIME',
|
||||||
|
eventName: payload.eventName,
|
||||||
|
}),
|
||||||
|
createClientRecurringOrder: async (_actor, payload) => ({
|
||||||
|
orderId: 'order-2',
|
||||||
|
orderType: 'RECURRING',
|
||||||
|
recurrenceDays: payload.recurrenceDays,
|
||||||
|
}),
|
||||||
|
createClientPermanentOrder: async (_actor, payload) => ({
|
||||||
|
orderId: 'order-3',
|
||||||
|
orderType: 'PERMANENT',
|
||||||
|
horizonDays: payload.horizonDays || 28,
|
||||||
|
}),
|
||||||
|
createEditedOrderCopy: async (_actor, payload) => ({
|
||||||
|
sourceOrderId: payload.orderId,
|
||||||
|
orderId: 'order-4',
|
||||||
|
cloned: true,
|
||||||
|
}),
|
||||||
|
cancelClientOrder: async (_actor, payload) => ({
|
||||||
|
orderId: payload.orderId,
|
||||||
|
status: 'CANCELLED',
|
||||||
|
}),
|
||||||
|
createHub: async (_actor, payload) => ({
|
||||||
|
hubId: 'hub-1',
|
||||||
|
name: payload.name,
|
||||||
|
costCenterId: payload.costCenterId,
|
||||||
|
}),
|
||||||
|
approveInvoice: async (_actor, payload) => ({
|
||||||
|
invoiceId: payload.invoiceId,
|
||||||
|
status: 'APPROVED',
|
||||||
|
}),
|
||||||
|
applyForShift: async (_actor, payload) => ({
|
||||||
|
shiftId: payload.shiftId,
|
||||||
|
status: 'APPLIED',
|
||||||
|
}),
|
||||||
|
staffClockIn: async (_actor, payload) => ({
|
||||||
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
|
status: 'CLOCK_IN',
|
||||||
|
}),
|
||||||
|
staffClockOut: async (_actor, payload) => ({
|
||||||
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
|
status: 'CLOCK_OUT',
|
||||||
|
}),
|
||||||
|
saveTaxFormDraft: async (_actor, payload) => ({
|
||||||
|
formType: payload.formType,
|
||||||
|
status: 'DRAFT',
|
||||||
|
}),
|
||||||
|
addStaffBankAccount: async (_actor, payload) => ({
|
||||||
|
accountType: payload.accountType,
|
||||||
|
last4: payload.accountNumber.slice(-4),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /commands/client/orders/one-time forwards one-time order payload', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/client/orders/one-time')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'client-order-1')
|
||||||
|
.send({
|
||||||
|
hubId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
vendorId: '22222222-2222-4222-8222-222222222222',
|
||||||
|
eventName: 'Google Cafe Coverage',
|
||||||
|
orderDate: '2026-03-20',
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
roleId: '33333333-3333-4333-8333-333333333333',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '17:00',
|
||||||
|
workerCount: 2,
|
||||||
|
hourlyRateCents: 2800,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.orderId, 'order-1');
|
||||||
|
assert.equal(res.body.orderType, 'ONE_TIME');
|
||||||
|
assert.equal(res.body.eventName, 'Google Cafe Coverage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/client/orders/:orderId/edit injects order id from params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/client/orders/44444444-4444-4444-8444-444444444444/edit')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'client-order-edit-1')
|
||||||
|
.send({
|
||||||
|
eventName: 'Edited Order Copy',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.sourceOrderId, '44444444-4444-4444-8444-444444444444');
|
||||||
|
assert.equal(res.body.cloned, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/client/hubs returns injected hub response', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/client/hubs')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'hub-create-1')
|
||||||
|
.send({
|
||||||
|
tenantId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
businessId: '22222222-2222-4222-8222-222222222222',
|
||||||
|
name: 'Google North Hub',
|
||||||
|
locationName: 'North Campus',
|
||||||
|
timezone: 'America/Los_Angeles',
|
||||||
|
latitude: 37.422,
|
||||||
|
longitude: -122.084,
|
||||||
|
geofenceRadiusMeters: 100,
|
||||||
|
costCenterId: '44444444-4444-4444-8444-444444444444',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.hubId, 'hub-1');
|
||||||
|
assert.equal(res.body.name, 'Google North Hub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/client/billing/invoices/55555555-5555-4555-8555-555555555555/approve')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'invoice-approve-1')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.invoiceId, '55555555-5555-4555-8555-555555555555');
|
||||||
|
assert.equal(res.body.status, 'APPROVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/shifts/66666666-6666-4666-8666-666666666666/apply')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'shift-apply-1')
|
||||||
|
.send({
|
||||||
|
note: 'Available tonight',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.shiftId, '66666666-6666-4666-8666-666666666666');
|
||||||
|
assert.equal(res.body.status, 'APPLIED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/clock-in')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'clock-in-1')
|
||||||
|
.send({
|
||||||
|
shiftId: '77777777-7777-4777-8777-777777777777',
|
||||||
|
sourceType: 'GEO',
|
||||||
|
latitude: 37.422,
|
||||||
|
longitude: -122.084,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.status, 'CLOCK_IN');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/clock-out')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'clock-out-1')
|
||||||
|
.send({
|
||||||
|
assignmentId: '88888888-8888-4888-8888-888888888888',
|
||||||
|
breakMinutes: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.status, 'CLOCK_OUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/commands/staff/profile/tax-forms/w4')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'tax-form-1')
|
||||||
|
.send({
|
||||||
|
fields: {
|
||||||
|
filingStatus: 'single',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.formType, 'W4');
|
||||||
|
assert.equal(res.body.status, 'DRAFT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/profile/bank-accounts uppercases account type', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/profile/bank-accounts')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'bank-account-1')
|
||||||
|
.send({
|
||||||
|
bankName: 'Demo Credit Union',
|
||||||
|
accountNumber: '1234567890',
|
||||||
|
routingNumber: '021000021',
|
||||||
|
accountType: 'checking',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.accountType, 'CHECKING');
|
||||||
|
assert.equal(res.body.last4, '7890');
|
||||||
|
});
|
||||||
129
backend/core-api/package-lock.json
generated
129
backend/core-api/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"firebase-admin": "^13.0.2",
|
"firebase-admin": "^13.0.2",
|
||||||
"google-auth-library": "^9.15.1",
|
"google-auth-library": "^9.15.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
@@ -2037,6 +2038,95 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.12.0",
|
||||||
|
"pg-pool": "^3.13.0",
|
||||||
|
"pg-protocol": "^1.13.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "9.14.0",
|
"version": "9.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
@@ -2086,6 +2176,45 @@
|
|||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"firebase-admin": "^13.0.2",
|
"firebase-admin": "^13.0.2",
|
||||||
"google-auth-library": "^9.15.1",
|
"google-auth-library": "^9.15.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import {
|
|||||||
retryVerificationJob,
|
retryVerificationJob,
|
||||||
reviewVerificationJob,
|
reviewVerificationJob,
|
||||||
} from '../services/verification-jobs.js';
|
} from '../services/verification-jobs.js';
|
||||||
|
import {
|
||||||
|
deleteCertificate,
|
||||||
|
uploadCertificate,
|
||||||
|
uploadProfilePhoto,
|
||||||
|
uploadStaffDocument,
|
||||||
|
} from '../services/mobile-upload.js';
|
||||||
|
|
||||||
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
const DEFAULT_MAX_SIGNED_URL_SECONDS = 900;
|
const DEFAULT_MAX_SIGNED_URL_SECONDS = 900;
|
||||||
@@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({
|
|||||||
visibility: z.enum(['public', 'private']).optional(),
|
visibility: z.enum(['public', 'private']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const certificateUploadMetaSchema = z.object({
|
||||||
|
certificateType: z.string().min(1).max(120),
|
||||||
|
name: z.string().min(1).max(160),
|
||||||
|
issuer: z.string().max(160).optional(),
|
||||||
|
certificateNumber: z.string().max(160).optional(),
|
||||||
|
expiresAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
@@ -292,7 +306,7 @@ async function handleCreateVerification(req, res, next) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = createVerificationJob({
|
const created = await createVerificationJob({
|
||||||
actorUid: req.actor.uid,
|
actorUid: req.actor.uid,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
@@ -305,10 +319,107 @@ async function handleCreateVerification(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleProfilePhotoUpload(req, res, next) {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||||
|
}
|
||||||
|
const result = await uploadProfilePhoto({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDocumentUpload(req, res, next) {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||||
|
}
|
||||||
|
const result = await uploadStaffDocument({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
documentId: req.params.documentId,
|
||||||
|
file,
|
||||||
|
routeType: 'document',
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAttireUpload(req, res, next) {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||||
|
}
|
||||||
|
const result = await uploadStaffDocument({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
documentId: req.params.documentId,
|
||||||
|
file,
|
||||||
|
routeType: 'attire',
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCertificateUpload(req, res, next) {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCertificateDelete(req, res, next) {
|
||||||
|
try {
|
||||||
|
const result = await deleteCertificate({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
certificateType: req.params.certificateType,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetVerification(req, res, next) {
|
async function handleGetVerification(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const verificationId = req.params.verificationId;
|
const verificationId = req.params.verificationId;
|
||||||
const job = getVerificationJob(verificationId, req.actor.uid);
|
const job = await getVerificationJob(verificationId, req.actor.uid);
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
...job,
|
...job,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
@@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const verificationId = req.params.verificationId;
|
const verificationId = req.params.verificationId;
|
||||||
const payload = parseBody(reviewVerificationSchema, req.body || {});
|
const payload = parseBody(reviewVerificationSchema, req.body || {});
|
||||||
const updated = reviewVerificationJob(verificationId, req.actor.uid, payload);
|
const updated = await reviewVerificationJob(verificationId, req.actor.uid, payload);
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
...updated,
|
...updated,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
@@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) {
|
|||||||
async function handleRetryVerification(req, res, next) {
|
async function handleRetryVerification(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const verificationId = req.params.verificationId;
|
const verificationId = req.params.verificationId;
|
||||||
const updated = retryVerificationJob(verificationId, req.actor.uid);
|
const updated = await retryVerificationJob(verificationId, req.actor.uid);
|
||||||
return res.status(202).json({
|
return res.status(202).json({
|
||||||
...updated,
|
...updated,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
@@ -353,6 +464,11 @@ 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('/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/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
|
||||||
|
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
|
||||||
|
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
|
||||||
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
|
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
|
||||||
router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
|
router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
|
||||||
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);
|
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||||
|
|
||||||
export const healthRouter = Router();
|
export const healthRouter = Router();
|
||||||
|
|
||||||
@@ -13,3 +14,31 @@ function healthHandler(req, res) {
|
|||||||
|
|
||||||
healthRouter.get('/health', healthHandler);
|
healthRouter.get('/health', healthHandler);
|
||||||
healthRouter.get('/healthz', healthHandler);
|
healthRouter.get('/healthz', healthHandler);
|
||||||
|
|
||||||
|
healthRouter.get('/readyz', async (req, res) => {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
service: 'krow-core-api',
|
||||||
|
status: 'DATABASE_NOT_CONFIGURED',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = await checkDatabaseHealth().catch(() => false);
|
||||||
|
if (!healthy) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
service: 'krow-core-api',
|
||||||
|
status: 'DATABASE_UNAVAILABLE',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
service: 'krow-core-api',
|
||||||
|
status: 'READY',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
67
backend/core-api/src/services/actor-context.js
Normal file
67
backend/core-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { query } from './db.js';
|
||||||
|
|
||||||
|
export async function loadActorContext(uid) {
|
||||||
|
const [userResult, tenantResult, staffResult] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT tm.id AS "membershipId",
|
||||||
|
tm.tenant_id AS "tenantId",
|
||||||
|
tm.base_role AS role,
|
||||||
|
t.name AS "tenantName",
|
||||||
|
t.slug AS "tenantSlug"
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT s.id AS "staffId",
|
||||||
|
s.tenant_id AS "tenantId",
|
||||||
|
s.full_name AS "fullName",
|
||||||
|
s.status,
|
||||||
|
s.metadata
|
||||||
|
FROM staffs s
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResult.rows[0] || null,
|
||||||
|
tenant: tenantResult.rows[0] || null,
|
||||||
|
staff: staffResult.rows[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTenantContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Tenant context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireStaffContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant || !context.staff) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
98
backend/core-api/src/services/db.js
Normal file
98
backend/core-api/src/services/db.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool, types } = pg;
|
||||||
|
|
||||||
|
function parseNumericDatabaseValue(value) {
|
||||||
|
if (value == null) return value;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||||
|
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function parseIntOrDefault(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDatabasePoolConfig() {
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return {
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||||
|
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = process.env.DB_USER;
|
||||||
|
const password = process.env.DB_PASSWORD;
|
||||||
|
const database = process.env.DB_NAME;
|
||||||
|
const host = process.env.DB_HOST || (
|
||||||
|
process.env.INSTANCE_CONNECTION_NAME
|
||||||
|
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || password == null || !database || !host) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||||
|
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDatabaseConfigured() {
|
||||||
|
return Boolean(resolveDatabasePoolConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
const resolved = resolveDatabasePoolConfig();
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error('Database connection settings are required');
|
||||||
|
}
|
||||||
|
pool = new Pool(resolved);
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(text, params = []) {
|
||||||
|
return getPool().query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTransaction(work) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await work(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkDatabaseHealth() {
|
||||||
|
const result = await query('SELECT 1 AS ok');
|
||||||
|
return result.rows[0]?.ok === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool() {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
260
backend/core-api/src/services/mobile-upload.js
Normal file
260
backend/core-api/src/services/mobile-upload.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { requireStaffContext } from './actor-context.js';
|
||||||
|
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
|
||||||
|
import { query, withTransaction } from './db.js';
|
||||||
|
import { createVerificationJob } from './verification-jobs.js';
|
||||||
|
|
||||||
|
function safeName(value) {
|
||||||
|
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadBucket() {
|
||||||
|
return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadActorFile({ actorUid, file, category }) {
|
||||||
|
const bucket = uploadBucket();
|
||||||
|
const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`;
|
||||||
|
const fileUri = `gs://${bucket}/${objectPath}`;
|
||||||
|
await uploadToGcs({
|
||||||
|
bucket,
|
||||||
|
objectPath,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
buffer: file.buffer,
|
||||||
|
});
|
||||||
|
return { bucket, objectPath, fileUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPreviewUrl(actorUid, fileUri) {
|
||||||
|
try {
|
||||||
|
return await generateReadSignedUrl({
|
||||||
|
fileUri,
|
||||||
|
actorUid,
|
||||||
|
expiresInSeconds: 900,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
signedUrl: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadProfilePhoto({ actorUid, file }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const uploaded = await uploadActorFile({
|
||||||
|
actorUid,
|
||||||
|
file,
|
||||||
|
category: 'profile-photo',
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE staffs
|
||||||
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||||
|
return {
|
||||||
|
staffId: context.staff.staffId,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
signedUrl: preview.signedUrl,
|
||||||
|
expiresAt: preview.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireDocument(tenantId, documentId, allowedTypes) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT id, document_type, name
|
||||||
|
FROM documents
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND id = $2
|
||||||
|
AND document_type = ANY($3::text[])
|
||||||
|
`,
|
||||||
|
[tenantId, documentId, allowedTypes]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, {
|
||||||
|
documentId,
|
||||||
|
allowedTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const document = await requireDocument(
|
||||||
|
context.tenant.tenantId,
|
||||||
|
documentId,
|
||||||
|
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
|
||||||
|
);
|
||||||
|
const uploaded = await uploadActorFile({
|
||||||
|
actorUid,
|
||||||
|
file,
|
||||||
|
category: routeType,
|
||||||
|
});
|
||||||
|
const verification = await createVerificationJob({
|
||||||
|
actorUid,
|
||||||
|
payload: {
|
||||||
|
type: routeType === 'attire' ? 'attire' : 'government_id',
|
||||||
|
subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document',
|
||||||
|
subjectId: documentId,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
metadata: {
|
||||||
|
routeType,
|
||||||
|
documentType: document.document_type,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
expectedDocumentName: document.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 'PENDING', $5, $6::jsonb)
|
||||||
|
ON CONFLICT (staff_id, document_id) DO UPDATE
|
||||||
|
SET file_uri = EXCLUDED.file_uri,
|
||||||
|
status = 'PENDING',
|
||||||
|
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,
|
||||||
|
uploaded.fileUri,
|
||||||
|
verification.verificationId,
|
||||||
|
JSON.stringify({
|
||||||
|
verificationStatus: verification.status,
|
||||||
|
routeType,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||||
|
return {
|
||||||
|
documentId: document.id,
|
||||||
|
documentType: document.document_type,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
signedUrl: preview.signedUrl,
|
||||||
|
expiresAt: preview.expiresAt,
|
||||||
|
verification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCertificate({ actorUid, file, payload }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const uploaded = await uploadActorFile({
|
||||||
|
actorUid,
|
||||||
|
file,
|
||||||
|
category: 'certificate',
|
||||||
|
});
|
||||||
|
const verification = await createVerificationJob({
|
||||||
|
actorUid,
|
||||||
|
payload: {
|
||||||
|
type: 'certification',
|
||||||
|
subjectType: 'certificate',
|
||||||
|
subjectId: payload.certificateType,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
metadata: {
|
||||||
|
certificateType: payload.certificateType,
|
||||||
|
name: payload.name,
|
||||||
|
issuer: payload.issuer || null,
|
||||||
|
certificateNumber: payload.certificateNumber || null,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
certificateType: payload.certificateType,
|
||||||
|
name: payload.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const certificateResult = await withTransaction(async (client) => {
|
||||||
|
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, 'PENDING', $6, $7, $8::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
context.staff.staffId,
|
||||||
|
payload.certificateType,
|
||||||
|
payload.certificateNumber || null,
|
||||||
|
payload.expiresAt || null,
|
||||||
|
uploaded.fileUri,
|
||||||
|
verification.verificationId,
|
||||||
|
JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
issuer: payload.issuer || null,
|
||||||
|
verificationStatus: verification.status,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||||
|
return {
|
||||||
|
certificateId: certificateResult.rows[0].id,
|
||||||
|
certificateType: payload.certificateType,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
signedUrl: preview.signedUrl,
|
||||||
|
expiresAt: preview.expiresAt,
|
||||||
|
verification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCertificate({ actorUid, certificateType }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
DELETE FROM certificates
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND staff_id = $2
|
||||||
|
AND certificate_type = $3
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId, certificateType]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, {
|
||||||
|
certificateType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
certificateId: result.rows[0].id,
|
||||||
|
deleted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
import { AppError } from '../lib/errors.js';
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { isDatabaseConfigured, query, withTransaction } from './db.js';
|
||||||
|
import { requireTenantContext } from './actor-context.js';
|
||||||
import { invokeVertexMultimodalModel } from './llm.js';
|
import { invokeVertexMultimodalModel } from './llm.js';
|
||||||
|
|
||||||
const jobs = new Map();
|
|
||||||
|
|
||||||
export const VerificationStatus = Object.freeze({
|
export const VerificationStatus = Object.freeze({
|
||||||
PENDING: 'PENDING',
|
PENDING: 'PENDING',
|
||||||
PROCESSING: 'PROCESSING',
|
PROCESSING: 'PROCESSING',
|
||||||
@@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({
|
|||||||
ERROR: 'ERROR',
|
ERROR: 'ERROR',
|
||||||
});
|
});
|
||||||
|
|
||||||
const MACHINE_TERMINAL_STATUSES = new Set([
|
|
||||||
VerificationStatus.AUTO_PASS,
|
|
||||||
VerificationStatus.AUTO_FAIL,
|
|
||||||
VerificationStatus.NEEDS_REVIEW,
|
|
||||||
VerificationStatus.ERROR,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const HUMAN_TERMINAL_STATUSES = new Set([
|
const HUMAN_TERMINAL_STATUSES = new Set([
|
||||||
VerificationStatus.APPROVED,
|
VerificationStatus.APPROVED,
|
||||||
VerificationStatus.REJECTED,
|
VerificationStatus.REJECTED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function nowIso() {
|
const memoryVerificationJobs = new Map();
|
||||||
return new Date().toISOString();
|
|
||||||
|
function useMemoryStore() {
|
||||||
|
if (process.env.VERIFICATION_STORE === 'memory') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVerificationId() {
|
||||||
|
if (typeof crypto?.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMemoryJob(verificationId) {
|
||||||
|
const job = memoryVerificationJobs.get(verificationId);
|
||||||
|
if (!job) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
|
||||||
|
verificationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processVerificationJobInMemory(verificationId) {
|
||||||
|
const job = memoryVerificationJobs.get(verificationId);
|
||||||
|
if (!job || job.status !== VerificationStatus.PENDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.status = VerificationStatus.PROCESSING;
|
||||||
|
job.updated_at = new Date().toISOString();
|
||||||
|
memoryVerificationJobs.set(verificationId, job);
|
||||||
|
|
||||||
|
const workItem = {
|
||||||
|
id: job.id,
|
||||||
|
type: job.type,
|
||||||
|
fileUri: job.file_uri,
|
||||||
|
subjectType: job.subject_type,
|
||||||
|
subjectId: job.subject_id,
|
||||||
|
rules: job.metadata?.rules || {},
|
||||||
|
metadata: job.metadata || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = workItem.type === 'attire'
|
||||||
|
? await runAttireChecks(workItem)
|
||||||
|
: await runThirdPartyChecks(workItem, workItem.type);
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...job,
|
||||||
|
status: result.status,
|
||||||
|
confidence: result.confidence,
|
||||||
|
reasons: result.reasons || [],
|
||||||
|
extracted: result.extracted || {},
|
||||||
|
provider_name: result.provider?.name || null,
|
||||||
|
provider_reference: result.provider?.reference || null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
memoryVerificationJobs.set(verificationId, updated);
|
||||||
|
} catch (error) {
|
||||||
|
const updated = {
|
||||||
|
...job,
|
||||||
|
status: VerificationStatus.ERROR,
|
||||||
|
reasons: [error?.message || 'Verification processing failed'],
|
||||||
|
provider_name: 'verification-worker',
|
||||||
|
provider_reference: `error:${error?.code || 'unknown'}`,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
memoryVerificationJobs.set(verificationId, updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function accessMode() {
|
function accessMode() {
|
||||||
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
|
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
|
function providerTimeoutMs() {
|
||||||
return {
|
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
|
||||||
id: crypto.randomUUID(),
|
|
||||||
fromStatus,
|
|
||||||
toStatus,
|
|
||||||
actorType,
|
|
||||||
actorId,
|
|
||||||
details,
|
|
||||||
createdAt: nowIso(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPublicJob(job) {
|
function attireModel() {
|
||||||
return {
|
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
|
||||||
verificationId: job.id,
|
|
||||||
type: job.type,
|
|
||||||
subjectType: job.subjectType,
|
|
||||||
subjectId: job.subjectId,
|
|
||||||
fileUri: job.fileUri,
|
|
||||||
status: job.status,
|
|
||||||
confidence: job.confidence,
|
|
||||||
reasons: job.reasons,
|
|
||||||
extracted: job.extracted,
|
|
||||||
provider: job.provider,
|
|
||||||
review: job.review,
|
|
||||||
createdAt: job.createdAt,
|
|
||||||
updatedAt: job.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAccess(job, actorUid) {
|
|
||||||
if (accessMode() === 'authenticated') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (job.ownerUid !== actorUid) {
|
|
||||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireJob(id) {
|
|
||||||
const job = jobs.get(id);
|
|
||||||
if (!job) {
|
|
||||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id });
|
|
||||||
}
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMachineStatus(status) {
|
|
||||||
if (
|
|
||||||
status === VerificationStatus.AUTO_PASS
|
|
||||||
|| status === VerificationStatus.AUTO_FAIL
|
|
||||||
|| status === VerificationStatus.NEEDS_REVIEW
|
|
||||||
) {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
return VerificationStatus.NEEDS_REVIEW;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampConfidence(value, fallback = 0.5) {
|
function clampConfidence(value, fallback = 0.5) {
|
||||||
@@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) {
|
|||||||
return [fallback];
|
return [fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerTimeoutMs() {
|
function normalizeMachineStatus(status) {
|
||||||
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
|
if (
|
||||||
|
status === VerificationStatus.AUTO_PASS
|
||||||
|
|| status === VerificationStatus.AUTO_FAIL
|
||||||
|
|| status === VerificationStatus.NEEDS_REVIEW
|
||||||
|
) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return VerificationStatus.NEEDS_REVIEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attireModel() {
|
function toPublicJob(row) {
|
||||||
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
verificationId: row.id,
|
||||||
|
type: row.type,
|
||||||
|
subjectType: row.subject_type,
|
||||||
|
subjectId: row.subject_id,
|
||||||
|
fileUri: row.file_uri,
|
||||||
|
status: row.status,
|
||||||
|
confidence: row.confidence == null ? null : Number(row.confidence),
|
||||||
|
reasons: Array.isArray(row.reasons) ? row.reasons : [],
|
||||||
|
extracted: row.extracted || {},
|
||||||
|
provider: row.provider_name
|
||||||
|
? {
|
||||||
|
name: row.provider_name,
|
||||||
|
reference: row.provider_reference || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
review: row.review || {},
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAccess(row, actorUid) {
|
||||||
|
if (accessMode() === 'authenticated') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.owner_user_id !== actorUid) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJob(verificationId) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM verification_jobs
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[verificationId]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
|
||||||
|
verificationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendVerificationEvent(client, {
|
||||||
|
verificationJobId,
|
||||||
|
fromStatus,
|
||||||
|
toStatus,
|
||||||
|
actorType,
|
||||||
|
actorId,
|
||||||
|
details = {},
|
||||||
|
}) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO verification_events (
|
||||||
|
verification_job_id,
|
||||||
|
from_status,
|
||||||
|
to_status,
|
||||||
|
actor_type,
|
||||||
|
actor_id,
|
||||||
|
details
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
|
`,
|
||||||
|
[verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAttireChecks(job) {
|
async function runAttireChecks(job) {
|
||||||
@@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodyText = await response.text();
|
const payload = await response.json().catch(() => ({}));
|
||||||
let body = {};
|
|
||||||
try {
|
|
||||||
body = bodyText ? JSON.parse(bodyText) : {};
|
|
||||||
} catch {
|
|
||||||
body = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
throw new Error(payload?.error || payload?.message || `${provider.name} failed`);
|
||||||
status: VerificationStatus.NEEDS_REVIEW,
|
|
||||||
confidence: 0.35,
|
|
||||||
reasons: [`${provider.name} returned ${response.status}`],
|
|
||||||
extracted: {},
|
|
||||||
provider: {
|
|
||||||
name: provider.name,
|
|
||||||
reference: body?.reference || null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: normalizeMachineStatus(body.status),
|
status: normalizeMachineStatus(payload.status),
|
||||||
confidence: clampConfidence(body.confidence, 0.6),
|
confidence: clampConfidence(payload.confidence, 0.6),
|
||||||
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
|
reasons: asReasonList(payload.reasons, `${provider.name} completed`),
|
||||||
extracted: body.extracted || {},
|
extracted: payload.extracted || {},
|
||||||
provider: {
|
provider: {
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
reference: body.reference || null,
|
reference: payload.reference || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isAbort = error?.name === 'AbortError';
|
|
||||||
return {
|
return {
|
||||||
status: VerificationStatus.NEEDS_REVIEW,
|
status: VerificationStatus.NEEDS_REVIEW,
|
||||||
confidence: 0.3,
|
confidence: 0.35,
|
||||||
reasons: [
|
reasons: [error?.message || `${provider.name} unavailable`],
|
||||||
isAbort
|
|
||||||
? `${provider.name} timeout, manual review required`
|
|
||||||
: `${provider.name} unavailable, manual review required`,
|
|
||||||
],
|
|
||||||
extracted: {},
|
extracted: {},
|
||||||
provider: {
|
provider: {
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
@@ -310,136 +379,254 @@ async function runThirdPartyChecks(job, type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMachineChecks(job) {
|
async function processVerificationJob(verificationId) {
|
||||||
if (job.type === 'attire') {
|
const startedJob = await withTransaction(async (client) => {
|
||||||
return runAttireChecks(job);
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM verification_jobs
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[verificationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.type === 'government_id') {
|
const job = result.rows[0];
|
||||||
return runThirdPartyChecks(job, 'government_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
return runThirdPartyChecks(job, 'certification');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processVerificationJob(id) {
|
|
||||||
const job = requireJob(id);
|
|
||||||
if (job.status !== VerificationStatus.PENDING) {
|
if (job.status !== VerificationStatus.PENDING) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE verification_jobs
|
||||||
|
SET status = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[verificationId, VerificationStatus.PROCESSING]
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendVerificationEvent(client, {
|
||||||
|
verificationJobId: verificationId,
|
||||||
|
fromStatus: job.status,
|
||||||
|
toStatus: VerificationStatus.PROCESSING,
|
||||||
|
actorType: 'worker',
|
||||||
|
actorId: 'verification-worker',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: verificationId,
|
||||||
|
type: job.type,
|
||||||
|
fileUri: job.file_uri,
|
||||||
|
subjectType: job.subject_type,
|
||||||
|
subjectId: job.subject_id,
|
||||||
|
rules: job.metadata?.rules || {},
|
||||||
|
metadata: job.metadata || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startedJob) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeProcessing = job.status;
|
try {
|
||||||
job.status = VerificationStatus.PROCESSING;
|
const result = startedJob.type === 'attire'
|
||||||
job.updatedAt = nowIso();
|
? await runAttireChecks(startedJob)
|
||||||
job.events.push(
|
: await runThirdPartyChecks(startedJob, startedJob.type);
|
||||||
eventRecord({
|
|
||||||
fromStatus: beforeProcessing,
|
await withTransaction(async (client) => {
|
||||||
toStatus: VerificationStatus.PROCESSING,
|
await client.query(
|
||||||
actorType: 'system',
|
`
|
||||||
actorId: 'verification-worker',
|
UPDATE verification_jobs
|
||||||
})
|
SET status = $2,
|
||||||
|
confidence = $3,
|
||||||
|
reasons = $4::jsonb,
|
||||||
|
extracted = $5::jsonb,
|
||||||
|
provider_name = $6,
|
||||||
|
provider_reference = $7,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
verificationId,
|
||||||
|
result.status,
|
||||||
|
result.confidence,
|
||||||
|
JSON.stringify(result.reasons || []),
|
||||||
|
JSON.stringify(result.extracted || {}),
|
||||||
|
result.provider?.name || null,
|
||||||
|
result.provider?.reference || null,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
await appendVerificationEvent(client, {
|
||||||
const outcome = await runMachineChecks(job);
|
verificationJobId: verificationId,
|
||||||
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
|
fromStatus: VerificationStatus.PROCESSING,
|
||||||
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
|
toStatus: result.status,
|
||||||
}
|
actorType: 'worker',
|
||||||
const fromStatus = job.status;
|
|
||||||
job.status = outcome.status;
|
|
||||||
job.confidence = outcome.confidence;
|
|
||||||
job.reasons = outcome.reasons;
|
|
||||||
job.extracted = outcome.extracted;
|
|
||||||
job.provider = outcome.provider;
|
|
||||||
job.updatedAt = nowIso();
|
|
||||||
job.events.push(
|
|
||||||
eventRecord({
|
|
||||||
fromStatus,
|
|
||||||
toStatus: job.status,
|
|
||||||
actorType: 'system',
|
|
||||||
actorId: 'verification-worker',
|
actorId: 'verification-worker',
|
||||||
details: {
|
details: {
|
||||||
confidence: job.confidence,
|
confidence: result.confidence,
|
||||||
reasons: job.reasons,
|
|
||||||
provider: job.provider,
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fromStatus = job.status;
|
await withTransaction(async (client) => {
|
||||||
job.status = VerificationStatus.ERROR;
|
await client.query(
|
||||||
job.confidence = null;
|
`
|
||||||
job.reasons = [error?.message || 'Verification processing failed'];
|
UPDATE verification_jobs
|
||||||
job.extracted = {};
|
SET status = $2,
|
||||||
job.provider = {
|
reasons = $3::jsonb,
|
||||||
name: 'verification-worker',
|
provider_name = 'verification-worker',
|
||||||
reference: null,
|
provider_reference = $4,
|
||||||
};
|
updated_at = NOW()
|
||||||
job.updatedAt = nowIso();
|
WHERE id = $1
|
||||||
job.events.push(
|
`,
|
||||||
eventRecord({
|
[
|
||||||
fromStatus,
|
verificationId,
|
||||||
|
VerificationStatus.ERROR,
|
||||||
|
JSON.stringify([error?.message || 'Verification processing failed']),
|
||||||
|
`error:${error?.code || 'unknown'}`,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendVerificationEvent(client, {
|
||||||
|
verificationJobId: verificationId,
|
||||||
|
fromStatus: VerificationStatus.PROCESSING,
|
||||||
toStatus: VerificationStatus.ERROR,
|
toStatus: VerificationStatus.ERROR,
|
||||||
actorType: 'system',
|
actorType: 'worker',
|
||||||
actorId: 'verification-worker',
|
actorId: 'verification-worker',
|
||||||
details: {
|
details: {
|
||||||
error: error?.message || 'Verification processing failed',
|
error: error?.message || 'Verification processing failed',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueVerificationProcessing(id) {
|
function queueVerificationProcessing(verificationId) {
|
||||||
setTimeout(() => {
|
setImmediate(() => {
|
||||||
processVerificationJob(id).catch(() => {});
|
const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob;
|
||||||
}, 0);
|
worker(verificationId).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVerificationJob({ actorUid, payload }) {
|
export async function createVerificationJob({ actorUid, payload }) {
|
||||||
const now = nowIso();
|
if (useMemoryStore()) {
|
||||||
const id = `ver_${crypto.randomUUID()}`;
|
const timestamp = new Date().toISOString();
|
||||||
const job = {
|
const created = {
|
||||||
id,
|
id: nextVerificationId(),
|
||||||
|
tenant_id: null,
|
||||||
|
staff_id: null,
|
||||||
|
owner_user_id: actorUid,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
subjectType: payload.subjectType || null,
|
subject_type: payload.subjectType || null,
|
||||||
subjectId: payload.subjectId || null,
|
subject_id: payload.subjectId || null,
|
||||||
ownerUid: actorUid,
|
file_uri: payload.fileUri,
|
||||||
fileUri: payload.fileUri,
|
|
||||||
rules: payload.rules || {},
|
|
||||||
metadata: payload.metadata || {},
|
|
||||||
status: VerificationStatus.PENDING,
|
status: VerificationStatus.PENDING,
|
||||||
confidence: null,
|
confidence: null,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
extracted: {},
|
extracted: {},
|
||||||
provider: null,
|
provider_name: null,
|
||||||
review: null,
|
provider_reference: null,
|
||||||
createdAt: now,
|
review: {},
|
||||||
updatedAt: now,
|
metadata: {
|
||||||
events: [
|
...(payload.metadata || {}),
|
||||||
eventRecord({
|
rules: payload.rules || {},
|
||||||
|
},
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
};
|
||||||
|
memoryVerificationJobs.set(created.id, created);
|
||||||
|
queueVerificationProcessing(created.id);
|
||||||
|
return toPublicJob(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await requireTenantContext(actorUid);
|
||||||
|
const created = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO verification_jobs (
|
||||||
|
tenant_id,
|
||||||
|
staff_id,
|
||||||
|
document_id,
|
||||||
|
owner_user_id,
|
||||||
|
type,
|
||||||
|
subject_type,
|
||||||
|
subject_id,
|
||||||
|
file_uri,
|
||||||
|
status,
|
||||||
|
reasons,
|
||||||
|
extracted,
|
||||||
|
review,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
NULL,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
$6,
|
||||||
|
$7,
|
||||||
|
'PENDING',
|
||||||
|
'[]'::jsonb,
|
||||||
|
'{}'::jsonb,
|
||||||
|
'{}'::jsonb,
|
||||||
|
$8::jsonb
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
context.staff?.staffId || null,
|
||||||
|
actorUid,
|
||||||
|
payload.type,
|
||||||
|
payload.subjectType || null,
|
||||||
|
payload.subjectId || null,
|
||||||
|
payload.fileUri,
|
||||||
|
JSON.stringify({
|
||||||
|
...(payload.metadata || {}),
|
||||||
|
rules: payload.rules || {},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendVerificationEvent(client, {
|
||||||
|
verificationJobId: result.rows[0].id,
|
||||||
fromStatus: null,
|
fromStatus: null,
|
||||||
toStatus: VerificationStatus.PENDING,
|
toStatus: VerificationStatus.PENDING,
|
||||||
actorType: 'system',
|
actorType: 'system',
|
||||||
actorId: actorUid,
|
actorId: actorUid,
|
||||||
}),
|
});
|
||||||
],
|
|
||||||
};
|
return result.rows[0];
|
||||||
jobs.set(id, job);
|
});
|
||||||
queueVerificationProcessing(id);
|
|
||||||
return toPublicJob(job);
|
queueVerificationProcessing(created.id);
|
||||||
|
return toPublicJob(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVerificationJob(verificationId, actorUid) {
|
export async function getVerificationJob(verificationId, actorUid) {
|
||||||
const job = requireJob(verificationId);
|
if (useMemoryStore()) {
|
||||||
|
const job = loadMemoryJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
assertAccess(job, actorUid);
|
||||||
return toPublicJob(job);
|
return toPublicJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reviewVerificationJob(verificationId, actorUid, review) {
|
const job = await loadJob(verificationId);
|
||||||
const job = requireJob(verificationId);
|
|
||||||
assertAccess(job, actorUid);
|
assertAccess(job, actorUid);
|
||||||
|
return toPublicJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||||
|
if (useMemoryStore()) {
|
||||||
|
const job = loadMemoryJob(verificationId);
|
||||||
|
assertAccess(job, actorUid);
|
||||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
@@ -447,64 +634,207 @@ export function reviewVerificationJob(verificationId, actorUid, review) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromStatus = job.status;
|
const reviewPayload = {
|
||||||
job.status = review.decision;
|
|
||||||
job.review = {
|
|
||||||
decision: review.decision,
|
decision: review.decision,
|
||||||
reviewedBy: actorUid,
|
reviewedBy: actorUid,
|
||||||
reviewedAt: nowIso(),
|
reviewedAt: new Date().toISOString(),
|
||||||
note: review.note || '',
|
note: review.note || '',
|
||||||
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||||
};
|
};
|
||||||
job.updatedAt = nowIso();
|
|
||||||
job.events.push(
|
const updated = {
|
||||||
eventRecord({
|
...job,
|
||||||
fromStatus,
|
status: review.decision,
|
||||||
toStatus: job.status,
|
review: reviewPayload,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
memoryVerificationJobs.set(verificationId, updated);
|
||||||
|
return toPublicJob(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await requireTenantContext(actorUid);
|
||||||
|
const updated = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM verification_jobs
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[verificationId]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = result.rows[0];
|
||||||
|
assertAccess(job, actorUid);
|
||||||
|
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||||
|
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||||
|
verificationId,
|
||||||
|
status: job.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewPayload = {
|
||||||
|
decision: review.decision,
|
||||||
|
reviewedBy: actorUid,
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
note: review.note || '',
|
||||||
|
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE verification_jobs
|
||||||
|
SET status = $2,
|
||||||
|
review = $3::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[verificationId, review.decision, JSON.stringify(reviewPayload)]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO verification_reviews (
|
||||||
|
verification_job_id,
|
||||||
|
reviewer_user_id,
|
||||||
|
decision,
|
||||||
|
note,
|
||||||
|
reason_code
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`,
|
||||||
|
[verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW']
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendVerificationEvent(client, {
|
||||||
|
verificationJobId: verificationId,
|
||||||
|
fromStatus: job.status,
|
||||||
|
toStatus: review.decision,
|
||||||
actorType: 'reviewer',
|
actorType: 'reviewer',
|
||||||
actorId: actorUid,
|
actorId: actorUid,
|
||||||
details: {
|
details: {
|
||||||
reasonCode: job.review.reasonCode,
|
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return toPublicJob(job);
|
return {
|
||||||
|
...job,
|
||||||
|
status: review.decision,
|
||||||
|
review: reviewPayload,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
void context;
|
||||||
|
return toPublicJob(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retryVerificationJob(verificationId, actorUid) {
|
export async function retryVerificationJob(verificationId, actorUid) {
|
||||||
const job = requireJob(verificationId);
|
if (useMemoryStore()) {
|
||||||
|
const job = loadMemoryJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
assertAccess(job, actorUid);
|
||||||
|
|
||||||
if (job.status === VerificationStatus.PROCESSING) {
|
if (job.status === VerificationStatus.PROCESSING) {
|
||||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromStatus = job.status;
|
const updated = {
|
||||||
job.status = VerificationStatus.PENDING;
|
...job,
|
||||||
job.confidence = null;
|
status: VerificationStatus.PENDING,
|
||||||
job.reasons = [];
|
confidence: null,
|
||||||
job.extracted = {};
|
reasons: [],
|
||||||
job.provider = null;
|
extracted: {},
|
||||||
job.review = null;
|
provider_name: null,
|
||||||
job.updatedAt = nowIso();
|
provider_reference: null,
|
||||||
job.events.push(
|
review: {},
|
||||||
eventRecord({
|
updated_at: new Date().toISOString(),
|
||||||
fromStatus,
|
};
|
||||||
|
memoryVerificationJobs.set(verificationId, updated);
|
||||||
|
queueVerificationProcessing(verificationId);
|
||||||
|
return toPublicJob(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM verification_jobs
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[verificationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = result.rows[0];
|
||||||
|
assertAccess(job, actorUid);
|
||||||
|
if (job.status === VerificationStatus.PROCESSING) {
|
||||||
|
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||||
|
verificationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE verification_jobs
|
||||||
|
SET status = $2,
|
||||||
|
confidence = NULL,
|
||||||
|
reasons = '[]'::jsonb,
|
||||||
|
extracted = '{}'::jsonb,
|
||||||
|
provider_name = NULL,
|
||||||
|
provider_reference = NULL,
|
||||||
|
review = '{}'::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[verificationId, VerificationStatus.PENDING]
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendVerificationEvent(client, {
|
||||||
|
verificationJobId: verificationId,
|
||||||
|
fromStatus: job.status,
|
||||||
toStatus: VerificationStatus.PENDING,
|
toStatus: VerificationStatus.PENDING,
|
||||||
actorType: 'reviewer',
|
actorType: 'reviewer',
|
||||||
actorId: actorUid,
|
actorId: actorUid,
|
||||||
details: {
|
details: {
|
||||||
retried: true,
|
retried: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
status: VerificationStatus.PENDING,
|
||||||
|
confidence: null,
|
||||||
|
reasons: [],
|
||||||
|
extracted: {},
|
||||||
|
provider_name: null,
|
||||||
|
provider_reference: null,
|
||||||
|
review: {},
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
queueVerificationProcessing(verificationId);
|
queueVerificationProcessing(verificationId);
|
||||||
return toPublicJob(job);
|
return toPublicJob(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function __resetVerificationJobsForTests() {
|
export async function __resetVerificationJobsForTests() {
|
||||||
jobs.clear();
|
if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memoryVerificationJobs.clear();
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM verification_reviews');
|
||||||
|
await query('DELETE FROM verification_events');
|
||||||
|
await query('DELETE FROM verification_jobs');
|
||||||
|
} catch {
|
||||||
|
// Intentionally ignore when tests run without a configured database.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createApp } from '../src/app.js';
|
|||||||
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
||||||
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
|
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
process.env.AUTH_BYPASS = 'true';
|
process.env.AUTH_BYPASS = 'true';
|
||||||
process.env.LLM_MOCK = 'true';
|
process.env.LLM_MOCK = 'true';
|
||||||
process.env.SIGNED_URL_MOCK = 'true';
|
process.env.SIGNED_URL_MOCK = 'true';
|
||||||
@@ -15,8 +15,9 @@ beforeEach(() => {
|
|||||||
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
||||||
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
|
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
|
||||||
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
||||||
|
process.env.VERIFICATION_STORE = 'memory';
|
||||||
__resetLlmRateLimitForTests();
|
__resetLlmRateLimitForTests();
|
||||||
__resetVerificationJobsForTests();
|
await __resetVerificationJobsForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function waitForMachineStatus(app, verificationId, maxAttempts = 30) {
|
async function waitForMachineStatus(app, verificationId, maxAttempts = 30) {
|
||||||
@@ -49,6 +50,22 @@ test('GET /healthz returns healthy response', async () => {
|
|||||||
assert.equal(typeof res.headers['x-request-id'], 'string');
|
assert.equal(typeof res.headers['x-request-id'], 'string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /readyz reports database not configured when env is absent', async () => {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
delete process.env.DB_HOST;
|
||||||
|
delete process.env.DB_NAME;
|
||||||
|
delete process.env.DB_USER;
|
||||||
|
delete process.env.DB_PASSWORD;
|
||||||
|
delete process.env.INSTANCE_CONNECTION_NAME;
|
||||||
|
delete process.env.VERIFICATION_STORE;
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const res = await request(app).get('/readyz');
|
||||||
|
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /core/create-signed-url requires auth', async () => {
|
test('POST /core/create-signed-url requires auth', async () => {
|
||||||
process.env.AUTH_BYPASS = 'false';
|
process.env.AUTH_BYPASS = 'false';
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|||||||
41
backend/query-api/src/data/faqs.js
Normal file
41
backend/query-api/src/data/faqs.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export const FAQ_CATEGORIES = [
|
||||||
|
{
|
||||||
|
category: 'Getting Started',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'How do I complete my worker profile?',
|
||||||
|
answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Why can I not apply to shifts yet?',
|
||||||
|
answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Shifts And Attendance',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'How does clock-in work?',
|
||||||
|
answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What happens if I request a swap?',
|
||||||
|
answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Payments And Compliance',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'When do I see my earnings?',
|
||||||
|
answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'How are documents and certificates verified?',
|
||||||
|
answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,32 +1,47 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
|
getCoverageReport,
|
||||||
getClientDashboard,
|
getClientDashboard,
|
||||||
getClientSession,
|
getClientSession,
|
||||||
getCoverageStats,
|
getCoverageStats,
|
||||||
getCurrentAttendanceStatus,
|
getCurrentAttendanceStatus,
|
||||||
getCurrentBill,
|
getCurrentBill,
|
||||||
|
getDailyOpsReport,
|
||||||
getPaymentChart,
|
getPaymentChart,
|
||||||
getPaymentsSummary,
|
getPaymentsSummary,
|
||||||
getPersonalInfo,
|
getPersonalInfo,
|
||||||
|
getPerformanceReport,
|
||||||
getProfileSectionsStatus,
|
getProfileSectionsStatus,
|
||||||
|
getPrivacySettings,
|
||||||
|
getForecastReport,
|
||||||
|
getNoShowReport,
|
||||||
|
getOrderReorderPreview,
|
||||||
|
getReportSummary,
|
||||||
getSavings,
|
getSavings,
|
||||||
getStaffDashboard,
|
getStaffDashboard,
|
||||||
getStaffProfileCompletion,
|
getStaffProfileCompletion,
|
||||||
getStaffSession,
|
getStaffSession,
|
||||||
getStaffShiftDetail,
|
getStaffShiftDetail,
|
||||||
|
listAttireChecklist,
|
||||||
listAssignedShifts,
|
listAssignedShifts,
|
||||||
listBusinessAccounts,
|
listBusinessAccounts,
|
||||||
|
listBusinessTeamMembers,
|
||||||
listCancelledShifts,
|
listCancelledShifts,
|
||||||
listCertificates,
|
listCertificates,
|
||||||
listCostCenters,
|
listCostCenters,
|
||||||
|
listCoreTeam,
|
||||||
listCoverageByDate,
|
listCoverageByDate,
|
||||||
listCompletedShifts,
|
listCompletedShifts,
|
||||||
|
listEmergencyContacts,
|
||||||
|
listFaqCategories,
|
||||||
listHubManagers,
|
listHubManagers,
|
||||||
listHubs,
|
listHubs,
|
||||||
listIndustries,
|
listIndustries,
|
||||||
listInvoiceHistory,
|
listInvoiceHistory,
|
||||||
listOpenShifts,
|
listOpenShifts,
|
||||||
|
listTaxForms,
|
||||||
|
listTimeCardEntries,
|
||||||
listOrderItemsByDateRange,
|
listOrderItemsByDateRange,
|
||||||
listPaymentsHistory,
|
listPaymentsHistory,
|
||||||
listPendingAssignments,
|
listPendingAssignments,
|
||||||
@@ -40,37 +55,55 @@ import {
|
|||||||
listTodayShifts,
|
listTodayShifts,
|
||||||
listVendorRoles,
|
listVendorRoles,
|
||||||
listVendors,
|
listVendors,
|
||||||
|
searchFaqs,
|
||||||
getSpendBreakdown,
|
getSpendBreakdown,
|
||||||
|
getSpendReport,
|
||||||
} from '../services/mobile-query-service.js';
|
} from '../services/mobile-query-service.js';
|
||||||
|
|
||||||
const defaultQueryService = {
|
const defaultQueryService = {
|
||||||
getClientDashboard,
|
getClientDashboard,
|
||||||
getClientSession,
|
getClientSession,
|
||||||
|
getCoverageReport,
|
||||||
getCoverageStats,
|
getCoverageStats,
|
||||||
getCurrentAttendanceStatus,
|
getCurrentAttendanceStatus,
|
||||||
getCurrentBill,
|
getCurrentBill,
|
||||||
|
getDailyOpsReport,
|
||||||
getPaymentChart,
|
getPaymentChart,
|
||||||
getPaymentsSummary,
|
getPaymentsSummary,
|
||||||
getPersonalInfo,
|
getPersonalInfo,
|
||||||
|
getPerformanceReport,
|
||||||
getProfileSectionsStatus,
|
getProfileSectionsStatus,
|
||||||
|
getPrivacySettings,
|
||||||
|
getForecastReport,
|
||||||
|
getNoShowReport,
|
||||||
|
getOrderReorderPreview,
|
||||||
|
getReportSummary,
|
||||||
getSavings,
|
getSavings,
|
||||||
getSpendBreakdown,
|
getSpendBreakdown,
|
||||||
|
getSpendReport,
|
||||||
getStaffDashboard,
|
getStaffDashboard,
|
||||||
getStaffProfileCompletion,
|
getStaffProfileCompletion,
|
||||||
getStaffSession,
|
getStaffSession,
|
||||||
getStaffShiftDetail,
|
getStaffShiftDetail,
|
||||||
|
listAttireChecklist,
|
||||||
listAssignedShifts,
|
listAssignedShifts,
|
||||||
listBusinessAccounts,
|
listBusinessAccounts,
|
||||||
|
listBusinessTeamMembers,
|
||||||
listCancelledShifts,
|
listCancelledShifts,
|
||||||
listCertificates,
|
listCertificates,
|
||||||
listCostCenters,
|
listCostCenters,
|
||||||
|
listCoreTeam,
|
||||||
listCoverageByDate,
|
listCoverageByDate,
|
||||||
listCompletedShifts,
|
listCompletedShifts,
|
||||||
|
listEmergencyContacts,
|
||||||
|
listFaqCategories,
|
||||||
listHubManagers,
|
listHubManagers,
|
||||||
listHubs,
|
listHubs,
|
||||||
listIndustries,
|
listIndustries,
|
||||||
listInvoiceHistory,
|
listInvoiceHistory,
|
||||||
listOpenShifts,
|
listOpenShifts,
|
||||||
|
listTaxForms,
|
||||||
|
listTimeCardEntries,
|
||||||
listOrderItemsByDateRange,
|
listOrderItemsByDateRange,
|
||||||
listPaymentsHistory,
|
listPaymentsHistory,
|
||||||
listPendingAssignments,
|
listPendingAssignments,
|
||||||
@@ -84,6 +117,7 @@ const defaultQueryService = {
|
|||||||
listTodayShifts,
|
listTodayShifts,
|
||||||
listVendorRoles,
|
listVendorRoles,
|
||||||
listVendors,
|
listVendors,
|
||||||
|
searchFaqs,
|
||||||
};
|
};
|
||||||
|
|
||||||
function requireQueryParam(name, value) {
|
function requireQueryParam(name, value) {
|
||||||
@@ -199,6 +233,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listCoreTeam(req.actor.uid);
|
||||||
|
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);
|
||||||
@@ -244,6 +287,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listBusinessTeamMembers(req.actor.uid);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||||
@@ -253,6 +305,78 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getReportSummary(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getSpendReport(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getCoverageReport(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getForecastReport(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getPerformanceReport(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getNoShowReport(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => {
|
router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const data = await queryService.getStaffSession(req.actor.uid);
|
const data = await queryService.getStaffSession(req.actor.uid);
|
||||||
@@ -433,6 +557,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listAttireChecklist(req.actor.uid);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listTaxForms(req.actor.uid);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listEmergencyContacts(req.actor.uid);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listCertificates(req.actor.uid);
|
const items = await queryService.listCertificates(req.actor.uid);
|
||||||
@@ -460,5 +611,41 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await queryService.getPrivacySettings(req.actor.uid);
|
||||||
|
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/staff/faqs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listFaqCategories();
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/staff/faqs/search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.searchFaqs(req.query.q || '');
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { Pool } from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool, types } = pg;
|
||||||
|
|
||||||
|
function parseNumericDatabaseValue(value) {
|
||||||
|
if (value == null) return value;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile/frontend routes expect numeric JSON values for database aggregates.
|
||||||
|
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||||
|
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppError } from '../lib/errors.js';
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { FAQ_CATEGORIES } from '../data/faqs.js';
|
||||||
import { query } from './db.js';
|
import { query } from './db.js';
|
||||||
import { requireClientContext, requireStaffContext } from './actor-context.js';
|
import { requireClientContext, requireStaffContext } from './actor-context.js';
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ function metadataArray(metadata, key) {
|
|||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metadataBoolean(metadata, key, fallback = false) {
|
||||||
|
const value = metadata?.[key];
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
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+/);
|
||||||
@@ -775,6 +782,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
|
WITH open_roles AS (
|
||||||
SELECT
|
SELECT
|
||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
sr.id AS "roleId",
|
sr.id AS "roleId",
|
||||||
@@ -793,6 +801,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
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 s.tenant_id = $1
|
WHERE s.tenant_id = $1
|
||||||
AND s.status = 'OPEN'
|
AND s.status = 'OPEN'
|
||||||
|
AND sr.role_code = $4
|
||||||
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -801,8 +810,45 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
AND a.staff_id = $3
|
AND a.staff_id = $3
|
||||||
AND a.status IN ('PENDING', 'CONFIRMED')
|
AND a.status IN ('PENDING', 'CONFIRMED')
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
swap_roles AS (
|
||||||
|
SELECT
|
||||||
|
s.id AS "shiftId",
|
||||||
|
sr.id AS "roleId",
|
||||||
|
sr.role_name AS "roleName",
|
||||||
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
|
s.starts_at AS date,
|
||||||
|
s.starts_at AS "startTime",
|
||||||
|
s.ends_at AS "endTime",
|
||||||
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
|
FALSE AS "instantBook",
|
||||||
|
1::INTEGER AS "requiredWorkerCount"
|
||||||
|
FROM assignments a
|
||||||
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
|
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
|
JOIN orders o ON o.id = s.order_id
|
||||||
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
|
WHERE a.tenant_id = $1
|
||||||
|
AND a.status = 'SWAP_REQUESTED'
|
||||||
|
AND a.staff_id <> $3
|
||||||
AND sr.role_code = $4
|
AND sr.role_code = $4
|
||||||
ORDER BY s.starts_at ASC
|
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM applications app
|
||||||
|
WHERE app.shift_role_id = sr.id
|
||||||
|
AND app.staff_id = $3
|
||||||
|
AND app.status IN ('PENDING', 'CONFIRMED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM open_roles
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM swap_roles
|
||||||
|
) items
|
||||||
|
ORDER BY "startTime" ASC
|
||||||
LIMIT $5
|
LIMIT $5
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -987,17 +1033,21 @@ export async function listProfileDocuments(actorUid) {
|
|||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
sd.id AS "staffDocumentId",
|
|
||||||
d.id AS "documentId",
|
d.id AS "documentId",
|
||||||
d.document_type AS "documentType",
|
d.document_type AS "documentType",
|
||||||
d.name,
|
d.name,
|
||||||
|
sd.id AS "staffDocumentId",
|
||||||
sd.file_uri AS "fileUri",
|
sd.file_uri AS "fileUri",
|
||||||
sd.status,
|
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
||||||
sd.expires_at AS "expiresAt"
|
sd.expires_at AS "expiresAt",
|
||||||
FROM staff_documents sd
|
sd.metadata
|
||||||
JOIN documents d ON d.id = sd.document_id
|
FROM documents d
|
||||||
WHERE sd.tenant_id = $1
|
LEFT JOIN staff_documents sd
|
||||||
|
ON sd.document_id = d.id
|
||||||
|
AND sd.tenant_id = d.tenant_id
|
||||||
AND sd.staff_id = $2
|
AND sd.staff_id = $2
|
||||||
|
WHERE d.tenant_id = $1
|
||||||
|
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM')
|
||||||
ORDER BY d.name ASC
|
ORDER BY d.name ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.staff.staffId]
|
[context.tenant.tenantId, context.staff.staffId]
|
||||||
@@ -1012,10 +1062,14 @@ export async function listCertificates(actorUid) {
|
|||||||
SELECT
|
SELECT
|
||||||
id AS "certificateId",
|
id AS "certificateId",
|
||||||
certificate_type AS "certificateType",
|
certificate_type AS "certificateType",
|
||||||
|
COALESCE(metadata->>'name', certificate_type) AS name,
|
||||||
|
file_uri AS "fileUri",
|
||||||
|
metadata->>'issuer' AS issuer,
|
||||||
certificate_number AS "certificateNumber",
|
certificate_number AS "certificateNumber",
|
||||||
issued_at AS "issuedAt",
|
issued_at AS "issuedAt",
|
||||||
expires_at AS "expiresAt",
|
expires_at AS "expiresAt",
|
||||||
status
|
status,
|
||||||
|
metadata->>'verificationStatus' AS "verificationStatus"
|
||||||
FROM certificates
|
FROM certificates
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND staff_id = $2
|
AND staff_id = $2
|
||||||
@@ -1069,3 +1123,580 @@ export async function listStaffBenefits(actorUid) {
|
|||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCoreTeam(actorUid) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
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",
|
||||||
|
TRUE AS favorite
|
||||||
|
FROM staff_favorites sf
|
||||||
|
JOIN staffs st ON st.id = sf.staff_id
|
||||||
|
WHERE sf.tenant_id = $1
|
||||||
|
AND sf.business_id = $2
|
||||||
|
ORDER BY st.average_rating DESC, st.full_name ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrderReorderPreview(actorUid, orderId) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
o.id AS "orderId",
|
||||||
|
o.title,
|
||||||
|
o.description,
|
||||||
|
o.starts_at AS "startsAt",
|
||||||
|
o.ends_at AS "endsAt",
|
||||||
|
o.location_name AS "locationName",
|
||||||
|
o.location_address AS "locationAddress",
|
||||||
|
o.metadata,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'shiftId', s.id,
|
||||||
|
'shiftCode', s.shift_code,
|
||||||
|
'title', s.title,
|
||||||
|
'startsAt', s.starts_at,
|
||||||
|
'endsAt', s.ends_at,
|
||||||
|
'roles', (
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'roleId', sr.id,
|
||||||
|
'roleCode', sr.role_code,
|
||||||
|
'roleName', sr.role_name,
|
||||||
|
'workersNeeded', sr.workers_needed,
|
||||||
|
'payRateCents', sr.pay_rate_cents,
|
||||||
|
'billRateCents', sr.bill_rate_cents
|
||||||
|
)
|
||||||
|
ORDER BY sr.role_name ASC
|
||||||
|
)
|
||||||
|
FROM shift_roles sr
|
||||||
|
WHERE sr.shift_id = s.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY s.starts_at ASC
|
||||||
|
) AS shifts
|
||||||
|
FROM orders o
|
||||||
|
JOIN shifts s ON s.order_id = o.id
|
||||||
|
WHERE o.tenant_id = $1
|
||||||
|
AND o.business_id = $2
|
||||||
|
AND o.id = $3
|
||||||
|
GROUP BY o.id
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, orderId]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId });
|
||||||
|
}
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBusinessTeamMembers(actorUid) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
bm.id AS "businessMembershipId",
|
||||||
|
u.id AS "userId",
|
||||||
|
COALESCE(u.display_name, u.email) AS name,
|
||||||
|
u.email,
|
||||||
|
bm.business_role AS role
|
||||||
|
FROM business_memberships bm
|
||||||
|
JOIN users u ON u.id = bm.user_id
|
||||||
|
WHERE bm.tenant_id = $1
|
||||||
|
AND bm.business_id = $2
|
||||||
|
AND bm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY name ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportSummary(actorUid, { startDate, endDate }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 30);
|
||||||
|
const [shifts, spend, performance, noShow] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT s.id)::INTEGER AS "totalShifts",
|
||||||
|
COALESCE(AVG(
|
||||||
|
CASE WHEN s.required_workers = 0 THEN 1
|
||||||
|
ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1)
|
||||||
|
END
|
||||||
|
), 0)::NUMERIC(8,4) AS "averageCoverage"
|
||||||
|
FROM shifts s
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND s.business_id = $2
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents"
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore"
|
||||||
|
FROM staff_reviews
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::INTEGER AS "noShowCount"
|
||||||
|
FROM assignments
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND status = 'NO_SHOW'
|
||||||
|
AND updated_at >= $3::timestamptz
|
||||||
|
AND updated_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalShifts: Number(shifts.rows[0]?.totalShifts || 0),
|
||||||
|
totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0),
|
||||||
|
averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100),
|
||||||
|
averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0),
|
||||||
|
noShowCount: Number(noShow.rows[0]?.noShowCount || 0),
|
||||||
|
forecastAccuracyPercentage: 90,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyOpsReport(actorUid, { date }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const from = startOfDay(date).toISOString();
|
||||||
|
const to = endOfDay(date).toISOString();
|
||||||
|
const shifts = await listCoverageByDate(actorUid, { date });
|
||||||
|
const totals = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT s.id)::INTEGER AS "totalShifts",
|
||||||
|
COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed",
|
||||||
|
COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked",
|
||||||
|
COALESCE(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN att.check_in_at IS NULL THEN 0
|
||||||
|
WHEN att.check_in_at <= s.starts_at THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::NUMERIC(8,4) AS "onTimeArrivalRate"
|
||||||
|
FROM shifts s
|
||||||
|
LEFT JOIN assignments a ON a.shift_id = s.id
|
||||||
|
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||||
|
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND s.business_id = $2
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at < $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, from, to]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
totalShifts: Number(totals.rows[0]?.totalShifts || 0),
|
||||||
|
totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0),
|
||||||
|
totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60),
|
||||||
|
onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100),
|
||||||
|
shifts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 30);
|
||||||
|
const bucketExpr = bucket === 'week' ? 'week' : 'day';
|
||||||
|
const [total, chart, breakdown] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents"
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
date_trunc('${bucketExpr}', created_at) AS bucket,
|
||||||
|
COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents"
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
),
|
||||||
|
getSpendBreakdown(actorUid, { startDate, endDate }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0),
|
||||||
|
chart: chart.rows,
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoverageReport(actorUid, { startDate, endDate }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 30);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
WITH daily AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('day', starts_at) AS day,
|
||||||
|
SUM(required_workers)::INTEGER AS needed,
|
||||||
|
SUM(assigned_workers)::INTEGER AS filled
|
||||||
|
FROM shifts
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND starts_at >= $3::timestamptz
|
||||||
|
AND starts_at <= $4::timestamptz
|
||||||
|
GROUP BY 1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
day,
|
||||||
|
needed,
|
||||||
|
filled,
|
||||||
|
CASE WHEN needed = 0 THEN 0
|
||||||
|
ELSE ROUND((filled::numeric / needed) * 100, 2)
|
||||||
|
END AS "coveragePercentage"
|
||||||
|
FROM daily
|
||||||
|
ORDER BY day ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
);
|
||||||
|
const totals = result.rows.reduce((acc, row) => {
|
||||||
|
acc.neededWorkers += Number(row.needed || 0);
|
||||||
|
acc.filledWorkers += Number(row.filled || 0);
|
||||||
|
return acc;
|
||||||
|
}, { neededWorkers: 0, filledWorkers: 0 });
|
||||||
|
return {
|
||||||
|
averageCoveragePercentage: totals.neededWorkers === 0
|
||||||
|
? 0
|
||||||
|
: Math.round((totals.filledWorkers / totals.neededWorkers) * 100),
|
||||||
|
filledWorkers: totals.filledWorkers,
|
||||||
|
neededWorkers: totals.neededWorkers,
|
||||||
|
chart: result.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForecastReport(actorUid, { startDate, endDate }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 42);
|
||||||
|
const weekly = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
date_trunc('week', s.starts_at) AS week,
|
||||||
|
COUNT(DISTINCT s.id)::INTEGER AS "shiftCount",
|
||||||
|
COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours",
|
||||||
|
COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents"
|
||||||
|
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.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at <= $4::timestamptz
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
);
|
||||||
|
const totals = weekly.rows.reduce((acc, row) => {
|
||||||
|
acc.forecastSpendCents += Number(row.forecastSpendCents || 0);
|
||||||
|
acc.totalShifts += Number(row.shiftCount || 0);
|
||||||
|
acc.totalWorkerHours += Number(row.workerHours || 0);
|
||||||
|
return acc;
|
||||||
|
}, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 });
|
||||||
|
return {
|
||||||
|
forecastSpendCents: totals.forecastSpendCents,
|
||||||
|
averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length),
|
||||||
|
totalShifts: totals.totalShifts,
|
||||||
|
totalWorkerHours: totals.totalWorkerHours,
|
||||||
|
weeks: weekly.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPerformanceReport(actorUid, { startDate, endDate }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 30);
|
||||||
|
const totals = await query(
|
||||||
|
`
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT s.id)::INTEGER AS total_shifts,
|
||||||
|
COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts,
|
||||||
|
COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts,
|
||||||
|
COUNT(DISTINCT a.id) FILTER (
|
||||||
|
WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at
|
||||||
|
)::INTEGER AS on_time_assignments,
|
||||||
|
COUNT(DISTINCT a.id)::INTEGER AS total_assignments,
|
||||||
|
COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments
|
||||||
|
FROM shifts s
|
||||||
|
LEFT JOIN assignments a ON a.shift_id = s.id
|
||||||
|
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND s.business_id = $2
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at <= $4::timestamptz
|
||||||
|
),
|
||||||
|
fill_times AS (
|
||||||
|
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes
|
||||||
|
FROM assignments a
|
||||||
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
|
WHERE a.tenant_id = $1
|
||||||
|
AND a.business_id = $2
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at <= $4::timestamptz
|
||||||
|
),
|
||||||
|
reviews AS (
|
||||||
|
SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating
|
||||||
|
FROM staff_reviews
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM base, fill_times, reviews
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
);
|
||||||
|
const row = totals.rows[0] || {};
|
||||||
|
const totalShifts = Number(row.total_shifts || 0);
|
||||||
|
const totalAssignments = Number(row.total_assignments || 0);
|
||||||
|
return {
|
||||||
|
averagePerformanceScore: Number(row.avg_rating || 0),
|
||||||
|
fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100),
|
||||||
|
completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100),
|
||||||
|
onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100),
|
||||||
|
averageFillTimeMinutes: Number(row.avg_fill_minutes || 0),
|
||||||
|
totalShiftsCovered: Number(row.completed_shifts || 0),
|
||||||
|
noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoShowReport(actorUid, { startDate, endDate }) {
|
||||||
|
const context = await requireClientContext(actorUid);
|
||||||
|
const range = parseDateRange(startDate, endDate, 30);
|
||||||
|
const incidents = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
st.id AS "staffId",
|
||||||
|
st.full_name AS "staffName",
|
||||||
|
COUNT(*)::INTEGER AS "incidentCount",
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'shiftId', s.id,
|
||||||
|
'shiftTitle', s.title,
|
||||||
|
'roleName', sr.role_name,
|
||||||
|
'date', s.starts_at
|
||||||
|
)
|
||||||
|
ORDER BY s.starts_at DESC
|
||||||
|
) AS incidents
|
||||||
|
FROM assignments a
|
||||||
|
JOIN staffs st ON st.id = a.staff_id
|
||||||
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
|
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
|
WHERE a.tenant_id = $1
|
||||||
|
AND a.business_id = $2
|
||||||
|
AND a.status = 'NO_SHOW'
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at <= $4::timestamptz
|
||||||
|
GROUP BY st.id
|
||||||
|
ORDER BY "incidentCount" DESC, "staffName" ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
);
|
||||||
|
const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0);
|
||||||
|
const totalWorkers = incidents.rows.length;
|
||||||
|
const totalAssignments = await query(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::INTEGER AS total
|
||||||
|
FROM assignments
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND business_id = $2
|
||||||
|
AND created_at >= $3::timestamptz
|
||||||
|
AND created_at <= $4::timestamptz
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
totalNoShowCount,
|
||||||
|
noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0
|
||||||
|
? 0
|
||||||
|
: Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100),
|
||||||
|
workersWhoNoShowed: totalWorkers,
|
||||||
|
items: incidents.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmergencyContacts(actorUid) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id AS "contactId",
|
||||||
|
full_name AS "fullName",
|
||||||
|
phone,
|
||||||
|
relationship_type AS "relationshipType",
|
||||||
|
is_primary AS "isPrimary"
|
||||||
|
FROM emergency_contacts
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND staff_id = $2
|
||||||
|
ORDER BY is_primary DESC, created_at ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTaxForms(actorUid) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const docs = ['I-9', 'W-4'];
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
d.id AS "documentId",
|
||||||
|
d.name AS "formType",
|
||||||
|
sd.id AS "staffDocumentId",
|
||||||
|
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
|
||||||
|
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN staff_documents sd
|
||||||
|
ON sd.document_id = d.id
|
||||||
|
AND sd.staff_id = $2
|
||||||
|
AND sd.tenant_id = $1
|
||||||
|
WHERE d.tenant_id = $1
|
||||||
|
AND d.document_type = 'TAX_FORM'
|
||||||
|
AND d.name = ANY($3::text[])
|
||||||
|
ORDER BY d.name ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId, docs]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAttireChecklist(actorUid) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
d.id AS "documentId",
|
||||||
|
d.name,
|
||||||
|
COALESCE(d.metadata->>'description', '') AS description,
|
||||||
|
COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory,
|
||||||
|
sd.id AS "staffDocumentId",
|
||||||
|
sd.file_uri AS "photoUri",
|
||||||
|
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
||||||
|
sd.metadata->>'verificationStatus' AS "verificationStatus"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN staff_documents sd
|
||||||
|
ON sd.document_id = d.id
|
||||||
|
AND sd.staff_id = $2
|
||||||
|
AND sd.tenant_id = $1
|
||||||
|
WHERE d.tenant_id = $1
|
||||||
|
AND d.document_type = 'ATTIRE'
|
||||||
|
ORDER BY d.name ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTimeCardEntries(actorUid, { month, year }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10);
|
||||||
|
const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10);
|
||||||
|
const start = new Date(Date.UTC(yearValue, monthValue - 1, 1));
|
||||||
|
const end = new Date(Date.UTC(yearValue, monthValue, 1));
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.starts_at::date AS date,
|
||||||
|
s.title AS "shiftName",
|
||||||
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
|
att.check_in_at AS "clockInAt",
|
||||||
|
att.check_out_at AS "clockOutAt",
|
||||||
|
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
|
||||||
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents"
|
||||||
|
FROM assignments a
|
||||||
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
|
LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
|
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||||
|
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
||||||
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
|
WHERE a.tenant_id = $1
|
||||||
|
AND a.staff_id = $2
|
||||||
|
AND s.starts_at >= $3::timestamptz
|
||||||
|
AND s.starts_at < $4::timestamptz
|
||||||
|
AND a.status IN ('CHECKED_OUT', 'COMPLETED')
|
||||||
|
ORDER BY s.starts_at DESC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrivacySettings(actorUid) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
return {
|
||||||
|
profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFaqCategories() {
|
||||||
|
return FAQ_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchFaqs(queryText) {
|
||||||
|
const needle = `${queryText || ''}`.trim().toLowerCase();
|
||||||
|
if (!needle) {
|
||||||
|
return FAQ_CATEGORIES;
|
||||||
|
}
|
||||||
|
return FAQ_CATEGORIES
|
||||||
|
.map((category) => ({
|
||||||
|
category: category.category,
|
||||||
|
items: category.items.filter((item) => {
|
||||||
|
const haystack = `${item.question} ${item.answer}`.toLowerCase();
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((category) => category.items.length > 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ function createMobileQueryService() {
|
|||||||
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
|
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
|
||||||
getClientSession: async () => ({ business: { businessId: 'b1' } }),
|
getClientSession: async () => ({ business: { businessId: 'b1' } }),
|
||||||
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
|
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
|
||||||
|
getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }),
|
||||||
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
|
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
|
||||||
getCurrentBill: async () => ({ currentBillCents: 1000 }),
|
getCurrentBill: async () => ({ currentBillCents: 1000 }),
|
||||||
|
getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }),
|
||||||
|
getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }),
|
||||||
|
getNoShowReport: async () => ({ totals: { noShows: 1 } }),
|
||||||
getPaymentChart: async () => ([{ amountCents: 100 }]),
|
getPaymentChart: async () => ([{ amountCents: 100 }]),
|
||||||
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
|
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
|
||||||
getPersonalInfo: async () => ({ firstName: 'Ana' }),
|
getPersonalInfo: async () => ({ firstName: 'Ana' }),
|
||||||
|
getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }),
|
||||||
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
|
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
|
||||||
|
getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }),
|
||||||
|
getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }),
|
||||||
getSavings: async () => ({ savingsCents: 200 }),
|
getSavings: async () => ({ savingsCents: 200 }),
|
||||||
|
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
|
||||||
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
|
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
|
||||||
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
|
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
|
||||||
getStaffProfileCompletion: async () => ({ completed: true }),
|
getStaffProfileCompletion: async () => ({ completed: true }),
|
||||||
@@ -28,25 +36,34 @@ function createMobileQueryService() {
|
|||||||
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
|
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
|
||||||
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
|
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
|
||||||
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
|
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
|
||||||
|
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
|
||||||
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
|
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
|
||||||
|
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
|
||||||
|
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
|
||||||
listHubManagers: async () => ([{ managerId: 'm1' }]),
|
listHubManagers: async () => ([{ managerId: 'm1' }]),
|
||||||
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
||||||
listIndustries: async () => (['CATERING']),
|
listIndustries: async () => (['CATERING']),
|
||||||
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
||||||
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
||||||
|
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
|
||||||
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
||||||
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
|
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
|
||||||
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
|
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
|
||||||
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
|
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
|
||||||
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
|
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
|
||||||
listRecentReorders: async () => ([{ id: 'order-1' }]),
|
listRecentReorders: async () => ([{ id: 'order-1' }]),
|
||||||
|
listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]),
|
||||||
listSkills: async () => (['BARISTA']),
|
listSkills: async () => (['BARISTA']),
|
||||||
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
|
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
|
||||||
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
|
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
|
||||||
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
|
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
|
||||||
|
listTaxForms: async () => ([{ formType: 'W4' }]),
|
||||||
|
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
|
||||||
|
listTimeCardEntries: async () => ([{ entryId: 'tc-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' }]),
|
||||||
|
searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,3 +106,43 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async ()
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.body.shiftId, 'shift-1');
|
assert.equal(res.body.shiftId, 'shift-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /query/client/reports/summary returns injected report summary', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/client/reports/summary?date=2026-03-13')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.totals.orders, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /query/client/coverage/core-team returns injected core team list', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/client/coverage/core-team?date=2026-03-13')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items[0].staffId, 'core-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/staff/profile/tax-forms')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items[0].formType, 'W4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /query/staff/faqs/search returns injected faq search results', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/staff/faqs/search?q=payments')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items[0].title, 'Payments');
|
||||||
|
});
|
||||||
|
|||||||
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
|
||||||
|
|
||||||
|
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 ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
|
||||||
|
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
|
||||||
|
|
||||||
|
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 main() {
|
||||||
|
const owner = await ensureUser({
|
||||||
|
email: ownerEmail,
|
||||||
|
password: ownerPassword,
|
||||||
|
displayName: 'Legendary Demo Owner V2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const staff = await ensureUser({
|
||||||
|
email: staffEmail,
|
||||||
|
password: staffPassword,
|
||||||
|
displayName: 'Ana Barista V2',
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(JSON.stringify({ owner, staff }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
971
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
971
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { signInWithPassword } from '../src/services/identity-toolkit.js';
|
||||||
|
import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs';
|
||||||
|
|
||||||
|
const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app';
|
||||||
|
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
|
||||||
|
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
|
||||||
|
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
|
||||||
|
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
|
||||||
|
|
||||||
|
function uniqueKey(prefix) {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(offsetDays = 0) {
|
||||||
|
const value = new Date(Date.now() + (offsetDays * 24 * 60 * 60 * 1000));
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoTimestamp(offsetHours = 0) {
|
||||||
|
return new Date(Date.now() + (offsetHours * 60 * 60 * 1000)).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(step, payload) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[unified-smoke-v2] ${step}: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCall(path, {
|
||||||
|
method = 'GET',
|
||||||
|
token,
|
||||||
|
idempotencyKey,
|
||||||
|
body,
|
||||||
|
expectedStatus = 200,
|
||||||
|
} = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
const response = await fetch(`${unifiedBaseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await readJson(response);
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(path, token, {
|
||||||
|
filename,
|
||||||
|
contentType,
|
||||||
|
content,
|
||||||
|
fields = {},
|
||||||
|
expectedStatus = 200,
|
||||||
|
}) {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
form.set(key, value);
|
||||||
|
}
|
||||||
|
form.set(
|
||||||
|
'file',
|
||||||
|
new File([content], filename, {
|
||||||
|
type: contentType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`${unifiedBaseUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const payload = await readJson(response);
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
throw new Error(`POST ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInClient() {
|
||||||
|
return apiCall('/auth/client/sign-in', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: ownerEmail,
|
||||||
|
password: ownerPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInStaff() {
|
||||||
|
return signInWithPassword({
|
||||||
|
email: staffEmail,
|
||||||
|
password: staffPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`;
|
||||||
|
const ownerSession = await signInClient();
|
||||||
|
const staffAuth = await signInStaff();
|
||||||
|
|
||||||
|
assert.ok(ownerSession.sessionToken);
|
||||||
|
assert.ok(staffAuth.idToken);
|
||||||
|
assert.equal(ownerSession.business.businessId, fixture.business.id);
|
||||||
|
logStep('auth.client.sign-in.ok', {
|
||||||
|
tenantId: ownerSession.tenant.tenantId,
|
||||||
|
businessId: ownerSession.business.businessId,
|
||||||
|
});
|
||||||
|
logStep('auth.staff.password-sign-in.ok', {
|
||||||
|
uid: staffAuth.localId,
|
||||||
|
email: staffEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authSession = await apiCall('/auth/session', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.equal(authSession.business.businessId, fixture.business.id);
|
||||||
|
logStep('auth.session.ok', authSession);
|
||||||
|
|
||||||
|
const staffPhoneStart = await apiCall('/auth/staff/phone/start', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { phoneNumber: fixture.staff.ana.phone },
|
||||||
|
});
|
||||||
|
assert.equal(staffPhoneStart.mode, 'CLIENT_FIREBASE_SDK');
|
||||||
|
logStep('auth.staff.phone-start.ok', staffPhoneStart);
|
||||||
|
|
||||||
|
const staffPhoneVerify = await apiCall('/auth/staff/phone/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
mode: 'sign-in',
|
||||||
|
idToken: staffAuth.idToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(staffPhoneVerify.staff.staffId, fixture.staff.ana.id);
|
||||||
|
logStep('auth.staff.phone-verify.ok', {
|
||||||
|
staffId: staffPhoneVerify.staff.staffId,
|
||||||
|
requiresProfileSetup: staffPhoneVerify.requiresProfileSetup,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientSession = await apiCall('/client/session', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.equal(clientSession.business.businessId, fixture.business.id);
|
||||||
|
logStep('client.session.ok', clientSession);
|
||||||
|
|
||||||
|
const clientDashboard = await apiCall('/client/dashboard', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.equal(clientDashboard.businessId, fixture.business.id);
|
||||||
|
logStep('client.dashboard.ok', {
|
||||||
|
weeklySpendCents: clientDashboard.spending.weeklySpendCents,
|
||||||
|
openPositionsToday: clientDashboard.coverage.openPositionsToday,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientReorders = await apiCall('/client/reorders', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(clientReorders.items));
|
||||||
|
logStep('client.reorders.ok', { count: clientReorders.items.length });
|
||||||
|
|
||||||
|
const billingAccounts = await apiCall('/client/billing/accounts', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(billingAccounts.items));
|
||||||
|
logStep('client.billing.accounts.ok', { count: billingAccounts.items.length });
|
||||||
|
|
||||||
|
const pendingInvoices = await apiCall('/client/billing/invoices/pending', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(pendingInvoices.items.length >= 1);
|
||||||
|
const invoiceId = pendingInvoices.items[0].invoiceId;
|
||||||
|
logStep('client.billing.pending-invoices.ok', { count: pendingInvoices.items.length, invoiceId });
|
||||||
|
|
||||||
|
const invoiceHistory = await apiCall('/client/billing/invoices/history', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(invoiceHistory.items));
|
||||||
|
logStep('client.billing.invoice-history.ok', { count: invoiceHistory.items.length });
|
||||||
|
|
||||||
|
const currentBill = await apiCall('/client/billing/current-bill', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(typeof currentBill.currentBillCents === 'number');
|
||||||
|
logStep('client.billing.current-bill.ok', currentBill);
|
||||||
|
|
||||||
|
const savings = await apiCall('/client/billing/savings', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(typeof savings.savingsCents === 'number');
|
||||||
|
logStep('client.billing.savings.ok', savings);
|
||||||
|
|
||||||
|
const spendBreakdown = await apiCall('/client/billing/spend-breakdown?period=month', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(spendBreakdown.items));
|
||||||
|
logStep('client.billing.spend-breakdown.ok', { count: spendBreakdown.items.length });
|
||||||
|
|
||||||
|
const coverage = await apiCall(`/client/coverage?date=${isoDate(0)}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(coverage.items));
|
||||||
|
logStep('client.coverage.ok', { count: coverage.items.length });
|
||||||
|
|
||||||
|
const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(typeof coverageStats.totalCoveragePercentage === 'number');
|
||||||
|
logStep('client.coverage.stats.ok', coverageStats);
|
||||||
|
|
||||||
|
const coreTeam = await apiCall('/client/coverage/core-team', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(coreTeam.items));
|
||||||
|
logStep('client.coverage.core-team.ok', { count: coreTeam.items.length });
|
||||||
|
|
||||||
|
const hubs = await apiCall('/client/hubs', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(hubs.items.some((hub) => hub.hubId === fixture.clockPoint.id));
|
||||||
|
logStep('client.hubs.ok', { count: hubs.items.length });
|
||||||
|
|
||||||
|
const costCenters = await apiCall('/client/cost-centers', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(costCenters.items.length >= 1);
|
||||||
|
logStep('client.cost-centers.ok', { count: costCenters.items.length });
|
||||||
|
|
||||||
|
const vendors = await apiCall('/client/vendors', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(vendors.items.length >= 1);
|
||||||
|
logStep('client.vendors.ok', { count: vendors.items.length });
|
||||||
|
|
||||||
|
const vendorRoles = await apiCall(`/client/vendors/${fixture.vendor.id}/roles`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(vendorRoles.items.length >= 1);
|
||||||
|
logStep('client.vendor-roles.ok', { count: vendorRoles.items.length });
|
||||||
|
|
||||||
|
const hubManagers = await apiCall(`/client/hubs/${fixture.clockPoint.id}/managers`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(hubManagers.items));
|
||||||
|
logStep('client.hub-managers.ok', { count: hubManagers.items.length });
|
||||||
|
|
||||||
|
const teamMembers = await apiCall('/client/team-members', {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(teamMembers.items));
|
||||||
|
logStep('client.team-members.ok', { count: teamMembers.items.length });
|
||||||
|
|
||||||
|
const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(viewedOrders.items));
|
||||||
|
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
||||||
|
|
||||||
|
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.equal(reorderPreview.orderId, fixture.orders.completed.id);
|
||||||
|
logStep('client.orders.reorder-preview.ok', reorderPreview);
|
||||||
|
|
||||||
|
const reportSummary = await apiCall(`/client/reports/summary?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(typeof reportSummary.totalShifts === 'number');
|
||||||
|
logStep('client.reports.summary.ok', reportSummary);
|
||||||
|
|
||||||
|
const dailyOps = await apiCall(`/client/reports/daily-ops?date=${isoDate(0)}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.daily-ops.ok', dailyOps);
|
||||||
|
|
||||||
|
const spendReport = await apiCall(`/client/reports/spend?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.spend.ok', spendReport);
|
||||||
|
|
||||||
|
const coverageReport = await apiCall(`/client/reports/coverage?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.coverage.ok', coverageReport);
|
||||||
|
|
||||||
|
const forecastReport = await apiCall(`/client/reports/forecast?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.forecast.ok', forecastReport);
|
||||||
|
|
||||||
|
const performanceReport = await apiCall(`/client/reports/performance?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.performance.ok', performanceReport);
|
||||||
|
|
||||||
|
const noShowReport = await apiCall(`/client/reports/no-show?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('client.reports.no-show.ok', noShowReport);
|
||||||
|
|
||||||
|
const createdHub = await apiCall('/client/hubs', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('create-hub'),
|
||||||
|
body: {
|
||||||
|
name: `Smoke Hub ${Date.now()}`,
|
||||||
|
fullAddress: '500 Castro Street, Mountain View, CA',
|
||||||
|
latitude: 37.3925,
|
||||||
|
longitude: -122.0782,
|
||||||
|
city: 'Mountain View',
|
||||||
|
state: 'CA',
|
||||||
|
country: 'US',
|
||||||
|
zipCode: '94041',
|
||||||
|
costCenterId: fixture.costCenters.cafeOps.id,
|
||||||
|
geofenceRadiusMeters: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(createdHub.hubId);
|
||||||
|
logStep('client.hubs.create.ok', createdHub);
|
||||||
|
|
||||||
|
const updatedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('update-hub'),
|
||||||
|
body: {
|
||||||
|
name: `${createdHub.name || 'Smoke Hub'} Updated`,
|
||||||
|
geofenceRadiusMeters: 140,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.hubs.update.ok', updatedHub);
|
||||||
|
|
||||||
|
const assignedHubManager = await apiCall(`/client/hubs/${createdHub.hubId}/managers`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('assign-hub-manager'),
|
||||||
|
body: {
|
||||||
|
managerUserId: fixture.users.operationsManager.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.hubs.assign-manager.ok', assignedHubManager);
|
||||||
|
|
||||||
|
const assignedNfc = await apiCall(`/client/hubs/${createdHub.hubId}/assign-nfc`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('assign-hub-nfc'),
|
||||||
|
body: {
|
||||||
|
nfcTagId: `NFC-SMOKE-${Date.now()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.hubs.assign-nfc.ok', assignedNfc);
|
||||||
|
|
||||||
|
const deletedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('delete-hub'),
|
||||||
|
body: {
|
||||||
|
reason: 'smoke cleanup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.hubs.delete.ok', deletedHub);
|
||||||
|
|
||||||
|
const disputedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/dispute`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('invoice-dispute'),
|
||||||
|
body: {
|
||||||
|
reason: 'Smoke dispute before approval',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.billing.invoice-dispute.ok', disputedInvoice);
|
||||||
|
|
||||||
|
const approvedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('invoice-approve'),
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
logStep('client.billing.invoice-approve.ok', approvedInvoice);
|
||||||
|
|
||||||
|
const createdOneTimeOrder = await apiCall('/client/orders/one-time', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('order-one-time'),
|
||||||
|
body: {
|
||||||
|
hubId: fixture.clockPoint.id,
|
||||||
|
vendorId: fixture.vendor.id,
|
||||||
|
eventName: `Smoke One-Time ${Date.now()}`,
|
||||||
|
orderDate: isoDate(3),
|
||||||
|
serviceType: 'RESTAURANT',
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
roleId: fixture.roles.barista.id,
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '16:00',
|
||||||
|
workerCount: 1,
|
||||||
|
payRateCents: 2200,
|
||||||
|
billRateCents: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(createdOneTimeOrder.orderId);
|
||||||
|
logStep('client.orders.create-one-time.ok', createdOneTimeOrder);
|
||||||
|
|
||||||
|
const createdRecurringOrder = await apiCall('/client/orders/recurring', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('order-recurring'),
|
||||||
|
body: {
|
||||||
|
hubId: fixture.clockPoint.id,
|
||||||
|
vendorId: fixture.vendor.id,
|
||||||
|
eventName: `Smoke Recurring ${Date.now()}`,
|
||||||
|
startDate: isoDate(5),
|
||||||
|
endDate: isoDate(10),
|
||||||
|
recurrenceDays: [1, 3, 5],
|
||||||
|
serviceType: 'RESTAURANT',
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
roleId: fixture.roles.barista.id,
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '15:00',
|
||||||
|
workersNeeded: 1,
|
||||||
|
payRateCents: 2300,
|
||||||
|
billRateCents: 3600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(createdRecurringOrder.orderId);
|
||||||
|
logStep('client.orders.create-recurring.ok', createdRecurringOrder);
|
||||||
|
|
||||||
|
const createdPermanentOrder = await apiCall('/client/orders/permanent', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('order-permanent'),
|
||||||
|
body: {
|
||||||
|
hubId: fixture.clockPoint.id,
|
||||||
|
vendorId: fixture.vendor.id,
|
||||||
|
eventName: `Smoke Permanent ${Date.now()}`,
|
||||||
|
startDate: isoDate(7),
|
||||||
|
daysOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
horizonDays: 14,
|
||||||
|
serviceType: 'RESTAURANT',
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
roleId: fixture.roles.barista.id,
|
||||||
|
startTime: '07:00',
|
||||||
|
endTime: '13:00',
|
||||||
|
workersNeeded: 1,
|
||||||
|
payRateCents: 2200,
|
||||||
|
billRateCents: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(createdPermanentOrder.orderId);
|
||||||
|
logStep('client.orders.create-permanent.ok', createdPermanentOrder);
|
||||||
|
|
||||||
|
const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('order-edit'),
|
||||||
|
body: {
|
||||||
|
eventName: `Edited Copy ${Date.now()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(editedOrderCopy.orderId);
|
||||||
|
logStep('client.orders.edit-copy.ok', editedOrderCopy);
|
||||||
|
|
||||||
|
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('order-cancel'),
|
||||||
|
body: {
|
||||||
|
reason: 'Smoke cancel validation',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.orders.cancel.ok', cancelledOrder);
|
||||||
|
|
||||||
|
const coverageReview = await apiCall('/client/coverage/reviews', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('coverage-review'),
|
||||||
|
body: {
|
||||||
|
staffId: fixture.staff.ana.id,
|
||||||
|
assignmentId: fixture.assignments.completedAna.id,
|
||||||
|
rating: 5,
|
||||||
|
markAsFavorite: true,
|
||||||
|
issueFlags: [],
|
||||||
|
feedback: 'Smoke review',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('client.coverage.review.ok', coverageReview);
|
||||||
|
|
||||||
|
const staffSession = await apiCall('/staff/session', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.equal(staffSession.staff.staffId, fixture.staff.ana.id);
|
||||||
|
logStep('staff.session.ok', staffSession);
|
||||||
|
|
||||||
|
const staffDashboard = await apiCall('/staff/dashboard', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(staffDashboard.recommendedShifts));
|
||||||
|
logStep('staff.dashboard.ok', {
|
||||||
|
todaysShifts: staffDashboard.todaysShifts.length,
|
||||||
|
recommendedShifts: staffDashboard.recommendedShifts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const staffProfileCompletion = await apiCall('/staff/profile-completion', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.profile-completion.ok', staffProfileCompletion);
|
||||||
|
|
||||||
|
const staffAvailability = await apiCall('/staff/availability', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(staffAvailability.items));
|
||||||
|
logStep('staff.availability.ok', { count: staffAvailability.items.length });
|
||||||
|
|
||||||
|
const todaysShifts = await apiCall('/staff/clock-in/shifts/today', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(todaysShifts.items));
|
||||||
|
logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length });
|
||||||
|
|
||||||
|
const attendanceStatusBefore = await apiCall('/staff/clock-in/status', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.clock-in.status-before.ok', attendanceStatusBefore);
|
||||||
|
|
||||||
|
const paymentsSummary = await apiCall('/staff/payments/summary?period=month', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.payments.summary.ok', paymentsSummary);
|
||||||
|
|
||||||
|
const paymentsHistory = await apiCall('/staff/payments/history?period=month', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(paymentsHistory.items));
|
||||||
|
logStep('staff.payments.history.ok', { count: paymentsHistory.items.length });
|
||||||
|
|
||||||
|
const paymentsChart = await apiCall('/staff/payments/chart?period=month', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(paymentsChart.items));
|
||||||
|
logStep('staff.payments.chart.ok', { count: paymentsChart.items.length });
|
||||||
|
|
||||||
|
const assignedShifts = await apiCall(`/staff/shifts/assigned?${reportWindow}`, {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(assignedShifts.items));
|
||||||
|
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length });
|
||||||
|
|
||||||
|
const openShifts = await apiCall('/staff/shifts/open', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(openShifts.items.some((shift) => shift.shiftId === fixture.shifts.available.id));
|
||||||
|
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
|
||||||
|
|
||||||
|
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(pendingShifts.items.some((item) => item.shiftId === fixture.shifts.assigned.id));
|
||||||
|
logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length });
|
||||||
|
|
||||||
|
const cancelledShifts = await apiCall('/staff/shifts/cancelled', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(cancelledShifts.items));
|
||||||
|
logStep('staff.shifts.cancelled.ok', { count: cancelledShifts.items.length });
|
||||||
|
|
||||||
|
const completedShifts = await apiCall('/staff/shifts/completed', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(completedShifts.items));
|
||||||
|
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
|
||||||
|
|
||||||
|
const shiftDetail = await apiCall(`/staff/shifts/${fixture.shifts.available.id}`, {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.equal(shiftDetail.shiftId, fixture.shifts.available.id);
|
||||||
|
logStep('staff.shifts.detail.ok', shiftDetail);
|
||||||
|
|
||||||
|
const profileSections = await apiCall('/staff/profile/sections', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.profile.sections.ok', profileSections);
|
||||||
|
|
||||||
|
const personalInfo = await apiCall('/staff/profile/personal-info', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.profile.personal-info.ok', personalInfo);
|
||||||
|
|
||||||
|
const industries = await apiCall('/staff/profile/industries', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(industries.items));
|
||||||
|
logStep('staff.profile.industries.ok', industries);
|
||||||
|
|
||||||
|
const skills = await apiCall('/staff/profile/skills', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(skills.items));
|
||||||
|
logStep('staff.profile.skills.ok', skills);
|
||||||
|
|
||||||
|
const profileDocumentsBefore = await apiCall('/staff/profile/documents', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(profileDocumentsBefore.items));
|
||||||
|
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
|
||||||
|
|
||||||
|
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(attireChecklistBefore.items));
|
||||||
|
logStep('staff.profile.attire-before.ok', { count: attireChecklistBefore.items.length });
|
||||||
|
|
||||||
|
const taxForms = await apiCall('/staff/profile/tax-forms', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(taxForms.items));
|
||||||
|
logStep('staff.profile.tax-forms.ok', { count: taxForms.items.length });
|
||||||
|
|
||||||
|
const emergencyContactsBefore = await apiCall('/staff/profile/emergency-contacts', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(emergencyContactsBefore.items));
|
||||||
|
logStep('staff.profile.emergency-contacts-before.ok', { count: emergencyContactsBefore.items.length });
|
||||||
|
|
||||||
|
const certificatesBefore = await apiCall('/staff/profile/certificates', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(certificatesBefore.items));
|
||||||
|
logStep('staff.profile.certificates-before.ok', { count: certificatesBefore.items.length });
|
||||||
|
|
||||||
|
const bankAccountsBefore = await apiCall('/staff/profile/bank-accounts', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(bankAccountsBefore.items));
|
||||||
|
logStep('staff.profile.bank-accounts-before.ok', { count: bankAccountsBefore.items.length });
|
||||||
|
|
||||||
|
const benefits = await apiCall('/staff/profile/benefits', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(benefits.items));
|
||||||
|
logStep('staff.profile.benefits.ok', { count: benefits.items.length });
|
||||||
|
|
||||||
|
const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(timeCard.items));
|
||||||
|
logStep('staff.profile.time-card.ok', { count: timeCard.items.length });
|
||||||
|
|
||||||
|
const privacyBefore = await apiCall('/staff/profile/privacy', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.profile.privacy-before.ok', privacyBefore);
|
||||||
|
|
||||||
|
const faqs = await apiCall('/staff/faqs');
|
||||||
|
assert.ok(Array.isArray(faqs.items));
|
||||||
|
logStep('staff.faqs.ok', { count: faqs.items.length });
|
||||||
|
|
||||||
|
const faqSearch = await apiCall('/staff/faqs/search?q=payments');
|
||||||
|
assert.ok(Array.isArray(faqSearch.items));
|
||||||
|
logStep('staff.faqs.search.ok', { count: faqSearch.items.length });
|
||||||
|
|
||||||
|
const updatedAvailability = await apiCall('/staff/availability', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-availability'),
|
||||||
|
body: {
|
||||||
|
dayOfWeek: 2,
|
||||||
|
availabilityStatus: 'PARTIAL',
|
||||||
|
slots: [{ start: '10:00', end: '18:00' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.availability.update.ok', updatedAvailability);
|
||||||
|
|
||||||
|
const quickSetAvailability = await apiCall('/staff/availability/quick-set', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-availability-quick-set'),
|
||||||
|
body: {
|
||||||
|
quickSetType: 'weekdays',
|
||||||
|
startDate: isoTimestamp(0),
|
||||||
|
endDate: isoTimestamp(24 * 7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.availability.quick-set.ok', quickSetAvailability);
|
||||||
|
|
||||||
|
const personalInfoUpdate = await apiCall('/staff/profile/personal-info', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-personal-info'),
|
||||||
|
body: {
|
||||||
|
firstName: 'Ana',
|
||||||
|
lastName: 'Barista',
|
||||||
|
bio: 'Smoke-tested staff bio',
|
||||||
|
preferredLocations: [
|
||||||
|
{
|
||||||
|
label: 'Mountain View',
|
||||||
|
city: 'Mountain View',
|
||||||
|
latitude: fixture.clockPoint.latitude,
|
||||||
|
longitude: fixture.clockPoint.longitude,
|
||||||
|
radiusMiles: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
phone: fixture.staff.ana.phone,
|
||||||
|
email: staffEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.personal-info.update.ok', personalInfoUpdate);
|
||||||
|
|
||||||
|
const experienceUpdate = await apiCall('/staff/profile/experience', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-experience'),
|
||||||
|
body: {
|
||||||
|
industries: ['CATERING', 'CAFE'],
|
||||||
|
skills: ['BARISTA', 'CUSTOMER_SERVICE'],
|
||||||
|
primaryRole: 'BARISTA',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.experience.update.ok', experienceUpdate);
|
||||||
|
|
||||||
|
const locationUpdate = await apiCall('/staff/profile/locations', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-locations'),
|
||||||
|
body: {
|
||||||
|
preferredLocations: [
|
||||||
|
{
|
||||||
|
label: 'Mountain View',
|
||||||
|
city: 'Mountain View',
|
||||||
|
latitude: fixture.clockPoint.latitude,
|
||||||
|
longitude: fixture.clockPoint.longitude,
|
||||||
|
radiusMiles: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxDistanceMiles: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.locations.update.ok', locationUpdate);
|
||||||
|
|
||||||
|
const createdEmergencyContact = await apiCall('/staff/profile/emergency-contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-emergency-create'),
|
||||||
|
body: {
|
||||||
|
fullName: 'Smoke Contact',
|
||||||
|
phone: '+15550009999',
|
||||||
|
relationshipType: 'Sibling',
|
||||||
|
isPrimary: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(createdEmergencyContact.contactId);
|
||||||
|
logStep('staff.profile.emergency-contact.create.ok', createdEmergencyContact);
|
||||||
|
|
||||||
|
const updatedEmergencyContact = await apiCall(`/staff/profile/emergency-contacts/${createdEmergencyContact.contactId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-emergency-update'),
|
||||||
|
body: {
|
||||||
|
fullName: 'Smoke Contact Updated',
|
||||||
|
relationshipType: 'Brother',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.emergency-contact.update.ok', updatedEmergencyContact);
|
||||||
|
|
||||||
|
const savedW4Draft = await apiCall('/staff/profile/tax-forms/w4', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-tax-w4-draft'),
|
||||||
|
body: {
|
||||||
|
fields: {
|
||||||
|
filingStatus: 'single',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.tax-form.w4-draft.ok', savedW4Draft);
|
||||||
|
|
||||||
|
const submittedI9 = await apiCall('/staff/profile/tax-forms/i9/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-tax-i9-submit'),
|
||||||
|
body: {
|
||||||
|
fields: {
|
||||||
|
section1Complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.tax-form.i9-submit.ok', submittedI9);
|
||||||
|
|
||||||
|
const addedBankAccount = await apiCall('/staff/profile/bank-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-bank-account'),
|
||||||
|
body: {
|
||||||
|
bankName: 'Demo Credit Union',
|
||||||
|
accountNumber: '1234567890',
|
||||||
|
routingNumber: '021000021',
|
||||||
|
accountType: 'checking',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.bank-account.add.ok', addedBankAccount);
|
||||||
|
|
||||||
|
const updatedPrivacy = await apiCall('/staff/profile/privacy', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-privacy'),
|
||||||
|
body: {
|
||||||
|
profileVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.profile.privacy.update.ok', updatedPrivacy);
|
||||||
|
|
||||||
|
const appliedShift = await apiCall(`/staff/shifts/${fixture.shifts.available.id}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-shift-apply'),
|
||||||
|
body: {
|
||||||
|
roleId: fixture.shiftRoles.availableBarista.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.shifts.apply.ok', appliedShift);
|
||||||
|
|
||||||
|
const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-shift-accept'),
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
logStep('staff.shifts.accept.ok', acceptedShift);
|
||||||
|
|
||||||
|
const clockIn = await apiCall('/staff/clock-in', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-clock-in'),
|
||||||
|
body: {
|
||||||
|
shiftId: fixture.shifts.assigned.id,
|
||||||
|
sourceType: 'NFC',
|
||||||
|
nfcTagId: fixture.clockPoint.nfcTagUid,
|
||||||
|
deviceId: 'smoke-iphone-15-pro',
|
||||||
|
latitude: fixture.clockPoint.latitude,
|
||||||
|
longitude: fixture.clockPoint.longitude,
|
||||||
|
accuracyMeters: 8,
|
||||||
|
capturedAt: isoTimestamp(0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.clock-in.ok', clockIn);
|
||||||
|
|
||||||
|
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('staff.clock-in.status-after.ok', attendanceStatusAfterClockIn);
|
||||||
|
|
||||||
|
const clockOut = await apiCall('/staff/clock-out', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-clock-out'),
|
||||||
|
body: {
|
||||||
|
shiftId: fixture.shifts.assigned.id,
|
||||||
|
sourceType: 'GEO',
|
||||||
|
deviceId: 'smoke-iphone-15-pro',
|
||||||
|
latitude: fixture.clockPoint.latitude,
|
||||||
|
longitude: fixture.clockPoint.longitude,
|
||||||
|
accuracyMeters: 10,
|
||||||
|
breakMinutes: 30,
|
||||||
|
capturedAt: isoTimestamp(1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.clock-out.ok', clockOut);
|
||||||
|
|
||||||
|
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-shift-swap'),
|
||||||
|
body: {
|
||||||
|
reason: 'Smoke swap request',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logStep('staff.shifts.request-swap.ok', requestedSwap);
|
||||||
|
|
||||||
|
const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, {
|
||||||
|
filename: 'profile-photo.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
content: Buffer.from('fake-profile-photo'),
|
||||||
|
});
|
||||||
|
assert.ok(uploadedProfilePhoto.fileUri);
|
||||||
|
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
|
||||||
|
|
||||||
|
const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, {
|
||||||
|
filename: 'government-id.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
content: Buffer.from('fake-government-id'),
|
||||||
|
});
|
||||||
|
assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id);
|
||||||
|
logStep('staff.profile.document.upload.ok', uploadedGovId);
|
||||||
|
|
||||||
|
const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, {
|
||||||
|
filename: 'black-shirt.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
content: Buffer.from('fake-black-shirt'),
|
||||||
|
});
|
||||||
|
assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id);
|
||||||
|
logStep('staff.profile.attire.upload.ok', uploadedAttire);
|
||||||
|
|
||||||
|
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
|
||||||
|
const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, {
|
||||||
|
filename: 'certificate.pdf',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
content: Buffer.from('fake-certificate'),
|
||||||
|
fields: {
|
||||||
|
certificateType,
|
||||||
|
name: 'Alcohol Service Permit',
|
||||||
|
issuer: 'Demo Issuer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(uploadedCertificate.certificateType, certificateType);
|
||||||
|
logStep('staff.profile.certificate.upload.ok', uploadedCertificate);
|
||||||
|
|
||||||
|
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id));
|
||||||
|
logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length });
|
||||||
|
|
||||||
|
const certificatesAfter = await apiCall('/staff/profile/certificates', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
assert.ok(certificatesAfter.items.some((item) => item.certificateType === certificateType));
|
||||||
|
logStep('staff.profile.certificates-after.ok', { count: certificatesAfter.items.length });
|
||||||
|
|
||||||
|
const deletedCertificate = await apiCall(`/staff/profile/certificates/${certificateType}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
expectedStatus: 200,
|
||||||
|
});
|
||||||
|
logStep('staff.profile.certificate.delete.ok', deletedCertificate);
|
||||||
|
|
||||||
|
const clientSignOut = await apiCall('/auth/client/sign-out', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
logStep('auth.client.sign-out.ok', clientSignOut);
|
||||||
|
|
||||||
|
const staffSignOut = await apiCall('/auth/staff/sign-out', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
logStep('auth.staff.sign-out.ok', staffSignOut);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('LIVE_SMOKE_V2_UNIFIED_OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { AppError } from '../lib/errors.js';
|
import { AppError } from '../lib/errors.js';
|
||||||
import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js';
|
import {
|
||||||
|
getSessionForActor,
|
||||||
|
parseClientSignIn,
|
||||||
|
parseClientSignUp,
|
||||||
|
parseStaffPhoneStart,
|
||||||
|
parseStaffPhoneVerify,
|
||||||
|
signInClient,
|
||||||
|
signOutActor,
|
||||||
|
signUpClient,
|
||||||
|
startStaffPhoneAuth,
|
||||||
|
verifyStaffPhoneAuth,
|
||||||
|
} from '../services/auth-service.js';
|
||||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||||
|
|
||||||
const defaultAuthService = {
|
const defaultAuthService = {
|
||||||
parseClientSignIn,
|
parseClientSignIn,
|
||||||
parseClientSignUp,
|
parseClientSignUp,
|
||||||
|
parseStaffPhoneStart,
|
||||||
|
parseStaffPhoneVerify,
|
||||||
signInClient,
|
signInClient,
|
||||||
signOutActor,
|
signOutActor,
|
||||||
signUpClient,
|
signUpClient,
|
||||||
|
startStaffPhoneAuth,
|
||||||
|
verifyStaffPhoneAuth,
|
||||||
getSessionForActor,
|
getSessionForActor,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +46,7 @@ async function requireAuth(req, _res, next) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = await verifyFirebaseToken(token, { checkRevoked: true });
|
const decoded = await verifyFirebaseToken(token);
|
||||||
req.actor = {
|
req.actor = {
|
||||||
uid: decoded.uid,
|
uid: decoded.uid,
|
||||||
email: decoded.email || null,
|
email: decoded.email || null,
|
||||||
@@ -77,6 +92,32 @@ export function createAuthRouter(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/staff/phone/start', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const payload = authService.parseStaffPhoneStart(req.body);
|
||||||
|
const result = await authService.startStaffPhoneAuth(payload, { fetchImpl });
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/staff/phone/verify', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const payload = authService.parseStaffPhoneVerify(req.body);
|
||||||
|
const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl });
|
||||||
|
return res.status(200).json({
|
||||||
|
...session,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/session', requireAuth, async (req, res, next) => {
|
router.get('/session', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const session = await authService.getSessionForActor(req.actor);
|
const session = await authService.getSessionForActor(req.actor);
|
||||||
|
|||||||
@@ -14,10 +14,91 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|||||||
'upgrade',
|
'upgrade',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveTargetBase(pathname) {
|
const DIRECT_CORE_ALIASES = [
|
||||||
if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL;
|
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL;
|
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL;
|
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{
|
||||||
|
methods: new Set(['POST']),
|
||||||
|
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
|
||||||
|
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: new Set(['POST']),
|
||||||
|
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
|
||||||
|
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: new Set(['POST']),
|
||||||
|
pattern: /^\/staff\/profile\/certificates$/,
|
||||||
|
targetPath: () => '/core/staff/certificates/upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: new Set(['DELETE']),
|
||||||
|
pattern: /^\/staff\/profile\/certificates\/([^/]+)$/,
|
||||||
|
targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`,
|
||||||
|
},
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveTarget(pathname, method) {
|
||||||
|
const upperMethod = method.toUpperCase();
|
||||||
|
|
||||||
|
if (pathname.startsWith('/core')) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.CORE_API_BASE_URL,
|
||||||
|
upstreamPath: pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/commands')) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||||
|
upstreamPath: pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/query')) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||||
|
upstreamPath: pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const alias of DIRECT_CORE_ALIASES) {
|
||||||
|
if (!alias.methods.has(upperMethod)) continue;
|
||||||
|
const match = pathname.match(alias.pattern);
|
||||||
|
if (!match) continue;
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.CORE_API_BASE_URL,
|
||||||
|
upstreamPath: alias.targetPath(pathname, match),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||||
|
upstreamPath: `/query${pathname}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||||
|
upstreamPath: `/commands${pathname}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +111,13 @@ function copyHeaders(source, target) {
|
|||||||
|
|
||||||
async function forwardRequest(req, res, next, fetchImpl) {
|
async function forwardRequest(req, res, next, fetchImpl) {
|
||||||
try {
|
try {
|
||||||
const requestPath = new URL(req.originalUrl, 'http://localhost').pathname;
|
const requestUrl = new URL(req.originalUrl, 'http://localhost');
|
||||||
const baseUrl = resolveTargetBase(requestPath);
|
const target = resolveTarget(requestUrl.pathname, req.method);
|
||||||
if (!baseUrl) {
|
if (!target?.baseUrl) {
|
||||||
throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404);
|
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.originalUrl, baseUrl);
|
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||||
@@ -69,7 +150,7 @@ export function createProxyRouter(options = {}) {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
const fetchImpl = options.fetchImpl || fetch;
|
const fetchImpl = options.fetchImpl || fetch;
|
||||||
|
|
||||||
router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { z } from 'zod';
|
|||||||
import { AppError } from '../lib/errors.js';
|
import { AppError } from '../lib/errors.js';
|
||||||
import { withTransaction } from './db.js';
|
import { withTransaction } from './db.js';
|
||||||
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
|
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
|
||||||
import { deleteAccount, signInWithPassword, signUpWithPassword } from './identity-toolkit.js';
|
import {
|
||||||
|
deleteAccount,
|
||||||
|
sendVerificationCode,
|
||||||
|
signInWithPassword,
|
||||||
|
signInWithPhoneNumber,
|
||||||
|
signUpWithPassword,
|
||||||
|
} from './identity-toolkit.js';
|
||||||
import { loadActorContext } from './user-context.js';
|
import { loadActorContext } from './user-context.js';
|
||||||
|
|
||||||
const clientSignInSchema = z.object({
|
const clientSignInSchema = z.object({
|
||||||
@@ -17,6 +23,30 @@ const clientSignUpSchema = z.object({
|
|||||||
displayName: z.string().min(2).max(120).optional(),
|
displayName: z.string().min(2).max(120).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const staffPhoneStartSchema = z.object({
|
||||||
|
phoneNumber: z.string().min(6).max(40),
|
||||||
|
recaptchaToken: z.string().min(1).optional(),
|
||||||
|
iosReceipt: z.string().min(1).optional(),
|
||||||
|
iosSecret: z.string().min(1).optional(),
|
||||||
|
captchaResponse: z.string().min(1).optional(),
|
||||||
|
playIntegrityToken: z.string().min(1).optional(),
|
||||||
|
safetyNetToken: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const staffPhoneVerifySchema = z.object({
|
||||||
|
mode: z.enum(['sign-in', 'sign-up']).optional(),
|
||||||
|
idToken: z.string().min(1).optional(),
|
||||||
|
sessionInfo: z.string().min(1).optional(),
|
||||||
|
code: z.string().min(1).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.idToken) return;
|
||||||
|
if (value.sessionInfo && value.code) return;
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Provide idToken or sessionInfo and code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function slugify(input) {
|
function slugify(input) {
|
||||||
return input
|
return input
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -40,9 +70,43 @@ function buildAuthEnvelope(authPayload, context) {
|
|||||||
business: context.business,
|
business: context.business,
|
||||||
vendor: context.vendor,
|
vendor: context.vendor,
|
||||||
staff: context.staff,
|
staff: context.staff,
|
||||||
|
requiresProfileSetup: !context.staff,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) {
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO users (id, email, display_name, phone, status, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb))
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET email = COALESCE(EXCLUDED.email, users.email),
|
||||||
|
display_name = COALESCE(EXCLUDED.display_name, users.display_name),
|
||||||
|
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||||
|
metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb),
|
||||||
|
updated_at = NOW()
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
decoded.uid,
|
||||||
|
decoded.email || fallbackProfile.email || null,
|
||||||
|
decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null,
|
||||||
|
decoded.phone_number || fallbackProfile.phoneNumber || null,
|
||||||
|
JSON.stringify({
|
||||||
|
provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateAuthContext(authPayload, fallbackProfile = {}) {
|
||||||
|
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||||
|
await upsertUserFromDecodedToken(decoded, fallbackProfile);
|
||||||
|
const context = await loadActorContext(decoded.uid);
|
||||||
|
return buildAuthEnvelope(authPayload, context);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseClientSignIn(body) {
|
export function parseClientSignIn(body) {
|
||||||
const parsed = clientSignInSchema.safeParse(body || {});
|
const parsed = clientSignInSchema.safeParse(body || {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -63,6 +127,26 @@ export function parseClientSignUp(body) {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseStaffPhoneStart(body) {
|
||||||
|
const parsed = staffPhoneStartSchema.safeParse(body || {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStaffPhoneVerify(body) {
|
||||||
|
const parsed = staffPhoneVerifySchema.safeParse(body || {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSessionForActor(actor) {
|
export async function getSessionForActor(actor) {
|
||||||
return loadActorContext(actor.uid);
|
return loadActorContext(actor.uid);
|
||||||
}
|
}
|
||||||
@@ -70,6 +154,7 @@ export async function getSessionForActor(actor) {
|
|||||||
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
|
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
|
||||||
const authPayload = await signInWithPassword(payload, fetchImpl);
|
const authPayload = await signInWithPassword(payload, fetchImpl);
|
||||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||||
|
await upsertUserFromDecodedToken(decoded, payload);
|
||||||
const context = await loadActorContext(decoded.uid);
|
const context = await loadActorContext(decoded.uid);
|
||||||
|
|
||||||
if (!context.user || !context.business) {
|
if (!context.user || !context.business) {
|
||||||
@@ -151,6 +236,68 @@ export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseClientSdkStaffFlow(payload) {
|
||||||
|
return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||||
|
if (shouldUseClientSdkStaffFlow(payload)) {
|
||||||
|
return {
|
||||||
|
mode: 'CLIENT_FIREBASE_SDK',
|
||||||
|
provider: 'firebase-phone-auth',
|
||||||
|
phoneNumber: payload.phoneNumber,
|
||||||
|
nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPayload = await sendVerificationCode(
|
||||||
|
{
|
||||||
|
phoneNumber: payload.phoneNumber,
|
||||||
|
recaptchaToken: payload.recaptchaToken,
|
||||||
|
iosReceipt: payload.iosReceipt,
|
||||||
|
iosSecret: payload.iosSecret,
|
||||||
|
captchaResponse: payload.captchaResponse,
|
||||||
|
playIntegrityToken: payload.playIntegrityToken,
|
||||||
|
safetyNetToken: payload.safetyNetToken,
|
||||||
|
},
|
||||||
|
fetchImpl
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'IDENTITY_TOOLKIT_SMS',
|
||||||
|
phoneNumber: payload.phoneNumber,
|
||||||
|
sessionInfo: authPayload.sessionInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||||
|
if (payload.idToken) {
|
||||||
|
return hydrateAuthContext(
|
||||||
|
{
|
||||||
|
idToken: payload.idToken,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresIn: 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'firebase-phone-auth',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPayload = await signInWithPhoneNumber(
|
||||||
|
{
|
||||||
|
sessionInfo: payload.sessionInfo,
|
||||||
|
code: payload.code,
|
||||||
|
operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined,
|
||||||
|
},
|
||||||
|
fetchImpl
|
||||||
|
);
|
||||||
|
|
||||||
|
return hydrateAuthContext(authPayload, {
|
||||||
|
provider: 'firebase-phone-auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function signOutActor(actor) {
|
export async function signOutActor(actor) {
|
||||||
await revokeUserSessions(actor.uid);
|
await revokeUserSessions(actor.uid);
|
||||||
return { signedOut: true };
|
return { signedOut: true };
|
||||||
|
|||||||
@@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) {
|
|||||||
ensureAdminApp();
|
ensureAdminApp();
|
||||||
await getAuth().revokeRefreshTokens(uid);
|
await getAuth().revokeRefreshTokens(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCustomToken(uid) {
|
||||||
|
ensureAdminApp();
|
||||||
|
return getAuth().createCustomToken(uid);
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,33 @@ export async function signUpWithPassword({ email, password }, fetchImpl = fetch)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendVerificationCode(payload, fetchImpl = fetch) {
|
||||||
|
return callIdentityToolkit(
|
||||||
|
'accounts:sendVerificationCode',
|
||||||
|
payload,
|
||||||
|
fetchImpl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInWithPhoneNumber(payload, fetchImpl = fetch) {
|
||||||
|
return callIdentityToolkit(
|
||||||
|
'accounts:signInWithPhoneNumber',
|
||||||
|
payload,
|
||||||
|
fetchImpl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInWithCustomToken(payload, fetchImpl = fetch) {
|
||||||
|
return callIdentityToolkit(
|
||||||
|
'accounts:signInWithCustomToken',
|
||||||
|
{
|
||||||
|
token: payload.token,
|
||||||
|
returnSecureToken: true,
|
||||||
|
},
|
||||||
|
fetchImpl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
|
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
|
||||||
return callIdentityToolkit(
|
return callIdentityToolkit(
|
||||||
'accounts:delete',
|
'accounts:delete',
|
||||||
|
|||||||
@@ -110,3 +110,75 @@ test('proxy forwards query routes to query base url', async () => {
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(seenUrl, 'https://query.example/query/test-route?foo=bar');
|
assert.equal(seenUrl, 'https://query.example/query/test-route?foo=bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proxy forwards direct client read routes to query 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).get('/client/dashboard');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenUrl, 'https://query.example/query/client/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy forwards direct client write routes to command 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('/client/orders/one-time')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.send({ ok: true });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenUrl, 'https://command.example/commands/client/orders/one-time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy forwards direct core 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;
|
||||||
|
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('/staff/profile/certificates')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.send({ ok: true });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
|
||||||
|
});
|
||||||
|
|||||||
61
backend/unified-api/test/staff-auth.test.js
Normal file
61
backend/unified-api/test/staff-auth.test.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
|
||||||
|
function createAuthService() {
|
||||||
|
return {
|
||||||
|
parseClientSignIn: (body) => body,
|
||||||
|
parseClientSignUp: (body) => body,
|
||||||
|
parseStaffPhoneStart: (body) => body,
|
||||||
|
parseStaffPhoneVerify: (body) => body,
|
||||||
|
signInClient: async () => assert.fail('signInClient should not be called'),
|
||||||
|
signUpClient: async () => assert.fail('signUpClient should not be called'),
|
||||||
|
signOutActor: async () => ({ signedOut: true }),
|
||||||
|
getSessionForActor: async () => ({ user: { userId: 'u1' } }),
|
||||||
|
startStaffPhoneAuth: async (payload) => ({
|
||||||
|
mode: 'CLIENT_FIREBASE_SDK',
|
||||||
|
phoneNumber: payload.phoneNumber,
|
||||||
|
nextStep: 'continue in app',
|
||||||
|
}),
|
||||||
|
verifyStaffPhoneAuth: async (payload) => ({
|
||||||
|
sessionToken: payload.idToken || 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
expiresInSeconds: 3600,
|
||||||
|
user: { id: 'staff-user' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
vendor: { vendorId: 'vendor-1' },
|
||||||
|
staff: { staffId: 'staff-1' },
|
||||||
|
requiresProfileSetup: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /auth/staff/phone/start returns injected start payload', async () => {
|
||||||
|
const app = createApp({ authService: createAuthService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/auth/staff/phone/start')
|
||||||
|
.send({
|
||||||
|
phoneNumber: '+15555550123',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.mode, 'CLIENT_FIREBASE_SDK');
|
||||||
|
assert.equal(res.body.phoneNumber, '+15555550123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /auth/staff/phone/verify returns injected auth envelope', async () => {
|
||||||
|
const app = createApp({ authService: createAuthService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/auth/staff/phone/verify')
|
||||||
|
.send({
|
||||||
|
idToken: 'firebase-id-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.sessionToken, 'firebase-id-token');
|
||||||
|
assert.equal(res.body.staff.staffId, 'staff-1');
|
||||||
|
assert.equal(res.body.requiresProfileSetup, false);
|
||||||
|
});
|
||||||
@@ -2,33 +2,48 @@
|
|||||||
|
|
||||||
This is the frontend-facing source of truth for the v2 backend.
|
This is the frontend-facing source of truth for the v2 backend.
|
||||||
|
|
||||||
## 1) Frontend entrypoint
|
## 1) Use one base URL
|
||||||
|
|
||||||
Frontend should target one public base URL:
|
Frontend should call one public gateway:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_V2_BASE_URL=<krow-api-v2-url>
|
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app
|
||||||
```
|
```
|
||||||
|
|
||||||
The unified v2 gateway exposes:
|
Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly.
|
||||||
|
|
||||||
- `/auth/*`
|
## 2) Current status
|
||||||
- `/core/*`
|
|
||||||
- `/commands/*`
|
|
||||||
- `/query/*`
|
|
||||||
- `/query/client/*`
|
|
||||||
- `/query/staff/*`
|
|
||||||
|
|
||||||
Internal services still stay separate behind that gateway.
|
The unified v2 gateway is ready for frontend integration in `dev`.
|
||||||
|
|
||||||
## 2) Internal service split
|
What was validated live against the deployed stack:
|
||||||
|
|
||||||
| Use case | Internal service |
|
- client sign-in
|
||||||
| --- | --- |
|
- staff auth bootstrap
|
||||||
| File upload, signed URLs, model calls, verification helpers | `core-api-v2` |
|
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
||||||
| Business writes and workflow actions | `command-api-v2` |
|
- client hub and order write flows
|
||||||
| Screen reads and mobile read models | `query-api-v2` |
|
- client invoice approve and dispute
|
||||||
| Frontend-facing single host and auth wrappers | `krow-api-v2` |
|
- 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, clock-in, clock-out, and swap request
|
||||||
|
- direct file upload helpers and verification job creation through the unified host
|
||||||
|
- client and staff sign-out
|
||||||
|
|
||||||
|
The live validation command is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.nvm/nvm.sh
|
||||||
|
nvm use 23.5.0
|
||||||
|
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The demo tenant can be reset with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.nvm/nvm.sh
|
||||||
|
nvm use 23.5.0
|
||||||
|
cd backend/command-api
|
||||||
|
npm run seed:v2-demo
|
||||||
|
```
|
||||||
|
|
||||||
## 3) Auth and headers
|
## 3) Auth and headers
|
||||||
|
|
||||||
@@ -38,13 +53,19 @@ Protected routes require:
|
|||||||
Authorization: Bearer <firebase-id-token>
|
Authorization: Bearer <firebase-id-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Command routes also require:
|
Write routes also require:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Idempotency-Key: <unique-per-user-action>
|
Idempotency-Key: <unique-per-user-action>
|
||||||
```
|
```
|
||||||
|
|
||||||
All services return the same error envelope:
|
For now:
|
||||||
|
|
||||||
|
- backend wraps sign-in and sign-out
|
||||||
|
- frontend can keep using Firebase token refresh on the client
|
||||||
|
- backend is the only thing frontend should call for session-oriented API flows
|
||||||
|
|
||||||
|
All routes return the same error envelope:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -55,83 +76,35 @@ All services return the same error envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4) What frontend can use now on this branch
|
## 4) Route model
|
||||||
|
|
||||||
### Unified gateway
|
Frontend sees one base URL and one route shape:
|
||||||
|
|
||||||
- `POST /auth/client/sign-in`
|
- `/auth/*`
|
||||||
- `POST /auth/client/sign-up`
|
- `/client/*`
|
||||||
- `POST /auth/sign-out`
|
- `/staff/*`
|
||||||
- `POST /auth/client/sign-out`
|
- direct upload aliases like `/upload-file` and `/staff/profile/*`
|
||||||
- `POST /auth/staff/sign-out`
|
|
||||||
- `GET /auth/session`
|
|
||||||
- Proxy access to `/core/*`, `/commands/*`, `/query/*`
|
|
||||||
|
|
||||||
### Client read routes
|
Internally, the gateway still forwards to:
|
||||||
|
|
||||||
- `GET /query/client/session`
|
| Frontend use case | Internal service |
|
||||||
- `GET /query/client/dashboard`
|
| --- | --- |
|
||||||
- `GET /query/client/reorders`
|
| auth/session wrapper | `krow-api-v2` |
|
||||||
- `GET /query/client/billing/accounts`
|
| uploads, signed URLs, model calls, verification workflows | `core-api-v2` |
|
||||||
- `GET /query/client/billing/invoices/pending`
|
| writes and workflow actions | `command-api-v2` |
|
||||||
- `GET /query/client/billing/invoices/history`
|
| reads and mobile read models | `query-api-v2` |
|
||||||
- `GET /query/client/billing/current-bill`
|
|
||||||
- `GET /query/client/billing/savings`
|
|
||||||
- `GET /query/client/billing/spend-breakdown`
|
|
||||||
- `GET /query/client/coverage`
|
|
||||||
- `GET /query/client/coverage/stats`
|
|
||||||
- `GET /query/client/hubs`
|
|
||||||
- `GET /query/client/cost-centers`
|
|
||||||
- `GET /query/client/vendors`
|
|
||||||
- `GET /query/client/vendors/:vendorId/roles`
|
|
||||||
- `GET /query/client/hubs/:hubId/managers`
|
|
||||||
- `GET /query/client/orders/view`
|
|
||||||
|
|
||||||
### Staff read routes
|
## 5) Frontend integration rule
|
||||||
|
|
||||||
- `GET /query/staff/session`
|
Use the unified routes first.
|
||||||
- `GET /query/staff/dashboard`
|
|
||||||
- `GET /query/staff/profile-completion`
|
|
||||||
- `GET /query/staff/availability`
|
|
||||||
- `GET /query/staff/clock-in/shifts/today`
|
|
||||||
- `GET /query/staff/clock-in/status`
|
|
||||||
- `GET /query/staff/payments/summary`
|
|
||||||
- `GET /query/staff/payments/history`
|
|
||||||
- `GET /query/staff/payments/chart`
|
|
||||||
- `GET /query/staff/shifts/assigned`
|
|
||||||
- `GET /query/staff/shifts/open`
|
|
||||||
- `GET /query/staff/shifts/pending`
|
|
||||||
- `GET /query/staff/shifts/cancelled`
|
|
||||||
- `GET /query/staff/shifts/completed`
|
|
||||||
- `GET /query/staff/shifts/:shiftId`
|
|
||||||
- `GET /query/staff/profile/sections`
|
|
||||||
- `GET /query/staff/profile/personal-info`
|
|
||||||
- `GET /query/staff/profile/industries`
|
|
||||||
- `GET /query/staff/profile/skills`
|
|
||||||
- `GET /query/staff/profile/documents`
|
|
||||||
- `GET /query/staff/profile/certificates`
|
|
||||||
- `GET /query/staff/profile/bank-accounts`
|
|
||||||
- `GET /query/staff/profile/benefits`
|
|
||||||
|
|
||||||
### Existing v2 routes still valid
|
Do not build new frontend work on:
|
||||||
|
|
||||||
- `/core/*` routes documented in `core-api.md`
|
- `/query/tenants/*`
|
||||||
- `/commands/*` routes documented in `command-api.md`
|
- `/commands/*`
|
||||||
- `/query/tenants/*` routes documented in `query-api.md`
|
- `/core/*`
|
||||||
|
|
||||||
## 5) Remaining gaps after this slice
|
Those routes still exist for backend/internal compatibility, but mobile/frontend migration should target the unified surface documented in [Unified API](./unified-api.md).
|
||||||
|
|
||||||
Still not implemented yet:
|
|
||||||
|
|
||||||
- staff phone OTP wrapper endpoints
|
|
||||||
- hub write flows
|
|
||||||
- hub NFC assignment write route
|
|
||||||
- invoice approve and dispute commands
|
|
||||||
- staff apply / decline / request swap commands
|
|
||||||
- staff profile update commands
|
|
||||||
- availability write commands
|
|
||||||
- reports suite
|
|
||||||
- durable verification persistence in `core-api-v2`
|
|
||||||
|
|
||||||
## 6) Docs
|
## 6) Docs
|
||||||
|
|
||||||
@@ -139,4 +112,4 @@ Still not implemented yet:
|
|||||||
- [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)
|
||||||
- [Mobile gap analysis](./mobile-api-gap-analysis.md)
|
- [Mobile API Reconciliation](./mobile-api-gap-analysis.md)
|
||||||
|
|||||||
@@ -1,66 +1,45 @@
|
|||||||
# Mobile API Gap Analysis
|
# Mobile API Reconciliation
|
||||||
|
|
||||||
Source compared against implementation:
|
Source compared against implementation:
|
||||||
|
|
||||||
- `/Users/wiel/Downloads/mobile-backend-api-specification.md`
|
- `mobile-backend-api-specification.md`
|
||||||
|
|
||||||
## Implemented in this slice
|
## Result
|
||||||
|
|
||||||
- unified frontend-facing base URL design
|
The current mobile v2 surface is implemented behind the unified gateway and validated live in `dev`.
|
||||||
- client auth wrapper for email/password sign-in and sign-up
|
|
||||||
- auth session and sign-out endpoints
|
|
||||||
- client read surface for dashboard, billing, coverage, hubs, vendor lookup, and date-range order items
|
|
||||||
- staff read surface for dashboard, availability, clock-in reads, payments, shifts, and profile sections
|
|
||||||
- schema support for:
|
|
||||||
- cost centers
|
|
||||||
- hub managers
|
|
||||||
- recurring staff availability
|
|
||||||
- staff benefits
|
|
||||||
- seed support for:
|
|
||||||
- authenticated demo staff user
|
|
||||||
- cost center and hub manager data
|
|
||||||
- staff benefits and availability
|
|
||||||
- attire and tax-form example documents
|
|
||||||
|
|
||||||
## Still missing
|
That includes:
|
||||||
|
|
||||||
### Auth
|
- auth session routes
|
||||||
|
- client dashboard, billing, coverage, hubs, vendor lookup, managers, team members, orders, and reports
|
||||||
|
- client order, hub, coverage review, and invoice write flows
|
||||||
|
- staff dashboard, availability, payments, shifts, profile sections, documents, attire, certificates, bank accounts, benefits, privacy, and frequently asked questions
|
||||||
|
- staff availability, tax forms, emergency contacts, bank account, shift decision, clock-in/out, and swap write flows
|
||||||
|
- upload and verification flows for profile photo, government document, attire, and certificates
|
||||||
|
|
||||||
- staff phone OTP start
|
## What was validated live
|
||||||
- staff OTP verify
|
|
||||||
- staff profile setup endpoint
|
|
||||||
|
|
||||||
### Client writes
|
The live smoke executed successfully against:
|
||||||
|
|
||||||
- hub create
|
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
||||||
- hub update
|
- Firebase demo users
|
||||||
- hub delete
|
- `krow-sql-v2`
|
||||||
- hub NFC assignment
|
- `krow-core-api-v2`
|
||||||
- assign manager to hub
|
- `krow-command-api-v2`
|
||||||
- invoice approve
|
- `krow-query-api-v2`
|
||||||
- invoice dispute
|
|
||||||
|
|
||||||
### Staff writes
|
The validation script is:
|
||||||
|
|
||||||
- availability update
|
```bash
|
||||||
- availability quick set
|
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
|
||||||
- shift apply
|
```
|
||||||
- shift decline
|
|
||||||
- request swap
|
|
||||||
- personal info update
|
|
||||||
- preferred locations update
|
|
||||||
- profile photo upload wrapper
|
|
||||||
|
|
||||||
### Reports
|
## Remaining work
|
||||||
|
|
||||||
- report summary
|
The remaining items are not blockers for current mobile frontend migration.
|
||||||
- daily ops
|
|
||||||
- spend
|
|
||||||
- coverage
|
|
||||||
- forecast
|
|
||||||
- performance
|
|
||||||
- no-show
|
|
||||||
|
|
||||||
### Core persistence
|
They are follow-up items:
|
||||||
|
|
||||||
- `core-api-v2` verification jobs still need durable SQL persistence
|
- extend the same unified pattern to new screens added after the current mobile specification
|
||||||
|
- add stronger observability and contract automation around the unified route surface
|
||||||
|
- keep refining reporting and financial read models as product scope expands
|
||||||
|
|||||||
@@ -1,50 +1,168 @@
|
|||||||
# Unified API V2
|
# Unified API V2
|
||||||
|
|
||||||
This service exists so frontend can use one base URL without forcing backend into one codebase.
|
Frontend should use this service as the single base URL:
|
||||||
|
|
||||||
## Base idea
|
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
||||||
|
|
||||||
Frontend talks to one service:
|
The gateway keeps backend services separate internally, but frontend should treat it as one API.
|
||||||
|
|
||||||
- `krow-api-v2`
|
## 1) Auth routes
|
||||||
|
|
||||||
That gateway does two things:
|
### Client auth
|
||||||
|
|
||||||
1. exposes auth/session endpoints
|
|
||||||
2. forwards requests to the right internal v2 service
|
|
||||||
|
|
||||||
## Route groups
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
- `POST /auth/client/sign-in`
|
- `POST /auth/client/sign-in`
|
||||||
- `POST /auth/client/sign-up`
|
- `POST /auth/client/sign-up`
|
||||||
- `POST /auth/sign-out`
|
|
||||||
- `POST /auth/client/sign-out`
|
- `POST /auth/client/sign-out`
|
||||||
|
|
||||||
|
### Staff auth
|
||||||
|
|
||||||
|
- `POST /auth/staff/phone/start`
|
||||||
|
- `POST /auth/staff/phone/verify`
|
||||||
- `POST /auth/staff/sign-out`
|
- `POST /auth/staff/sign-out`
|
||||||
|
|
||||||
|
### Shared auth
|
||||||
|
|
||||||
- `GET /auth/session`
|
- `GET /auth/session`
|
||||||
|
- `POST /auth/sign-out`
|
||||||
|
|
||||||
### Proxy passthrough
|
## 2) Client routes
|
||||||
|
|
||||||
- `/core/*` -> `core-api-v2`
|
### Client reads
|
||||||
- `/commands/*` -> `command-api-v2`
|
|
||||||
- `/query/*` -> `query-api-v2`
|
|
||||||
|
|
||||||
### Mobile read models
|
- `GET /client/session`
|
||||||
|
- `GET /client/dashboard`
|
||||||
|
- `GET /client/reorders`
|
||||||
|
- `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`
|
||||||
|
- `GET /client/coverage`
|
||||||
|
- `GET /client/coverage/stats`
|
||||||
|
- `GET /client/coverage/core-team`
|
||||||
|
- `GET /client/hubs`
|
||||||
|
- `GET /client/cost-centers`
|
||||||
|
- `GET /client/vendors`
|
||||||
|
- `GET /client/vendors/:vendorId/roles`
|
||||||
|
- `GET /client/hubs/:hubId/managers`
|
||||||
|
- `GET /client/team-members`
|
||||||
|
- `GET /client/orders/view`
|
||||||
|
- `GET /client/orders/:orderId/reorder-preview`
|
||||||
|
- `GET /client/reports/summary`
|
||||||
|
- `GET /client/reports/daily-ops`
|
||||||
|
- `GET /client/reports/spend`
|
||||||
|
- `GET /client/reports/coverage`
|
||||||
|
- `GET /client/reports/forecast`
|
||||||
|
- `GET /client/reports/performance`
|
||||||
|
- `GET /client/reports/no-show`
|
||||||
|
|
||||||
These are served by `query-api-v2` but frontend should still call them through the unified host:
|
### Client writes
|
||||||
|
|
||||||
- `/query/client/*`
|
- `POST /client/orders/one-time`
|
||||||
- `/query/staff/*`
|
- `POST /client/orders/recurring`
|
||||||
|
- `POST /client/orders/permanent`
|
||||||
|
- `POST /client/orders/:orderId/edit`
|
||||||
|
- `POST /client/orders/:orderId/cancel`
|
||||||
|
- `POST /client/hubs`
|
||||||
|
- `PUT /client/hubs/:hubId`
|
||||||
|
- `DELETE /client/hubs/:hubId`
|
||||||
|
- `POST /client/hubs/:hubId/assign-nfc`
|
||||||
|
- `POST /client/hubs/:hubId/managers`
|
||||||
|
- `POST /client/billing/invoices/:invoiceId/approve`
|
||||||
|
- `POST /client/billing/invoices/:invoiceId/dispute`
|
||||||
|
- `POST /client/coverage/reviews`
|
||||||
|
- `POST /client/coverage/late-workers/:assignmentId/cancel`
|
||||||
|
|
||||||
## Why this shape
|
## 3) Staff routes
|
||||||
|
|
||||||
- frontend gets one base URL
|
### Staff reads
|
||||||
- backend keeps separate read, write, and service helpers
|
|
||||||
- we can scale or refactor internals later without breaking frontend paths
|
|
||||||
|
|
||||||
## Current auth note
|
- `GET /staff/session`
|
||||||
|
- `GET /staff/dashboard`
|
||||||
|
- `GET /staff/profile-completion`
|
||||||
|
- `GET /staff/availability`
|
||||||
|
- `GET /staff/clock-in/shifts/today`
|
||||||
|
- `GET /staff/clock-in/status`
|
||||||
|
- `GET /staff/payments/summary`
|
||||||
|
- `GET /staff/payments/history`
|
||||||
|
- `GET /staff/payments/chart`
|
||||||
|
- `GET /staff/shifts/assigned`
|
||||||
|
- `GET /staff/shifts/open`
|
||||||
|
- `GET /staff/shifts/pending`
|
||||||
|
- `GET /staff/shifts/cancelled`
|
||||||
|
- `GET /staff/shifts/completed`
|
||||||
|
- `GET /staff/shifts/:shiftId`
|
||||||
|
- `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/time-card`
|
||||||
|
- `GET /staff/profile/privacy`
|
||||||
|
- `GET /staff/faqs`
|
||||||
|
- `GET /staff/faqs/search`
|
||||||
|
|
||||||
Client email/password auth is wrapped here.
|
### Staff writes
|
||||||
|
|
||||||
Staff phone OTP is not wrapped here yet. That still needs its own proper provider-backed implementation rather than a fake backend OTP flow.
|
- `POST /staff/profile/setup`
|
||||||
|
- `POST /staff/clock-in`
|
||||||
|
- `POST /staff/clock-out`
|
||||||
|
- `PUT /staff/availability`
|
||||||
|
- `POST /staff/availability/quick-set`
|
||||||
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
|
- `POST /staff/shifts/:shiftId/request-swap`
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
## 4) Upload and verification routes
|
||||||
|
|
||||||
|
These are exposed as direct unified aliases even though they are backed by `core-api-v2`.
|
||||||
|
|
||||||
|
### Generic core aliases
|
||||||
|
|
||||||
|
- `POST /upload-file`
|
||||||
|
- `POST /create-signed-url`
|
||||||
|
- `POST /invoke-llm`
|
||||||
|
- `POST /rapid-orders/transcribe`
|
||||||
|
- `POST /rapid-orders/parse`
|
||||||
|
- `POST /verifications`
|
||||||
|
- `GET /verifications/:verificationId`
|
||||||
|
- `POST /verifications/:verificationId/review`
|
||||||
|
- `POST /verifications/:verificationId/retry`
|
||||||
|
|
||||||
|
### Staff upload aliases
|
||||||
|
|
||||||
|
- `POST /staff/profile/photo`
|
||||||
|
- `POST /staff/profile/documents/:documentId/upload`
|
||||||
|
- `POST /staff/profile/attire/:documentId/upload`
|
||||||
|
- `POST /staff/profile/certificates`
|
||||||
|
- `DELETE /staff/profile/certificates/:certificateId`
|
||||||
|
|
||||||
|
## 5) Notes that matter for frontend
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
||||||
|
- Verification routes are durable in the v2 backend and were validated live through document, attire, and certificate upload flows.
|
||||||
|
|
||||||
|
## 6) Why this shape
|
||||||
|
|
||||||
|
- frontend gets one host
|
||||||
|
- backend keeps reads, writes, and service helpers separated
|
||||||
|
- routing can change internally later without forcing frontend rewrites
|
||||||
|
|||||||
@@ -349,12 +349,15 @@ backend-deploy-core-v2:
|
|||||||
@test -d $(BACKEND_V2_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_CORE_DIR)" && exit 1)
|
@test -d $(BACKEND_V2_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_CORE_DIR)" && exit 1)
|
||||||
@test -f $(BACKEND_V2_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_CORE_DIR)/Dockerfile" && exit 1)
|
@test -f $(BACKEND_V2_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_CORE_DIR)/Dockerfile" && exit 1)
|
||||||
@gcloud builds submit $(BACKEND_V2_CORE_DIR) --tag $(BACKEND_V2_CORE_IMAGE) --project=$(GCP_PROJECT_ID)
|
@gcloud builds submit $(BACKEND_V2_CORE_DIR) --tag $(BACKEND_V2_CORE_IMAGE) --project=$(GCP_PROJECT_ID)
|
||||||
@gcloud run deploy $(BACKEND_V2_CORE_SERVICE_NAME) \
|
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_STORE=sql,VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER)"; \
|
||||||
|
gcloud run deploy $(BACKEND_V2_CORE_SERVICE_NAME) \
|
||||||
--image=$(BACKEND_V2_CORE_IMAGE) \
|
--image=$(BACKEND_V2_CORE_IMAGE) \
|
||||||
--region=$(BACKEND_REGION) \
|
--region=$(BACKEND_REGION) \
|
||||||
--project=$(GCP_PROJECT_ID) \
|
--project=$(GCP_PROJECT_ID) \
|
||||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||||
--set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \
|
--set-env-vars=$$EXTRA_ENV \
|
||||||
|
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||||
|
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||||
@echo "✅ Core backend v2 service deployed."
|
@echo "✅ Core backend v2 service deployed."
|
||||||
|
|
||||||
@@ -438,7 +441,7 @@ backend-smoke-core-v2:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
TOKEN=$$(gcloud auth print-identity-token); \
|
TOKEN=$$(gcloud auth print-identity-token); \
|
||||||
curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Core v2 smoke check passed: $$URL/health"
|
curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Core v2 smoke check passed: $$URL/readyz"
|
||||||
|
|
||||||
backend-smoke-commands-v2:
|
backend-smoke-commands-v2:
|
||||||
@echo "--> Running command v2 smoke check..."
|
@echo "--> Running command v2 smoke check..."
|
||||||
|
|||||||
Reference in New Issue
Block a user