Merge pull request #652 from Oloodi/codex/feat-v2-unified-api-surface

feat(api): complete unified v2 mobile surface
This commit is contained in:
Achintha Isuru
2026-03-16 09:38:44 -04:00
committed by GitHub
53 changed files with 14294 additions and 373 deletions

View File

@@ -44,11 +44,20 @@ async function main() {
const completedEndsAt = hoursFromNow(-20);
const checkedInAt = hoursFromNow(-27.5);
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);
await upsertUser(client, fixture.users.businessOwner);
await upsertUser(client, fixture.users.operationsManager);
await upsertUser(client, fixture.users.vendorManager);
await upsertUser(client, fixture.users.staffAna);
await client.query(
`
@@ -64,13 +73,15 @@ async function main() {
VALUES
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb)
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb),
($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb)
`,
[
fixture.tenant.id,
fixture.users.businessOwner.id,
fixture.users.operationsManager.id,
fixture.users.vendorManager.id,
fixture.users.staffAna.id,
]
);
@@ -134,6 +145,14 @@ async function main() {
[fixture.tenant.id, fixture.vendor.id, fixture.users.vendorManager.id]
);
await client.query(
`
INSERT INTO cost_centers (id, tenant_id, business_id, code, name, status, metadata)
VALUES ($1, $2, $3, 'CAFE_OPS', $4, 'ACTIVE', '{"seeded":true}'::jsonb)
`,
[fixture.costCenters.cafeOps.id, fixture.tenant.id, fixture.business.id, fixture.costCenters.cafeOps.name]
);
await client.query(
`
INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata)
@@ -158,16 +177,37 @@ async function main() {
id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
average_rating, rating_count, metadata
)
VALUES ($1, $2, NULL, $3, $4, $5, 'ACTIVE', $6, 'COMPLETED', 4.50, 1, $7::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, 'COMPLETED', 4.50, 1, $8::jsonb)
`,
[
fixture.staff.ana.id,
fixture.tenant.id,
fixture.users.staffAna.id,
fixture.staff.ana.fullName,
fixture.staff.ana.email,
fixture.staff.ana.phone,
fixture.staff.ana.primaryRole,
JSON.stringify({ favoriteCandidate: true, seeded: true }),
JSON.stringify({
favoriteCandidate: true,
seeded: true,
firstName: 'Ana',
lastName: 'Barista',
bio: 'Experienced barista and event staffing professional.',
preferredLocations: [
{
city: 'Mountain View',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
},
],
maxDistanceMiles: 20,
industries: ['CATERING', 'CAFE'],
skills: ['BARISTA', 'CUSTOMER_SERVICE'],
emergencyContact: {
name: 'Maria Barista',
phone: '+15550007777',
},
}),
]
);
@@ -196,21 +236,73 @@ async function main() {
await client.query(
`
INSERT INTO clock_points (
id, tenant_id, business_id, label, address, latitude, longitude, geofence_radius_meters, nfc_tag_uid, status, metadata
INSERT INTO staff_availability (
id, tenant_id, staff_id, day_of_week, availability_status, time_slots, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'ACTIVE', '{}'::jsonb)
VALUES
($1, $3, $4, 1, 'PARTIAL', '[{"start":"08:00","end":"18:00"}]'::jsonb, '{"seeded":true}'::jsonb),
($2, $3, $4, 5, 'PARTIAL', '[{"start":"09:00","end":"17:00"}]'::jsonb, '{"seeded":true}'::jsonb)
`,
[fixture.availability.monday.id, fixture.availability.friday.id, fixture.tenant.id, fixture.staff.ana.id]
);
await client.query(
`
INSERT INTO staff_benefits (
id, tenant_id, staff_id, benefit_type, title, status, tracked_hours, target_hours, metadata
)
VALUES ($1, $2, $3, 'COMMUTER', $4, 'ACTIVE', 32, 40, '{"description":"Commuter stipend unlocked after 40 hours"}'::jsonb)
`,
[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(
`
INSERT INTO clock_points (
id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude,
geofence_radius_meters, nfc_tag_uid, status, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'ACTIVE', $11::jsonb)
`,
[
fixture.clockPoint.id,
fixture.tenant.id,
fixture.business.id,
fixture.costCenters.cafeOps.id,
fixture.clockPoint.label,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
fixture.clockPoint.nfcTagUid,
JSON.stringify({ city: 'Mountain View', state: 'CA', zipCode: '94043', seeded: true }),
]
);
await client.query(
`
INSERT INTO hub_managers (id, tenant_id, hub_id, business_membership_id)
SELECT $1, $2, $3, bm.id
FROM business_memberships bm
WHERE bm.business_id = $4
AND bm.user_id = $5
`,
[
fixture.hubManagers.opsLead.id,
fixture.tenant.id,
fixture.clockPoint.id,
fixture.business.id,
fixture.users.operationsManager.id,
]
);
@@ -221,8 +313,8 @@ async function main() {
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
)
VALUES
($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open"}'::jsonb),
($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed"}'::jsonb)
($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open","orderType":"ONE_TIME"}'::jsonb),
($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed","orderType":"ONE_TIME"}'::jsonb)
`,
[
fixture.orders.open.id,
@@ -245,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(
`
INSERT INTO shifts (
@@ -283,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(
`
INSERT INTO shift_roles (
@@ -303,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(
`
INSERT INTO applications (
@@ -343,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(
`
INSERT INTO attendance_events (
@@ -411,30 +632,70 @@ async function main() {
await client.query(
`
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
VALUES ($1, $2, 'CERTIFICATION', $3, $4, '{"seeded":true}'::jsonb)
`,
[fixture.documents.foodSafety.id, fixture.tenant.id, fixture.documents.foodSafety.name, fixture.roles.barista.code]
);
await client.query(
`
INSERT INTO staff_documents (id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata)
VALUES ($1, $2, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb)
VALUES
($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb),
($4, $2, 'CERTIFICATION', $5, $10, '{"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.staffDocuments.foodSafety.id,
fixture.documents.governmentId.id,
fixture.tenant.id,
fixture.staff.ana.id,
fixture.documents.governmentId.name,
fixture.documents.foodSafety.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
hoursFromNow(24 * 180),
fixture.documents.foodSafety.name,
fixture.documents.attireBlackShirt.id,
fixture.documents.attireBlackShirt.name,
fixture.documents.taxFormI9.id,
fixture.documents.taxFormI9.name,
fixture.roles.barista.code,
fixture.documents.taxFormW4.id,
fixture.documents.taxFormW4.name,
]
);
await client.query(
`
INSERT INTO certificates (id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, metadata)
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', '{"seeded":true}'::jsonb)
INSERT INTO staff_documents (
id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata
)
VALUES
($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb),
($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::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.governmentId.id,
fixture.tenant.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,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
hoursFromNow(24 * 180),
fixture.staffDocuments.attireBlackShirt.id,
fixture.documents.attireBlackShirt.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`,
fixture.staffDocuments.taxFormW4.id,
fixture.documents.taxFormW4.id,
`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(
`
INSERT INTO certificates (
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,
@@ -442,6 +703,13 @@ async function main() {
fixture.staff.ana.id,
hoursFromNow(-24 * 30),
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',
}),
]
);
@@ -472,8 +740,8 @@ async function main() {
provider_name, provider_reference, last4, is_primary, metadata
)
VALUES
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true}'::jsonb),
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true}'::jsonb)
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0001"}'::jsonb),
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0002"}'::jsonb)
`,
[
fixture.accounts.businessPrimary.id,
@@ -490,7 +758,7 @@ async function main() {
id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code,
subtotal_cents, tax_cents, total_cents, due_at, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true}'::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true,"savingsCents":1250}'::jsonb)
`,
[
fixture.invoices.completed.id,
@@ -573,6 +841,7 @@ async function main() {
businessId: fixture.business.id,
vendorId: fixture.vendor.id,
staffId: fixture.staff.ana.id,
staffUserId: fixture.users.staffAna.id,
workforceId: fixture.workforce.ana.id,
openOrderId: fixture.orders.open.id,
openShiftId: fixture.shifts.open.id,

View File

@@ -20,6 +20,11 @@ export const V2DemoFixture = {
email: 'vendor+v2@krowd.com',
displayName: 'Vendor Manager',
},
staffAna: {
id: process.env.V2_DEMO_STAFF_UID || 'demo-staff-ana',
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
displayName: 'Ana Barista',
},
},
business: {
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
@@ -31,6 +36,12 @@ export const V2DemoFixture = {
slug: 'legendary-pool-a',
name: 'Legendary Staffing Pool A',
},
costCenters: {
cafeOps: {
id: '31db54dd-9b32-4504-9056-9c71a9f73001',
name: 'Cafe Operations',
},
},
roles: {
barista: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
@@ -67,6 +78,25 @@ export const V2DemoFixture = {
geofenceRadiusMeters: 120,
nfcTagUid: 'NFC-DEMO-ANA-001',
},
hubManagers: {
opsLead: {
id: '3f2dfd17-e6b4-4fe4-9fea-3c91c7ca8001',
},
},
availability: {
monday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902001',
},
friday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902002',
},
},
benefits: {
commuter: {
id: 'dbd28438-66b0-452f-a5fc-dd0f3ea61001',
title: 'Commuter Support',
},
},
orders: {
open: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
@@ -78,6 +108,11 @@ export const V2DemoFixture = {
number: 'ORD-V2-COMP-1002',
title: 'Completed catering shift',
},
active: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518003',
number: 'ORD-V2-ACT-1003',
title: 'Live staffing operations',
},
},
shifts: {
open: {
@@ -90,6 +125,26 @@ export const V2DemoFixture = {
code: 'SHIFT-V2-COMP-1',
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: {
openBarista: {
@@ -98,6 +153,18 @@ export const V2DemoFixture = {
completedBarista: {
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: {
openAna: {
@@ -108,6 +175,15 @@ export const V2DemoFixture = {
completedAna: {
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: {
completedAna: {
@@ -136,21 +212,54 @@ export const V2DemoFixture = {
},
},
documents: {
governmentId: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000',
name: 'Government ID',
},
foodSafety: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
name: 'Food Handler Card',
},
attireBlackShirt: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
name: 'Black Shirt',
},
taxFormI9: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
name: 'I-9',
},
taxFormW4: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004',
name: 'W-4',
},
},
staffDocuments: {
governmentId: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95000',
},
foodSafety: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
},
attireBlackShirt: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
},
taxFormI9: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
},
taxFormW4: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95004',
},
},
certificates: {
foodSafety: {
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
},
},
emergencyContacts: {
primary: {
id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001',
},
},
accounts: {
businessPrimary: {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',

View File

@@ -0,0 +1,64 @@
CREATE TABLE IF NOT EXISTS cost_centers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
code TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_business_name
ON cost_centers (business_id, name);
ALTER TABLE clock_points
ADD COLUMN IF NOT EXISTS cost_center_id UUID REFERENCES cost_centers(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS hub_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
hub_id UUID NOT NULL REFERENCES clock_points(id) ON DELETE CASCADE,
business_membership_id UUID NOT NULL REFERENCES business_memberships(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_managers_hub_membership
ON hub_managers (hub_id, business_membership_id);
CREATE TABLE IF NOT EXISTS staff_availability (
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,
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
availability_status TEXT NOT NULL DEFAULT 'UNAVAILABLE'
CHECK (availability_status IN ('AVAILABLE', 'UNAVAILABLE', 'PARTIAL')),
time_slots JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_availability_staff_day
ON staff_availability (staff_id, day_of_week);
CREATE TABLE IF NOT EXISTS staff_benefits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
benefit_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING')),
tracked_hours INTEGER NOT NULL DEFAULT 0 CHECK (tracked_hours >= 0),
target_hours INTEGER NOT NULL DEFAULT 0 CHECK (target_hours >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_benefits_staff_type
ON staff_benefits (staff_id, benefit_type);

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

View File

@@ -5,6 +5,7 @@ import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createCommandsRouter } from './routes/commands.js';
import { createMobileCommandsRouter } from './routes/mobile.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
@@ -22,6 +23,7 @@ export function createApp(options = {}) {
app.use(healthRouter);
app.use('/commands', createCommandsRouter(options.commandHandlers));
app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers));
app.use(notFoundHandler);
app.use(errorHandler);

View 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(),
});

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
const roleSchema = z.object({
roleId: z.string().uuid().optional(),
roleCode: z.string().min(1).max(100),
roleName: z.string().min(1).max(120),
workersNeeded: z.number().int().positive(),

View 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;
}

View 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;
}

View File

@@ -562,6 +562,7 @@ export async function createOrder(actor, payload) {
`
INSERT INTO shift_roles (
shift_id,
role_id,
role_code,
role_name,
workers_needed,
@@ -570,10 +571,11 @@ export async function createOrder(actor, payload) {
bill_rate_cents,
metadata
)
VALUES ($1, $2, $3, $4, 0, $5, $6, $7::jsonb)
VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8::jsonb)
`,
[
shift.id,
roleInput.roleId || null,
roleInput.roleCode,
roleInput.roleName,
roleInput.workersNeeded,

View File

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

File diff suppressed because it is too large Load Diff

View 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');
});

View File

@@ -13,6 +13,7 @@
"firebase-admin": "^13.0.2",
"google-auth-library": "^9.15.1",
"multer": "^2.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"
@@ -2037,6 +2038,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"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": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -2086,6 +2176,45 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",

View File

@@ -16,6 +16,7 @@
"firebase-admin": "^13.0.2",
"google-auth-library": "^9.15.1",
"multer": "^2.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"

View File

@@ -24,6 +24,12 @@ import {
retryVerificationJob,
reviewVerificationJob,
} 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_SIGNED_URL_SECONDS = 900;
@@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({
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) {
const encoded = encodeURIComponent(fileUri);
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,
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) {
try {
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({
...job,
requestId: req.requestId,
@@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
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({
...updated,
requestId: req.requestId,
@@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) {
async function handleRetryVerification(req, res, next) {
try {
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({
...updated,
requestId: req.requestId,
@@ -353,6 +464,11 @@ export function createCoreRouter() {
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
router.post('/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.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
@@ -13,3 +14,31 @@ function healthHandler(req, res) {
healthRouter.get('/health', 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,
});
});

View 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;
}

View 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;
}
}

View 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,
};
}

View File

@@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { isDatabaseConfigured, query, withTransaction } from './db.js';
import { requireTenantContext } from './actor-context.js';
import { invokeVertexMultimodalModel } from './llm.js';
const jobs = new Map();
export const VerificationStatus = Object.freeze({
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
@@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({
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([
VerificationStatus.APPROVED,
VerificationStatus.REJECTED,
]);
function nowIso() {
return new Date().toISOString();
const memoryVerificationJobs = new Map();
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() {
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
}
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
return {
id: crypto.randomUUID(),
fromStatus,
toStatus,
actorType,
actorId,
details,
createdAt: nowIso(),
};
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
}
function toPublicJob(job) {
return {
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 attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
}
function clampConfidence(value, fallback = 0.5) {
@@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) {
return [fallback];
}
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
}
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
function toPublicJob(row) {
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) {
@@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) {
signal: controller.signal,
});
const bodyText = await response.text();
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.35,
reasons: [`${provider.name} returned ${response.status}`],
extracted: {},
provider: {
name: provider.name,
reference: body?.reference || null,
},
};
throw new Error(payload?.error || payload?.message || `${provider.name} failed`);
}
return {
status: normalizeMachineStatus(body.status),
confidence: clampConfidence(body.confidence, 0.6),
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
extracted: body.extracted || {},
status: normalizeMachineStatus(payload.status),
confidence: clampConfidence(payload.confidence, 0.6),
reasons: asReasonList(payload.reasons, `${provider.name} completed`),
extracted: payload.extracted || {},
provider: {
name: provider.name,
reference: body.reference || null,
reference: payload.reference || null,
},
};
} catch (error) {
const isAbort = error?.name === 'AbortError';
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.3,
reasons: [
isAbort
? `${provider.name} timeout, manual review required`
: `${provider.name} unavailable, manual review required`,
],
confidence: 0.35,
reasons: [error?.message || `${provider.name} unavailable`],
extracted: {},
provider: {
name: provider.name,
@@ -310,136 +379,254 @@ async function runThirdPartyChecks(job, type) {
}
}
async function runMachineChecks(job) {
if (job.type === 'attire') {
return runAttireChecks(job);
async function processVerificationJob(verificationId) {
const startedJob = await withTransaction(async (client) => {
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') {
return runThirdPartyChecks(job, 'government_id');
}
return runThirdPartyChecks(job, 'certification');
}
async function processVerificationJob(id) {
const job = requireJob(id);
const job = result.rows[0];
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;
}
const beforeProcessing = job.status;
job.status = VerificationStatus.PROCESSING;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus: beforeProcessing,
toStatus: VerificationStatus.PROCESSING,
actorType: 'system',
actorId: 'verification-worker',
})
try {
const result = startedJob.type === 'attire'
? await runAttireChecks(startedJob)
: await runThirdPartyChecks(startedJob, startedJob.type);
await withTransaction(async (client) => {
await client.query(
`
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 {
const outcome = await runMachineChecks(job);
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
}
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',
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
toStatus: result.status,
actorType: 'worker',
actorId: 'verification-worker',
details: {
confidence: job.confidence,
reasons: job.reasons,
provider: job.provider,
confidence: result.confidence,
},
})
);
});
});
} catch (error) {
const fromStatus = job.status;
job.status = VerificationStatus.ERROR;
job.confidence = null;
job.reasons = [error?.message || 'Verification processing failed'];
job.extracted = {};
job.provider = {
name: 'verification-worker',
reference: null,
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
await withTransaction(async (client) => {
await client.query(
`
UPDATE verification_jobs
SET status = $2,
reasons = $3::jsonb,
provider_name = 'verification-worker',
provider_reference = $4,
updated_at = NOW()
WHERE id = $1
`,
[
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,
actorType: 'system',
actorType: 'worker',
actorId: 'verification-worker',
details: {
error: error?.message || 'Verification processing failed',
},
})
);
});
});
}
}
function queueVerificationProcessing(id) {
setTimeout(() => {
processVerificationJob(id).catch(() => {});
}, 0);
function queueVerificationProcessing(verificationId) {
setImmediate(() => {
const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob;
worker(verificationId).catch(() => {});
});
}
export function createVerificationJob({ actorUid, payload }) {
const now = nowIso();
const id = `ver_${crypto.randomUUID()}`;
const job = {
id,
export async function createVerificationJob({ actorUid, payload }) {
if (useMemoryStore()) {
const timestamp = new Date().toISOString();
const created = {
id: nextVerificationId(),
tenant_id: null,
staff_id: null,
owner_user_id: actorUid,
type: payload.type,
subjectType: payload.subjectType || null,
subjectId: payload.subjectId || null,
ownerUid: actorUid,
fileUri: payload.fileUri,
rules: payload.rules || {},
metadata: payload.metadata || {},
subject_type: payload.subjectType || null,
subject_id: payload.subjectId || null,
file_uri: payload.fileUri,
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider: null,
review: null,
createdAt: now,
updatedAt: now,
events: [
eventRecord({
provider_name: null,
provider_reference: null,
review: {},
metadata: {
...(payload.metadata || {}),
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,
toStatus: VerificationStatus.PENDING,
actorType: 'system',
actorId: actorUid,
}),
],
};
jobs.set(id, job);
queueVerificationProcessing(id);
return toPublicJob(job);
});
return result.rows[0];
});
queueVerificationProcessing(created.id);
return toPublicJob(created);
}
export function getVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
export async function getVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
const job = await loadJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export function reviewVerificationJob(verificationId, actorUid, review) {
const job = requireJob(verificationId);
export async function reviewVerificationJob(verificationId, actorUid, review) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
@@ -447,64 +634,207 @@ export function reviewVerificationJob(verificationId, actorUid, review) {
});
}
const fromStatus = job.status;
job.status = review.decision;
job.review = {
const reviewPayload = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: nowIso(),
reviewedAt: new Date().toISOString(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
const updated = {
...job,
status: review.decision,
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',
actorId: actorUid,
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) {
const job = requireJob(verificationId);
export async function retryVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
const fromStatus = job.status;
job.status = VerificationStatus.PENDING;
job.confidence = null;
job.reasons = [];
job.extracted = {};
job.provider = null;
job.review = null;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
const updated = {
...job,
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider_name: null,
provider_reference: null,
review: {},
updated_at: new Date().toISOString(),
};
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,
actorType: 'reviewer',
actorId: actorUid,
details: {
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);
return toPublicJob(job);
return toPublicJob(updated);
}
export function __resetVerificationJobsForTests() {
jobs.clear();
export async function __resetVerificationJobsForTests() {
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.
}
}

View File

@@ -5,7 +5,7 @@ import { createApp } from '../src/app.js';
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
beforeEach(() => {
beforeEach(async () => {
process.env.AUTH_BYPASS = 'true';
process.env.LLM_MOCK = 'true';
process.env.SIGNED_URL_MOCK = 'true';
@@ -15,8 +15,9 @@ beforeEach(() => {
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
process.env.VERIFICATION_STORE = 'memory';
__resetLlmRateLimitForTests();
__resetVerificationJobsForTests();
await __resetVerificationJobsForTests();
});
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');
});
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 () => {
process.env.AUTH_BYPASS = 'false';
const app = createApp();

View File

@@ -5,6 +5,7 @@ import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createQueryRouter } from './routes/query.js';
import { createMobileQueryRouter } from './routes/mobile.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
@@ -22,6 +23,7 @@ export function createApp(options = {}) {
app.use(healthRouter);
app.use('/query', createQueryRouter(options.queryService));
app.use('/query', createMobileQueryRouter(options.mobileQueryService));
app.use(notFoundHandler);
app.use(errorHandler);

View 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.',
},
],
},
];

View File

@@ -0,0 +1,651 @@
import { Router } from 'express';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getCoverageReport,
getClientDashboard,
getClientSession,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getReportSummary,
getSavings,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
listPendingInvoices,
listProfileDocuments,
listRecentReorders,
listSkills,
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
getSpendBreakdown,
getSpendReport,
} from '../services/mobile-query-service.js';
const defaultQueryService = {
getClientDashboard,
getClientSession,
getCoverageReport,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getReportSummary,
getSavings,
getSpendBreakdown,
getSpendReport,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
listPendingInvoices,
listProfileDocuments,
listRecentReorders,
listSkills,
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
};
function requireQueryParam(name, value) {
if (!value) {
const error = new Error(`${name} is required`);
error.code = 'VALIDATION_ERROR';
error.status = 400;
error.details = { field: name };
throw error;
}
return value;
}
export function createMobileQueryRouter(queryService = defaultQueryService) {
const router = Router();
router.get('/client/session', requireAuth, requirePolicy('client.session.read', 'session'), async (req, res, next) => {
try {
const data = await queryService.getClientSession(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/dashboard', requireAuth, requirePolicy('client.dashboard.read', 'dashboard'), async (req, res, next) => {
try {
const data = await queryService.getClientDashboard(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reorders', requireAuth, requirePolicy('orders.reorder.read', 'order'), async (req, res, next) => {
try {
const items = await queryService.listRecentReorders(req.actor.uid, req.query.limit);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/accounts', requireAuth, requirePolicy('billing.accounts.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listBusinessAccounts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/invoices/pending', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listPendingInvoices(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/invoices/history', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listInvoiceHistory(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/current-bill', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const data = await queryService.getCurrentBill(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/savings', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const data = await queryService.getSavings(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/spend-breakdown', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.getSpendBreakdown(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageByDate(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/stats', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const data = await queryService.getCoverageStats(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/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) => {
try {
const items = await queryService.listHubs(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/cost-centers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listCostCenters(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/vendors', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
try {
const items = await queryService.listVendors(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/vendors/:vendorId/roles', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
try {
const items = await queryService.listVendorRoles(req.actor.uid, req.params.vendorId);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs/:hubId/managers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubManagers(req.actor.uid, req.params.hubId);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
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) => {
try {
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
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) => {
try {
const data = await queryService.getStaffSession(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => {
try {
const data = await queryService.getStaffDashboard(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile-completion', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getStaffProfileCompletion(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/availability', requireAuth, requirePolicy('staff.availability.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffAvailability(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/clock-in/shifts/today', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
try {
const items = await queryService.listTodayShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/clock-in/status', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
try {
const data = await queryService.getCurrentAttendanceStatus(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/summary', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const data = await queryService.getPaymentsSummary(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/history', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const items = await queryService.listPaymentsHistory(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/chart', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const items = await queryService.getPaymentChart(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/assigned', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listAssignedShifts(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/open', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listOpenShifts(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listPendingAssignments(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/cancelled', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listCancelledShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/completed', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listCompletedShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/:shiftId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const data = await queryService.getStaffShiftDetail(req.actor.uid, req.params.shiftId);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/sections', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getProfileSectionsStatus(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/personal-info', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getPersonalInfo(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/industries', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listIndustries(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/skills', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listSkills(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/documents', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listProfileDocuments(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
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) => {
try {
const items = await queryService.listCertificates(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/bank-accounts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBankAccounts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/benefits', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBenefits(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
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;
}

View 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;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
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 createMobileQueryService() {
return {
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
getClientSession: async () => ({ business: { businessId: 'b1' } }),
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }),
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
getCurrentBill: async () => ({ currentBillCents: 1000 }),
getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }),
getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }),
getNoShowReport: async () => ({ totals: { noShows: 1 } }),
getPaymentChart: async () => ([{ amountCents: 100 }]),
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
getPersonalInfo: async () => ({ firstName: 'Ana' }),
getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }),
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }),
getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }),
getSavings: async () => ({ savingsCents: 200 }),
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
getStaffProfileCompletion: async () => ({ completed: true }),
getStaffSession: async () => ({ staff: { staffId: 's1' } }),
getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }),
listAssignedShifts: async () => ([{ shiftId: 'assigned-1' }]),
listBusinessAccounts: async () => ([{ accountId: 'acc-1' }]),
listCancelledShifts: async () => ([{ shiftId: 'cancelled-1' }]),
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listHubManagers: async () => ([{ managerId: 'm1' }]),
listHubs: async () => ([{ hubId: 'hub-1' }]),
listIndustries: async () => (['CATERING']),
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
listRecentReorders: async () => ([{ id: 'order-1' }]),
listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]),
listSkills: async () => (['BARISTA']),
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
listTaxForms: async () => ([{ formType: 'W4' }]),
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
listVendorRoles: async () => ([{ roleId: 'role-1' }]),
listVendors: async () => ([{ vendorId: 'vendor-1' }]),
searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]),
};
}
test('GET /query/client/session returns injected client session', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/session')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.business.businessId, 'b1');
});
test('GET /query/client/coverage validates date query param', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 400);
assert.equal(res.body.code, 'VALIDATION_ERROR');
});
test('GET /query/staff/dashboard returns injected dashboard', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/dashboard')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.staffName, 'Ana Barista');
});
test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/shifts/shift-1')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
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');
});

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV PORT=8080
EXPOSE 8080
CMD ["node", "src/server.js"]

3661
backend/unified-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "@krow/unified-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"
},
"devDependencies": {
"supertest": "^7.0.0"
}
}

View 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);
});

View 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);
});

View File

@@ -0,0 +1,31 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createAuthRouter } from './routes/auth.js';
import { createProxyRouter } from './routes/proxy.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: req.requestId }),
})
);
app.use(healthRouter);
app.use('/auth', createAuthRouter({ fetchImpl: options.fetchImpl, authService: options.authService }));
app.use(createProxyRouter(options));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,26 @@
export class AppError extends Error {
constructor(code, message, status = 400, details = {}) {
super(message);
this.name = 'AppError';
this.code = code;
this.status = status;
this.details = details;
}
}
export function toErrorEnvelope(error, requestId) {
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
const code = error?.code || 'INTERNAL_ERROR';
const message = error?.message || 'Unexpected error';
const details = error?.details || {};
return {
status,
body: {
code,
message,
details,
requestId,
},
};
}

View File

@@ -0,0 +1,25 @@
import { toErrorEnvelope } from '../lib/errors.js';
export function notFoundHandler(req, res) {
res.status(404).json({
code: 'NOT_FOUND',
message: `Route not found: ${req.method} ${req.path}`,
details: {},
requestId: req.requestId,
});
}
export function errorHandler(error, req, res, _next) {
const envelope = toErrorEnvelope(error, req.requestId);
if (req.log) {
req.log.error(
{
errCode: envelope.body.code,
status: envelope.status,
details: envelope.body.details,
},
envelope.body.message
);
}
res.status(envelope.status).json(envelope.body);
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
export function requestContext(req, res, next) {
const incoming = req.get('X-Request-Id');
req.requestId = incoming || randomUUID();
res.setHeader('X-Request-Id', req.requestId);
res.locals.startedAt = Date.now();
next();
}

View File

@@ -0,0 +1,170 @@
import express from 'express';
import { AppError } from '../lib/errors.js';
import {
getSessionForActor,
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
} from '../services/auth-service.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
const defaultAuthService = {
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
getSessionForActor,
};
function getBearerToken(header) {
if (!header) return null;
const [scheme, token] = header.split(' ');
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
return token;
}
async function requireAuth(req, _res, next) {
try {
const token = getBearerToken(req.get('Authorization'));
if (!token) {
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
return next();
}
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
role: decoded.role || null,
};
return next();
} catch (error) {
if (error instanceof AppError) return next(error);
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
}
}
export function createAuthRouter(options = {}) {
const router = express.Router();
const fetchImpl = options.fetchImpl || fetch;
const authService = options.authService || defaultAuthService;
router.use(express.json({ limit: '1mb' }));
router.post('/client/sign-in', async (req, res, next) => {
try {
const payload = authService.parseClientSignIn(req.body);
const session = await authService.signInClient(payload, { fetchImpl });
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/client/sign-up', async (req, res, next) => {
try {
const payload = authService.parseClientSignUp(req.body);
const session = await authService.signUpClient(payload, { fetchImpl });
return res.status(201).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
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) => {
try {
const session = await authService.getSessionForActor(req.actor);
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/client/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/staff/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
return router;
}

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
function healthHandler(req, res) {
res.status(200).json({
ok: true,
service: 'krow-api-v2',
version: process.env.SERVICE_VERSION || 'dev',
requestId: req.requestId,
});
}
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-api-v2',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-api-v2',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-api-v2',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,156 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'content-length',
'host',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const DIRECT_CORE_ALIASES = [
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/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;
}
function copyHeaders(source, target) {
for (const [key, value] of source.entries()) {
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
target.setHeader(key, value);
}
}
async function forwardRequest(req, res, next, fetchImpl) {
try {
const requestUrl = new URL(req.originalUrl, 'http://localhost');
const target = resolveTarget(requestUrl.pathname, req.method);
if (!target?.baseUrl) {
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
}
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else {
headers.set(key, value);
}
}
headers.set('x-request-id', req.requestId);
const upstream = await fetchImpl(url, {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
duplex: req.method === 'GET' || req.method === 'HEAD' ? undefined : 'half',
});
copyHeaders(upstream.headers, res);
res.status(upstream.status);
const buffer = Buffer.from(await upstream.arrayBuffer());
return res.send(buffer);
} catch (error) {
return next(error);
}
}
export function createProxyRouter(options = {}) {
const router = Router();
const fetchImpl = options.fetchImpl || fetch;
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
return router;
}

View File

@@ -0,0 +1,9 @@
import { createApp } from './app.js';
const port = Number(process.env.PORT || 8080);
const app = createApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`krow-api-v2 listening on port ${port}`);
});

View File

@@ -0,0 +1,304 @@
import { z } from 'zod';
import { AppError } from '../lib/errors.js';
import { withTransaction } from './db.js';
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
import {
deleteAccount,
sendVerificationCode,
signInWithPassword,
signInWithPhoneNumber,
signUpWithPassword,
} from './identity-toolkit.js';
import { loadActorContext } from './user-context.js';
const clientSignInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const clientSignUpSchema = z.object({
companyName: z.string().min(2).max(120),
email: z.string().email(),
password: z.string().min(8),
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) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function buildAuthEnvelope(authPayload, context) {
return {
sessionToken: authPayload.idToken,
refreshToken: authPayload.refreshToken,
expiresInSeconds: Number.parseInt(`${authPayload.expiresIn || 3600}`, 10),
user: {
id: context.user?.userId || authPayload.localId,
email: context.user?.email || null,
displayName: context.user?.displayName || null,
phone: context.user?.phone || null,
},
tenant: context.tenant,
business: context.business,
vendor: context.vendor,
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) {
const parsed = clientSignInSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-in payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export function parseClientSignUp(body) {
const parsed = clientSignUpSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-up payload', 400, {
issues: parsed.error.issues,
});
}
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) {
return loadActorContext(actor.uid);
}
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
const authPayload = await signInWithPassword(payload, fetchImpl);
const decoded = await verifyFirebaseToken(authPayload.idToken);
await upsertUserFromDecodedToken(decoded, payload);
const context = await loadActorContext(decoded.uid);
if (!context.user || !context.business) {
throw new AppError('FORBIDDEN', 'Authenticated user does not have a client business membership', 403, {
uid: decoded.uid,
email: decoded.email || null,
});
}
return buildAuthEnvelope(authPayload, context);
}
export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
const authPayload = await signUpWithPassword(payload, fetchImpl);
try {
const decoded = await verifyFirebaseToken(authPayload.idToken);
const defaultDisplayName = payload.displayName || payload.companyName;
const tenantSlug = slugify(payload.companyName);
const businessSlug = tenantSlug;
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO users (id, email, display_name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = NOW()
`,
[decoded.uid, payload.email, defaultDisplayName]
);
const tenantResult = await client.query(
`
INSERT INTO tenants (slug, name, status, metadata)
VALUES ($1, $2, 'ACTIVE', '{"source":"unified-api-sign-up"}'::jsonb)
RETURNING id, slug, name
`,
[tenantSlug, payload.companyName]
);
const tenant = tenantResult.rows[0];
const businessResult = await client.query(
`
INSERT INTO businesses (
tenant_id, slug, business_name, status, contact_name, contact_email, metadata
)
VALUES ($1, $2, $3, 'ACTIVE', $4, $5, '{"source":"unified-api-sign-up"}'::jsonb)
RETURNING id, slug, business_name
`,
[tenant.id, businessSlug, payload.companyName, defaultDisplayName, payload.email]
);
const business = businessResult.rows[0];
await client.query(
`
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
VALUES ($1, $2, 'ACTIVE', 'admin', '{"source":"sign-up"}'::jsonb)
`,
[tenant.id, decoded.uid]
);
await client.query(
`
INSERT INTO business_memberships (tenant_id, business_id, user_id, membership_status, business_role, metadata)
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"source":"sign-up"}'::jsonb)
`,
[tenant.id, business.id, decoded.uid]
);
});
const context = await loadActorContext(decoded.uid);
return buildAuthEnvelope(authPayload, context);
} catch (error) {
await deleteAccount({ idToken: authPayload.idToken }, fetchImpl).catch(() => null);
throw error;
}
}
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) {
await revokeUserSessions(actor.uid);
return { signedOut: true };
}

View File

@@ -0,0 +1,87 @@
import { Pool } from 'pg';
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;
}
}

View File

@@ -0,0 +1,23 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function ensureAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
export async function verifyFirebaseToken(token, { checkRevoked = false } = {}) {
ensureAdminApp();
return getAuth().verifyIdToken(token, checkRevoked);
}
export async function revokeUserSessions(uid) {
ensureAdminApp();
await getAuth().revokeRefreshTokens(uid);
}
export async function createCustomToken(uid) {
ensureAdminApp();
return getAuth().createCustomToken(uid);
}

View File

@@ -0,0 +1,92 @@
import { AppError } from '../lib/errors.js';
const IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1';
function getApiKey() {
const apiKey = process.env.FIREBASE_WEB_API_KEY;
if (!apiKey) {
throw new AppError('CONFIGURATION_ERROR', 'FIREBASE_WEB_API_KEY is required', 500);
}
return apiKey;
}
async function callIdentityToolkit(path, payload, fetchImpl = fetch) {
const response = await fetchImpl(`${IDENTITY_TOOLKIT_BASE_URL}/${path}?key=${getApiKey()}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new AppError(
'AUTH_PROVIDER_ERROR',
json?.error?.message || `Identity Toolkit request failed: ${path}`,
response.status,
{ provider: 'firebase-identity-toolkit', path }
);
}
return json;
}
export async function signInWithPassword({ email, password }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithPassword',
{
email,
password,
returnSecureToken: true,
},
fetchImpl
);
}
export async function signUpWithPassword({ email, password }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signUp',
{
email,
password,
returnSecureToken: true,
},
fetchImpl
);
}
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) {
return callIdentityToolkit(
'accounts:delete',
{ idToken },
fetchImpl
);
}

View File

@@ -0,0 +1,91 @@
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.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
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,
};
}

View File

@@ -0,0 +1,184 @@
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';
test('GET /healthz returns healthy response', async () => {
const app = createApp();
const res = await request(app).get('/healthz');
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.service, 'krow-api-v2');
});
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;
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 /auth/client/sign-in validates payload', async () => {
const app = createApp();
const res = await request(app).post('/auth/client/sign-in').send({
email: 'bad-email',
password: 'short',
});
assert.equal(res.status, 400);
assert.equal(res.body.code, 'VALIDATION_ERROR');
});
test('POST /auth/client/sign-in returns injected auth envelope', async () => {
const app = createApp({
authService: {
parseClientSignIn: (body) => body,
parseClientSignUp: (body) => body,
signInClient: async () => ({
sessionToken: 'token',
refreshToken: 'refresh',
expiresInSeconds: 3600,
user: { id: 'u1', email: 'legendary@krowd.com' },
tenant: { tenantId: 't1' },
business: { businessId: 'b1' },
}),
signUpClient: async () => assert.fail('signUpClient should not be called'),
signOutActor: async () => ({ signedOut: true }),
getSessionForActor: async () => ({ user: { userId: 'u1' } }),
},
});
const res = await request(app).post('/auth/client/sign-in').send({
email: 'legendary@krowd.com',
password: 'super-secret',
});
assert.equal(res.status, 200);
assert.equal(res.body.sessionToken, 'token');
assert.equal(res.body.business.businessId, 'b1');
});
test('GET /auth/session returns injected session for authenticated actor', async () => {
const app = createApp({
authService: {
parseClientSignIn: (body) => body,
parseClientSignUp: (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 (actor) => ({ actorUid: actor.uid }),
},
});
const res = await request(app)
.get('/auth/session')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.actorUid, 'test-user');
});
test('proxy forwards query routes to query base url', 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('/query/test-route?foo=bar');
assert.equal(res.status, 200);
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');
});

View 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);
});

View File

@@ -2,37 +2,70 @@
This is the frontend-facing source of truth for the v2 backend.
If you are building against the new backend, start here.
## 1) Use one base URL
## 1) Which service to use
Frontend should call one public gateway:
| Use case | Service |
| --- | --- |
| File upload, signed URLs, model calls, rapid order helpers, verification flows | `core-api-v2` |
| Business writes and workflow actions | `command-api-v2` |
| Screen reads for the implemented v2 views | `query-api-v2` |
```env
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app
```
## 2) Live dev base URLs
Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly.
- Core API: `https://krow-core-api-v2-e3g6witsvq-uc.a.run.app`
- Command API: `https://krow-command-api-v2-e3g6witsvq-uc.a.run.app`
- Query API: `https://krow-query-api-v2-e3g6witsvq-uc.a.run.app`
## 2) Current status
The unified v2 gateway is ready for frontend integration in `dev`.
What was validated live against the deployed stack:
- client sign-in
- staff auth bootstrap
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
- client hub and order write flows
- client invoice approve and dispute
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
- staff availability, profile, tax form, bank account, shift apply, shift accept, 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
All protected routes require:
Protected routes require:
```http
Authorization: Bearer <firebase-id-token>
```
All command routes also require:
Write routes also require:
```http
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
{
@@ -43,78 +76,40 @@ All services return the same error envelope:
}
```
## 4) What frontend can use now
## 4) Route model
### Ready now
Frontend sees one base URL and one route shape:
- `core-api-v2`
- upload file
- create signed URL
- invoke model
- rapid order transcribe
- rapid order parse
- create verification
- get verification
- review verification
- retry verification
- `command-api-v2`
- create order
- update order
- cancel order
- assign staff to shift
- accept shift
- change shift status
- clock in
- clock out
- favorite and unfavorite staff
- create staff review
- `query-api-v2`
- order list
- order detail
- favorite staff list
- staff review summary
- assignment attendance detail
- `/auth/*`
- `/client/*`
- `/staff/*`
- direct upload aliases like `/upload-file` and `/staff/profile/*`
### Do not move yet
Internally, the gateway still forwards to:
- reports
- payments and finance screens
- undocumented dashboard reads
- undocumented scheduling reads and writes
- any flow that assumes verification history is durable in SQL
| Frontend use case | Internal service |
| --- | --- |
| auth/session wrapper | `krow-api-v2` |
| uploads, signed URLs, model calls, verification workflows | `core-api-v2` |
| writes and workflow actions | `command-api-v2` |
| reads and mobile read models | `query-api-v2` |
## 5) Important caveat
## 5) Frontend integration rule
`core-api-v2` is usable now, but verification job state is not yet persisted to `krow-sql-v2`.
Use the unified routes first.
What is durable today:
- uploaded files in Google Cloud Storage
- generated signed URLs
- model invocation itself
Do not build new frontend work on:
What is not yet durable:
- verification job history
- verification review history
- verification event history
- `/query/tenants/*`
- `/commands/*`
- `/core/*`
That means frontend can integrate with verification routes now, but should not treat them as mission-critical durable state yet.
Those routes still exist for backend/internal compatibility, but mobile/frontend migration should target the unified surface documented in [Unified API](./unified-api.md).
## 6) Recommended frontend environment variables
```env
CORE_API_V2_BASE_URL=https://krow-core-api-v2-e3g6witsvq-uc.a.run.app
COMMAND_API_V2_BASE_URL=https://krow-command-api-v2-e3g6witsvq-uc.a.run.app
QUERY_API_V2_BASE_URL=https://krow-query-api-v2-e3g6witsvq-uc.a.run.app
```
## 7) Service docs
## 6) Docs
- [Unified API](./unified-api.md)
- [Core API](./core-api.md)
- [Command API](./command-api.md)
- [Query API](./query-api.md)
## 8) Frontend integration rule
Do not point screens directly at database access just because a route does not exist yet.
If a screen is missing from the docs, the next step is to define the route contract and add it to `query-api-v2` or `command-api-v2`.
- [Mobile API Reconciliation](./mobile-api-gap-analysis.md)

View File

@@ -0,0 +1,45 @@
# Mobile API Reconciliation
Source compared against implementation:
- `mobile-backend-api-specification.md`
## Result
The current mobile v2 surface is implemented behind the unified gateway and validated live in `dev`.
That includes:
- 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
## What was validated live
The live smoke executed successfully against:
- `https://krow-api-v2-933560802882.us-central1.run.app`
- Firebase demo users
- `krow-sql-v2`
- `krow-core-api-v2`
- `krow-command-api-v2`
- `krow-query-api-v2`
The validation script is:
```bash
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
```
## Remaining work
The remaining items are not blockers for current mobile frontend migration.
They are follow-up items:
- 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

View File

@@ -0,0 +1,168 @@
# Unified API V2
Frontend should use this service as the single base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app`
The gateway keeps backend services separate internally, but frontend should treat it as one API.
## 1) Auth routes
### Client auth
- `POST /auth/client/sign-in`
- `POST /auth/client/sign-up`
- `POST /auth/client/sign-out`
### Staff auth
- `POST /auth/staff/phone/start`
- `POST /auth/staff/phone/verify`
- `POST /auth/staff/sign-out`
### Shared auth
- `GET /auth/session`
- `POST /auth/sign-out`
## 2) Client routes
### Client reads
- `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`
### Client writes
- `POST /client/orders/one-time`
- `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`
## 3) Staff routes
### Staff reads
- `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`
### Staff writes
- `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

View File

@@ -40,12 +40,14 @@ BACKEND_V2_ARTIFACT_REPO ?= krow-backend-v2
BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-api-v2
BACKEND_V2_UNIFIED_SERVICE_NAME ?= krow-api-v2
BACKEND_V2_RUNTIME_SA_NAME ?= krow-backend-v2-runtime
BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
BACKEND_V2_CORE_DIR ?= backend/core-api
BACKEND_V2_COMMAND_DIR ?= backend/command-api
BACKEND_V2_QUERY_DIR ?= backend/query-api
BACKEND_V2_UNIFIED_DIR ?= backend/unified-api
BACKEND_V2_SQL_INSTANCE ?= krow-sql-v2
BACKEND_V2_SQL_DATABASE ?= krow_v2_db
@@ -72,8 +74,10 @@ endif
BACKEND_V2_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/core-api-v2:latest
BACKEND_V2_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/command-api-v2:latest
BACKEND_V2_QUERY_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/query-api-v2:latest
BACKEND_V2_UNIFIED_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/unified-api-v2:latest
BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET ?= firebase-web-api-key
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
backend-help:
@echo "--> Backend Foundation Commands"
@@ -92,11 +96,13 @@ backend-help:
@echo " make backend-deploy-core-v2 [ENV=dev] Build + deploy core API v2 service"
@echo " make backend-deploy-commands-v2 [ENV=dev] Build + deploy command API v2 service"
@echo " make backend-deploy-query-v2 [ENV=dev] Build + deploy query API v2 service"
@echo " make backend-deploy-unified-v2 [ENV=dev] Build + deploy unified API v2 gateway"
@echo " make backend-v2-migrate-schema Apply v2 domain schema against krow-sql-v2"
@echo " make backend-v2-migrate-idempotency Apply command idempotency migration against v2 DB"
@echo " make backend-smoke-core-v2 [ENV=dev] Smoke test core API v2 /health"
@echo " make backend-smoke-commands-v2 [ENV=dev] Smoke test command API v2 /health"
@echo " make backend-smoke-query-v2 [ENV=dev] Smoke test query API v2 /health"
@echo " make backend-smoke-unified-v2 [ENV=dev] Smoke test unified API v2 /health"
@echo " make backend-logs-core-v2 [ENV=dev] Read core API v2 logs"
backend-enable-apis:
@@ -343,12 +349,15 @@ backend-deploy-core-v2:
@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)
@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) \
--region=$(BACKEND_REGION) \
--project=$(GCP_PROJECT_ID) \
--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)
@echo "✅ Core backend v2 service deployed."
@@ -385,6 +394,33 @@ backend-deploy-query-v2:
$(BACKEND_V2_RUN_AUTH_FLAG)
@echo "✅ Query backend v2 service deployed."
backend-deploy-unified-v2:
@echo "--> Deploying unified backend v2 gateway [$(BACKEND_V2_UNIFIED_SERVICE_NAME)] to [$(ENV)]..."
@test -d $(BACKEND_V2_UNIFIED_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_UNIFIED_DIR)" && exit 1)
@test -f $(BACKEND_V2_UNIFIED_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_UNIFIED_DIR)/Dockerfile" && exit 1)
@if ! gcloud secrets describe $(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \
echo "❌ Missing secret: $(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET)"; \
exit 1; \
fi
@CORE_URL=$$(gcloud run services describe $(BACKEND_V2_CORE_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
COMMAND_URL=$$(gcloud run services describe $(BACKEND_V2_COMMAND_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
QUERY_URL=$$(gcloud run services describe $(BACKEND_V2_QUERY_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
if [ -z "$$CORE_URL" ] || [ -z "$$COMMAND_URL" ] || [ -z "$$QUERY_URL" ]; then \
echo "❌ Core, command, and query v2 services must be deployed before unified gateway"; \
exit 1; \
fi; \
gcloud builds submit $(BACKEND_V2_UNIFIED_DIR) --tag $(BACKEND_V2_UNIFIED_IMAGE) --project=$(GCP_PROJECT_ID); \
gcloud run deploy $(BACKEND_V2_UNIFIED_SERVICE_NAME) \
--image=$(BACKEND_V2_UNIFIED_IMAGE) \
--region=$(BACKEND_REGION) \
--project=$(GCP_PROJECT_ID) \
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
--set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),CORE_API_BASE_URL=$$CORE_URL,COMMAND_API_BASE_URL=$$COMMAND_URL,QUERY_API_BASE_URL=$$QUERY_URL \
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest,FIREBASE_WEB_API_KEY=$(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET):latest \
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
$(BACKEND_V2_RUN_AUTH_FLAG)
@echo "✅ Unified backend v2 gateway deployed."
backend-v2-migrate-idempotency:
@echo "--> Applying idempotency table migration for command API v2..."
@test -n "$(IDEMPOTENCY_DATABASE_URL)$(DATABASE_URL)" || (echo "❌ IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required" && exit 1)
@@ -405,7 +441,7 @@ backend-smoke-core-v2:
exit 1; \
fi; \
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:
@echo "--> Running command v2 smoke check..."
@@ -427,6 +463,16 @@ backend-smoke-query-v2:
TOKEN=$$(gcloud auth print-identity-token); \
curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Query v2 smoke check passed: $$URL/readyz"
backend-smoke-unified-v2:
@echo "--> Running unified v2 smoke check..."
@URL=$$(gcloud run services describe $(BACKEND_V2_UNIFIED_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
if [ -z "$$URL" ]; then \
echo "❌ Could not resolve URL for service $(BACKEND_V2_UNIFIED_SERVICE_NAME)"; \
exit 1; \
fi; \
TOKEN=$$(gcloud auth print-identity-token); \
curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Unified v2 smoke check passed: $$URL/readyz"
backend-logs-core-v2:
@echo "--> Reading logs for core backend v2 service [$(BACKEND_V2_CORE_SERVICE_NAME)]..."
@gcloud run services logs read $(BACKEND_V2_CORE_SERVICE_NAME) \