From 817a39e30571d8dba8d133984953dc9b72169b01 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:00 +0100 Subject: [PATCH 1/2] feat(api): add unified v2 gateway and mobile read slice --- .../command-api/scripts/seed-v2-demo-data.mjs | 124 +- .../command-api/scripts/v2-demo-fixture.mjs | 44 + .../sql/v2/002_v2_mobile_support.sql | 64 + backend/query-api/src/app.js | 2 + backend/query-api/src/routes/mobile.js | 464 +++ .../query-api/src/services/actor-context.js | 111 + .../src/services/mobile-query-service.js | 1071 +++++ backend/query-api/test/mobile-routes.test.js | 91 + backend/unified-api/Dockerfile | 13 + backend/unified-api/package-lock.json | 3661 +++++++++++++++++ backend/unified-api/package.json | 24 + backend/unified-api/src/app.js | 31 + backend/unified-api/src/lib/errors.js | 26 + .../src/middleware/error-handler.js | 25 + .../src/middleware/request-context.js | 9 + backend/unified-api/src/routes/auth.js | 129 + backend/unified-api/src/routes/health.js | 45 + backend/unified-api/src/routes/proxy.js | 75 + backend/unified-api/src/server.js | 9 + .../unified-api/src/services/auth-service.js | 157 + backend/unified-api/src/services/db.js | 87 + .../unified-api/src/services/firebase-auth.js | 18 + .../src/services/identity-toolkit.js | 65 + .../unified-api/src/services/user-context.js | 91 + backend/unified-api/test/app.test.js | 112 + docs/BACKEND/API_GUIDES/V2/README.md | 166 +- .../API_GUIDES/V2/mobile-api-gap-analysis.md | 66 + docs/BACKEND/API_GUIDES/V2/unified-api.md | 50 + makefiles/backend.mk | 45 +- 29 files changed, 6788 insertions(+), 87 deletions(-) create mode 100644 backend/command-api/sql/v2/002_v2_mobile_support.sql create mode 100644 backend/query-api/src/routes/mobile.js create mode 100644 backend/query-api/src/services/actor-context.js create mode 100644 backend/query-api/src/services/mobile-query-service.js create mode 100644 backend/query-api/test/mobile-routes.test.js create mode 100644 backend/unified-api/Dockerfile create mode 100644 backend/unified-api/package-lock.json create mode 100644 backend/unified-api/package.json create mode 100644 backend/unified-api/src/app.js create mode 100644 backend/unified-api/src/lib/errors.js create mode 100644 backend/unified-api/src/middleware/error-handler.js create mode 100644 backend/unified-api/src/middleware/request-context.js create mode 100644 backend/unified-api/src/routes/auth.js create mode 100644 backend/unified-api/src/routes/health.js create mode 100644 backend/unified-api/src/routes/proxy.js create mode 100644 backend/unified-api/src/server.js create mode 100644 backend/unified-api/src/services/auth-service.js create mode 100644 backend/unified-api/src/services/db.js create mode 100644 backend/unified-api/src/services/firebase-auth.js create mode 100644 backend/unified-api/src/services/identity-toolkit.js create mode 100644 backend/unified-api/src/services/user-context.js create mode 100644 backend/unified-api/test/app.test.js create mode 100644 docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md create mode 100644 docs/BACKEND/API_GUIDES/V2/unified-api.md diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index c14ce89b..94a41e0d 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -49,6 +49,7 @@ async function main() { 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 +65,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 +137,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 +169,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 +228,63 @@ 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 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 +295,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, @@ -411,15 +485,30 @@ 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) + VALUES + ($1, $2, 'CERTIFICATION', $3, $6, '{"seeded":true}'::jsonb), + ($4, $2, 'ATTIRE', $5, $6, '{"seeded":true}'::jsonb), + ($7, $2, 'TAX_FORM', $8, $6, '{"seeded":true}'::jsonb) `, - [fixture.documents.foodSafety.id, fixture.tenant.id, fixture.documents.foodSafety.name, fixture.roles.barista.code] + [ + fixture.documents.foodSafety.id, + fixture.tenant.id, + fixture.documents.foodSafety.name, + fixture.documents.attireBlackShirt.id, + fixture.documents.attireBlackShirt.name, + fixture.roles.barista.code, + fixture.documents.taxFormW9.id, + fixture.documents.taxFormW9.name, + ] ); 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, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb), + ($7, $2, $3, $8, $9, 'VERIFIED', NULL, '{"seeded":true}'::jsonb), + ($10, $2, $3, $11, $12, 'VERIFIED', NULL, '{"seeded":true}'::jsonb) `, [ fixture.staffDocuments.foodSafety.id, @@ -428,6 +517,12 @@ async function main() { 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.taxFormW9.id, + fixture.documents.taxFormW9.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w9-form.pdf`, ] ); @@ -472,8 +567,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 +585,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 +668,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, diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 04cea3e0..369ede17 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -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', @@ -140,11 +170,25 @@ export const V2DemoFixture = { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001', name: 'Food Handler Card', }, + attireBlackShirt: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002', + name: 'Black Shirt', + }, + taxFormW9: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003', + name: 'W-9 Tax Form', + }, }, staffDocuments: { foodSafety: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95001', }, + attireBlackShirt: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95002', + }, + taxFormW9: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95003', + }, }, certificates: { foodSafety: { diff --git a/backend/command-api/sql/v2/002_v2_mobile_support.sql b/backend/command-api/sql/v2/002_v2_mobile_support.sql new file mode 100644 index 00000000..1243a28f --- /dev/null +++ b/backend/command-api/sql/v2/002_v2_mobile_support.sql @@ -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); diff --git a/backend/query-api/src/app.js b/backend/query-api/src/app.js index 4e883cb3..43ff81da 100644 --- a/backend/query-api/src/app.js +++ b/backend/query-api/src/app.js @@ -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); diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js new file mode 100644 index 00000000..07674ea7 --- /dev/null +++ b/backend/query-api/src/routes/mobile.js @@ -0,0 +1,464 @@ +import { Router } from 'express'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { + getClientDashboard, + getClientSession, + getCoverageStats, + getCurrentAttendanceStatus, + getCurrentBill, + getPaymentChart, + getPaymentsSummary, + getPersonalInfo, + getProfileSectionsStatus, + getSavings, + getStaffDashboard, + getStaffProfileCompletion, + getStaffSession, + getStaffShiftDetail, + listAssignedShifts, + listBusinessAccounts, + listCancelledShifts, + listCertificates, + listCostCenters, + listCoverageByDate, + listCompletedShifts, + listHubManagers, + listHubs, + listIndustries, + listInvoiceHistory, + listOpenShifts, + listOrderItemsByDateRange, + listPaymentsHistory, + listPendingAssignments, + listPendingInvoices, + listProfileDocuments, + listRecentReorders, + listSkills, + listStaffAvailability, + listStaffBankAccounts, + listStaffBenefits, + listTodayShifts, + listVendorRoles, + listVendors, + getSpendBreakdown, +} from '../services/mobile-query-service.js'; + +const defaultQueryService = { + getClientDashboard, + getClientSession, + getCoverageStats, + getCurrentAttendanceStatus, + getCurrentBill, + getPaymentChart, + getPaymentsSummary, + getPersonalInfo, + getProfileSectionsStatus, + getSavings, + getSpendBreakdown, + getStaffDashboard, + getStaffProfileCompletion, + getStaffSession, + getStaffShiftDetail, + listAssignedShifts, + listBusinessAccounts, + listCancelledShifts, + listCertificates, + listCostCenters, + listCoverageByDate, + listCompletedShifts, + listHubManagers, + listHubs, + listIndustries, + listInvoiceHistory, + listOpenShifts, + listOrderItemsByDateRange, + listPaymentsHistory, + listPendingAssignments, + listPendingInvoices, + listProfileDocuments, + listRecentReorders, + listSkills, + listStaffAvailability, + listStaffBankAccounts, + listStaffBenefits, + listTodayShifts, + listVendorRoles, + listVendors, +}; + +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/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/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('/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/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); + } + }); + + return router; +} diff --git a/backend/query-api/src/services/actor-context.js b/backend/query-api/src/services/actor-context.js new file mode 100644 index 00000000..30d23aa5 --- /dev/null +++ b/backend/query-api/src/services/actor-context.js @@ -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; +} diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js new file mode 100644 index 00000000..166bb331 --- /dev/null +++ b/backend/query-api/src/services/mobile-query-service.js @@ -0,0 +1,1071 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; +import { requireClientContext, requireStaffContext } from './actor-context.js'; + +function parseLimit(value, fallback = 20, max = 100) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function parseDate(value, field) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new AppError('VALIDATION_ERROR', `${field} must be a valid ISO date`, 400, { field }); + } + return date; +} + +function parseDateRange(startDate, endDate, fallbackDays = 7) { + const start = startDate ? parseDate(startDate, 'startDate') : new Date(); + const end = endDate ? parseDate(endDate, 'endDate') : new Date(start.getTime() + (fallbackDays * 24 * 60 * 60 * 1000)); + if (start > end) { + throw new AppError('VALIDATION_ERROR', 'startDate must be before endDate', 400); + } + return { + start: start.toISOString(), + end: end.toISOString(), + }; +} + +function startOfDay(value) { + const date = parseDate(value || new Date().toISOString(), 'date'); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +function endOfDay(value) { + const date = startOfDay(value); + date.setUTCDate(date.getUTCDate() + 1); + return date; +} + +function metadataArray(metadata, key) { + const value = metadata?.[key]; + return Array.isArray(value) ? value : []; +} + +function getProfileCompletionFromMetadata(staffRow) { + const metadata = staffRow?.metadata || {}; + const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); + const lastName = lastParts.join(' '); + + const checks = { + firstName: Boolean(metadata.firstName || firstName), + lastName: Boolean(metadata.lastName || lastName), + email: Boolean(staffRow?.email), + phone: Boolean(staffRow?.phone), + preferredLocations: metadataArray(metadata, 'preferredLocations').length > 0, + skills: metadataArray(metadata, 'skills').length > 0, + industries: metadataArray(metadata, 'industries').length > 0, + emergencyContact: Boolean(metadata.emergencyContact?.name && metadata.emergencyContact?.phone), + }; + + const missingFields = Object.entries(checks) + .filter(([, value]) => !value) + .map(([key]) => key); + + return { + completed: missingFields.length === 0, + missingFields, + fields: checks, + }; +} + +export async function getClientSession(actorUid) { + const context = await requireClientContext(actorUid); + return context; +} + +export async function getStaffSession(actorUid) { + const context = await requireStaffContext(actorUid); + return context; +} + +export async function getClientDashboard(actorUid) { + const context = await requireClientContext(actorUid); + const businessId = context.business.businessId; + const tenantId = context.tenant.tenantId; + + const [spendResult, projectionResult, coverageResult, activityResult] = await Promise.all([ + query( + ` + SELECT + COALESCE(SUM(total_cents) FILTER (WHERE created_at >= date_trunc('week', NOW())), 0)::BIGINT AS "weeklySpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + `, + [tenantId, businessId] + ), + query( + ` + SELECT COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "projectedSpendCents" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= NOW() + AND s.starts_at < NOW() + INTERVAL '7 days' + AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') + `, + [tenantId, businessId] + ), + query( + ` + SELECT + COALESCE(SUM(required_workers), 0)::INTEGER AS "neededWorkersToday", + COALESCE(SUM(assigned_workers), 0)::INTEGER AS "filledWorkersToday", + COALESCE(SUM(required_workers - assigned_workers), 0)::INTEGER AS "openPositionsToday" + FROM shifts + WHERE tenant_id = $1 + AND business_id = $2 + AND starts_at >= date_trunc('day', NOW()) + AND starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' + `, + [tenantId, businessId] + ), + query( + ` + SELECT + COALESCE(COUNT(*) FILTER (WHERE a.status = 'NO_SHOW'), 0)::INTEGER AS "lateWorkersToday", + COALESCE(COUNT(*) FILTER (WHERE a.status IN ('CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')), 0)::INTEGER AS "checkedInWorkersToday", + COALESCE(AVG(sr.bill_rate_cents), 0)::NUMERIC(12,2) AS "averageShiftCostCents" + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= date_trunc('day', NOW()) + AND s.starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' + `, + [tenantId, businessId] + ), + ]); + + return { + userName: context.user.displayName || context.user.email, + businessName: context.business.businessName, + businessId, + spending: { + weeklySpendCents: Number(spendResult.rows[0]?.weeklySpendCents || 0), + projectedNext7DaysCents: Number(projectionResult.rows[0]?.projectedSpendCents || 0), + }, + coverage: coverageResult.rows[0], + liveActivity: activityResult.rows[0], + }; +} + +export async function listRecentReorders(actorUid, limit) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + o.id, + o.title, + o.starts_at AS "date", + COALESCE(cp.label, o.location_name) AS "hubName", + COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType" + FROM orders o + LEFT JOIN shifts s ON s.order_id = o.id + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED') + GROUP BY o.id, cp.label + ORDER BY o.starts_at DESC NULLS LAST + LIMIT $3 + `, + [context.tenant.tenantId, context.business.businessId, parseLimit(limit, 8, 20)] + ); + return result.rows; +} + +export async function listBusinessAccounts(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + id AS "accountId", + provider_name AS "bankName", + provider_reference AS "providerReference", + last4, + is_primary AS "isPrimary", + COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType", + COALESCE(metadata->>'routingNumberMasked', '***') AS "routingNumberMasked" + FROM accounts + WHERE tenant_id = $1 + AND owner_business_id = $2 + ORDER BY is_primary DESC, created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listPendingInvoices(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + i.id AS "invoiceId", + i.invoice_number AS "invoiceNumber", + i.total_cents AS "amountCents", + i.status, + i.due_at AS "dueDate", + v.id AS "vendorId", + v.company_name AS "vendorName" + FROM invoices i + LEFT JOIN vendors v ON v.id = i.vendor_id + WHERE i.tenant_id = $1 + AND i.business_id = $2 + AND i.status IN ('PENDING', 'PENDING_REVIEW', 'APPROVED', 'OVERDUE', 'DISPUTED') + ORDER BY i.due_at ASC NULLS LAST, i.created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listInvoiceHistory(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + i.id AS "invoiceId", + i.invoice_number AS "invoiceNumber", + i.total_cents AS "amountCents", + i.status, + i.updated_at AS "paymentDate", + v.id AS "vendorId", + v.company_name AS "vendorName" + FROM invoices i + LEFT JOIN vendors v ON v.id = i.vendor_id + WHERE i.tenant_id = $1 + AND i.business_id = $2 + ORDER BY i.created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getCurrentBill(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "currentBillCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND status NOT IN ('PAID', 'VOID') + AND created_at >= date_trunc('month', NOW()) + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows[0]; +} + +export async function getSavings(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT COALESCE(SUM(COALESCE(NULLIF(metadata->>'savingsCents', '')::BIGINT, 0)), 0)::BIGINT AS "savingsCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows[0]; +} + +export async function getSpendBreakdown(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + WITH items AS ( + SELECT + COALESCE(sr.role_name, 'Unknown') AS category, + SUM(sr.bill_rate_cents * GREATEST(sr.assigned_count, sr.workers_needed))::BIGINT AS amount_cents + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY sr.role_name + ) + SELECT + category, + amount_cents AS "amountCents", + CASE WHEN SUM(amount_cents) OVER () = 0 THEN 0 + ELSE ROUND((amount_cents::numeric / SUM(amount_cents) OVER ()) * 100, 2) + END AS percentage + FROM items + ORDER BY amount_cents DESC, category ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return result.rows; +} + +export async function listCoverageByDate(actorUid, { date }) { + const context = await requireClientContext(actorUid); + const from = startOfDay(date).toISOString(); + const to = endOfDay(date).toISOString(); + const result = await query( + ` + SELECT + s.id AS "shiftId", + s.title, + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + s.required_workers AS "requiredWorkers", + s.assigned_workers AS "assignedWorkers", + sr.role_name AS "roleName", + a.id AS "assignmentId", + a.status AS "assignmentStatus", + st.id AS "staffId", + st.full_name AS "staffName", + attendance_sessions.check_in_at AS "checkInAt" + FROM shifts s + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN staffs st ON st.id = a.staff_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + ORDER BY s.starts_at ASC, st.full_name ASC NULLS LAST + `, + [context.tenant.tenantId, context.business.businessId, from, to] + ); + + const grouped = new Map(); + for (const row of result.rows) { + const current = grouped.get(row.shiftId) || { + shiftId: row.shiftId, + roleName: row.roleName, + timeRange: { + startsAt: row.startsAt, + endsAt: row.endsAt, + }, + requiredWorkerCount: row.requiredWorkers, + assignedWorkerCount: row.assignedWorkers, + assignedWorkers: [], + }; + if (row.staffId) { + current.assignedWorkers.push({ + assignmentId: row.assignmentId, + staffId: row.staffId, + fullName: row.staffName, + status: row.assignmentStatus, + checkInAt: row.checkInAt, + }); + } + grouped.set(row.shiftId, current); + } + + return Array.from(grouped.values()); +} + +export async function getCoverageStats(actorUid, { date }) { + const items = await listCoverageByDate(actorUid, { date }); + const totals = items.reduce((acc, item) => { + acc.totalPositionsNeeded += Number(item.requiredWorkerCount || 0); + acc.totalPositionsConfirmed += Number(item.assignedWorkerCount || 0); + acc.totalWorkersCheckedIn += item.assignedWorkers.filter((worker) => worker.checkInAt).length; + acc.totalWorkersEnRoute += item.assignedWorkers.filter((worker) => worker.status === 'ACCEPTED').length; + acc.totalWorkersLate += item.assignedWorkers.filter((worker) => worker.status === 'NO_SHOW').length; + return acc; + }, { + totalPositionsNeeded: 0, + totalPositionsConfirmed: 0, + totalWorkersCheckedIn: 0, + totalWorkersEnRoute: 0, + totalWorkersLate: 0, + }); + + return { + ...totals, + totalCoveragePercentage: totals.totalPositionsNeeded === 0 + ? 0 + : Math.round((totals.totalPositionsConfirmed / totals.totalPositionsNeeded) * 100), + }; +} + +export async function listHubs(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + cp.id AS "hubId", + cp.label AS name, + cp.address AS "fullAddress", + cp.latitude, + cp.longitude, + cp.nfc_tag_uid AS "nfcTagId", + cp.metadata->>'city' AS city, + cp.metadata->>'state' AS state, + cp.metadata->>'zipCode' AS "zipCode", + cc.id AS "costCenterId", + cc.name AS "costCenterName" + FROM clock_points cp + LEFT JOIN cost_centers cc ON cc.id = cp.cost_center_id + WHERE cp.tenant_id = $1 + AND cp.business_id = $2 + AND cp.status = 'ACTIVE' + ORDER BY cp.label ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listCostCenters(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT id AS "costCenterId", name + FROM cost_centers + WHERE tenant_id = $1 + AND business_id = $2 + AND status = 'ACTIVE' + ORDER BY name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listVendors(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT id AS "vendorId", company_name AS "vendorName" + FROM vendors + WHERE tenant_id = $1 + AND status = 'ACTIVE' + ORDER BY company_name ASC + `, + [context.tenant.tenantId] + ); + return result.rows; +} + +export async function listVendorRoles(actorUid, vendorId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + rc.id AS "roleId", + rc.code AS "roleCode", + rc.name AS "roleName", + COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents" + FROM roles_catalog rc + LEFT JOIN shift_roles sr ON sr.role_id = rc.id + LEFT JOIN shifts s ON s.id = sr.shift_id AND (s.vendor_id = $2 OR $2::uuid IS NULL) + WHERE rc.tenant_id = $1 + AND rc.status = 'ACTIVE' + GROUP BY rc.id + ORDER BY rc.name ASC + `, + [context.tenant.tenantId, vendorId || null] + ); + return result.rows; +} + +export async function listHubManagers(actorUid, hubId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + hm.id AS "managerAssignmentId", + bm.id AS "businessMembershipId", + u.id AS "managerId", + COALESCE(u.display_name, u.email) AS name + FROM hub_managers hm + JOIN business_memberships bm ON bm.id = hm.business_membership_id + JOIN users u ON u.id = bm.user_id + WHERE hm.tenant_id = $1 + AND hm.hub_id = $2 + ORDER BY name ASC + `, + [context.tenant.tenantId, hubId] + ); + return result.rows; +} + +export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 14); + const result = await query( + ` + SELECT + sr.id AS "itemId", + o.id AS "orderId", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + sr.role_name AS "roleName", + s.starts_at AS date, + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + sr.workers_needed AS "requiredWorkerCount", + sr.assigned_count AS "filledCount", + sr.bill_rate_cents AS "hourlyRateCents", + (sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents", + COALESCE(cp.label, s.location_name) AS "locationName", + s.status, + COALESCE( + json_agg( + json_build_object( + 'applicationId', a.application_id, + 'workerName', st.full_name, + 'role', sr.role_name, + 'confirmationStatus', a.status + ) + ) FILTER (WHERE a.id IS NOT NULL), + '[]'::json + ) AS workers + FROM shift_roles sr + JOIN shifts s ON s.id = sr.shift_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN assignments a ON a.shift_role_id = sr.id + LEFT JOIN staffs st ON st.id = a.staff_id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY sr.id, o.id, s.id, cp.label + ORDER BY s.starts_at ASC, sr.role_name ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return result.rows; +} + +export async function getStaffDashboard(actorUid) { + const context = await requireStaffContext(actorUid); + const [todayShifts, tomorrowShifts, recommendedShifts, benefits] = await Promise.all([ + listTodayShifts(actorUid), + listAssignedShifts(actorUid, { + startDate: endOfDay(new Date().toISOString()).toISOString(), + endDate: endOfDay(new Date(Date.now() + (24 * 60 * 60 * 1000)).toISOString()).toISOString(), + }), + listOpenShifts(actorUid, { limit: 5 }), + listStaffBenefits(actorUid), + ]); + + return { + staffName: context.staff.fullName, + todaysShifts: todayShifts, + tomorrowsShifts: tomorrowShifts.slice(0, 5), + recommendedShifts: recommendedShifts.slice(0, 5), + benefits, + }; +} + +export async function getStaffProfileCompletion(actorUid) { + const context = await requireStaffContext(actorUid); + const completion = getProfileCompletionFromMetadata(context.staff); + return { + staffId: context.staff.staffId, + ...completion, + }; +} + +export async function listStaffAvailability(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 6); + const recurring = await query( + ` + SELECT day_of_week AS "dayOfWeek", availability_status AS status, time_slots AS slots + FROM staff_availability + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY day_of_week ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + + const rowsByDay = new Map(recurring.rows.map((row) => [Number(row.dayOfWeek), row])); + const items = []; + let cursor = new Date(range.start); + const end = new Date(range.end); + while (cursor <= end) { + const day = cursor.getUTCDay(); + const recurringEntry = rowsByDay.get(day); + items.push({ + date: cursor.toISOString().slice(0, 10), + dayOfWeek: day, + availabilityStatus: recurringEntry?.status || 'UNAVAILABLE', + slots: recurringEntry?.slots || [], + }); + cursor = new Date(cursor.getTime() + (24 * 60 * 60 * 1000)); + } + return items; +} + +export async function listTodayShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const from = startOfDay(new Date().toISOString()).toISOString(); + const to = endOfDay(new Date().toISOString()).toISOString(); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(attendance_sessions.status, 'NOT_CLOCKED_IN') AS "attendanceStatus", + attendance_sessions.check_in_at AS "clockInAt" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC + `, + [context.tenant.tenantId, context.staff.staffId, from, to] + ); + return result.rows; +} + +export async function getCurrentAttendanceStatus(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.shift_id AS "activeShiftId", + attendance_sessions.status AS "attendanceStatus", + attendance_sessions.check_in_at AS "clockInAt" + FROM attendance_sessions + JOIN assignments a ON a.id = attendance_sessions.assignment_id + WHERE attendance_sessions.tenant_id = $1 + AND attendance_sessions.staff_id = $2 + AND attendance_sessions.status = 'OPEN' + ORDER BY attendance_sessions.updated_at DESC + LIMIT 1 + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows[0] || { + attendanceStatus: 'NOT_CLOCKED_IN', + activeShiftId: null, + clockInAt: null, + }; +} + +export async function getPaymentsSummary(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "totalEarningsCents" + FROM recent_payments + WHERE tenant_id = $1 + AND staff_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows[0]; +} + +export async function listPaymentsHistory(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + SELECT + rp.id AS "paymentId", + rp.amount_cents AS "amountCents", + COALESCE(rp.process_date, rp.created_at) AS date, + rp.status, + s.title AS "shiftName", + COALESCE(cp.label, s.location_name) AS location, + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS minutesWorked + FROM recent_payments rp + LEFT JOIN assignments a ON a.id = rp.assignment_id + LEFT JOIN shifts s ON s.id = a.shift_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE rp.tenant_id = $1 + AND rp.staff_id = $2 + AND rp.created_at >= $3::timestamptz + AND rp.created_at <= $4::timestamptz + ORDER BY date DESC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function getPaymentChart(actorUid, { startDate, endDate, bucket = 'day' }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const dateBucket = bucket === 'week' ? 'week' : bucket === 'month' ? 'month' : 'day'; + const result = await query( + ` + SELECT + date_trunc('${dateBucket}', COALESCE(process_date, created_at)) AS bucket, + COALESCE(SUM(amount_cents), 0)::BIGINT AS "amountCents" + FROM recent_payments + WHERE tenant_id = $1 + AND staff_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function listAssignedShifts(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 14); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + a.status + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at ASC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function listOpenShifts(actorUid, { limit, search } = {}) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + sr.workers_needed AS "requiredWorkerCount" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE s.tenant_id = $1 + AND s.status = 'OPEN' + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED') + ) + AND sr.role_code = $4 + ORDER BY s.starts_at ASC + LIMIT $5 + `, + [ + context.tenant.tenantId, + search || null, + context.staff.staffId, + context.staff.primaryRole || 'BARISTA', + parseLimit(limit, 20, 100), + ] + ); + return result.rows; +} + +export async function listPendingAssignments(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + sr.role_name AS "roleName", + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(cp.label, s.location_name) AS location, + a.created_at AS "responseDeadline" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status = 'ASSIGNED' + ORDER BY s.starts_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function getStaffShiftDetail(actorUid, shiftId) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + s.id AS "shiftId", + s.title, + o.description, + COALESCE(cp.label, s.location_name) AS location, + s.location_address AS address, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.id AS "roleId", + sr.role_name AS "roleName", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + sr.workers_needed AS "requiredCount", + sr.assigned_count AS "confirmedCount", + a.status AS "assignmentStatus", + app.status AS "applicationStatus" + FROM shifts s + JOIN orders o ON o.id = s.order_id + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3 + LEFT JOIN applications app ON app.shift_role_id = sr.id AND app.staff_id = $3 + WHERE s.tenant_id = $1 + AND s.id = $2 + ORDER BY sr.role_name ASC + LIMIT 1 + `, + [context.tenant.tenantId, shiftId, context.staff.staffId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift not found', 404, { shiftId }); + } + + return result.rows[0]; +} + +export async function listCancelledShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + a.metadata->>'cancellationReason' AS "cancellationReason" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status = 'CANCELLED' + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listCompletedShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + COALESCE(rp.status, 'PENDING') AS "paymentStatus" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN recent_payments rp ON rp.assignment_id = a.id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status IN ('CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function getProfileSectionsStatus(actorUid) { + const context = await requireStaffContext(actorUid); + const completion = getProfileCompletionFromMetadata(context.staff); + const [documents, certificates, benefits] = await Promise.all([ + listProfileDocuments(actorUid), + listCertificates(actorUid), + listStaffBenefits(actorUid), + ]); + return { + personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations, + emergencyContactCompleted: completion.fields.emergencyContact, + experienceCompleted: completion.fields.skills && completion.fields.industries, + attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'), + taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'), + benefitsConfigured: benefits.length > 0, + certificateCount: certificates.length, + }; +} + +export async function getPersonalInfo(actorUid) { + const context = await requireStaffContext(actorUid); + const metadata = context.staff.metadata || {}; + return { + staffId: context.staff.staffId, + firstName: metadata.firstName || context.staff.fullName.split(' ')[0] || null, + lastName: metadata.lastName || context.staff.fullName.split(' ').slice(1).join(' ') || null, + bio: metadata.bio || null, + preferredLocations: metadataArray(metadata, 'preferredLocations'), + maxDistanceMiles: metadata.maxDistanceMiles || null, + industries: metadataArray(metadata, 'industries'), + skills: metadataArray(metadata, 'skills'), + email: context.staff.email, + phone: context.staff.phone, + }; +} + +export async function listIndustries(actorUid) { + const context = await requireStaffContext(actorUid); + return metadataArray(context.staff.metadata || {}, 'industries'); +} + +export async function listSkills(actorUid) { + const context = await requireStaffContext(actorUid); + return metadataArray(context.staff.metadata || {}, 'skills'); +} + +export async function listProfileDocuments(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + sd.id AS "staffDocumentId", + d.id AS "documentId", + d.document_type AS "documentType", + d.name, + sd.file_uri AS "fileUri", + sd.status, + sd.expires_at AS "expiresAt" + FROM staff_documents sd + JOIN documents d ON d.id = sd.document_id + WHERE sd.tenant_id = $1 + AND sd.staff_id = $2 + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listCertificates(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "certificateId", + certificate_type AS "certificateType", + certificate_number AS "certificateNumber", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + status + FROM certificates + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY created_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listStaffBankAccounts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "accountId", + provider_name AS "bankName", + provider_reference AS "providerReference", + last4, + is_primary AS "isPrimary", + COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType" + FROM accounts + WHERE tenant_id = $1 + AND owner_staff_id = $2 + ORDER BY is_primary DESC, created_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listStaffBenefits(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "benefitId", + benefit_type AS "benefitType", + title, + status, + tracked_hours AS "trackedHours", + target_hours AS "targetHours", + metadata + FROM staff_benefits + WHERE tenant_id = $1 + AND staff_id = $2 + AND status = 'ACTIVE' + ORDER BY created_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js new file mode 100644 index 00000000..c42ad7df --- /dev/null +++ b/backend/query-api/test/mobile-routes.test.js @@ -0,0 +1,91 @@ +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 }), + getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }), + getCurrentBill: async () => ({ currentBillCents: 1000 }), + getPaymentChart: async () => ([{ amountCents: 100 }]), + getPaymentsSummary: async () => ({ totalEarningsCents: 500 }), + getPersonalInfo: async () => ({ firstName: 'Ana' }), + getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }), + getSavings: async () => ({ savingsCents: 200 }), + 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' }]), + listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]), + listHubManagers: async () => ([{ managerId: 'm1' }]), + listHubs: async () => ([{ hubId: 'hub-1' }]), + listIndustries: async () => (['CATERING']), + listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), + listOpenShifts: async () => ([{ shiftId: 'open-1' }]), + 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' }]), + listSkills: async () => (['BARISTA']), + listStaffAvailability: async () => ([{ dayOfWeek: 1 }]), + listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]), + listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]), + listTodayShifts: async () => ([{ shiftId: 'today-1' }]), + listVendorRoles: async () => ([{ roleId: 'role-1' }]), + listVendors: async () => ([{ vendorId: 'vendor-1' }]), + }; +} + +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'); +}); diff --git a/backend/unified-api/Dockerfile b/backend/unified-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/unified-api/Dockerfile @@ -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"] diff --git a/backend/unified-api/package-lock.json b/backend/unified-api/package-lock.json new file mode 100644 index 00000000..43c1ab9c --- /dev/null +++ b/backend/unified-api/package-lock.json @@ -0,0 +1,3661 @@ +{ + "name": "@krow/unified-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/unified-api", + "version": "0.1.0", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", + "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", + "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", + "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/database": "1.1.1", + "@firebase/database-types": "1.0.17", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", + "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.14.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", + "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "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", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "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", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/unified-api/package.json b/backend/unified-api/package.json new file mode 100644 index 00000000..6556c3ba --- /dev/null +++ b/backend/unified-api/package.json @@ -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" + } +} diff --git a/backend/unified-api/src/app.js b/backend/unified-api/src/app.js new file mode 100644 index 00000000..a30f8657 --- /dev/null +++ b/backend/unified-api/src/app.js @@ -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; +} diff --git a/backend/unified-api/src/lib/errors.js b/backend/unified-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/unified-api/src/lib/errors.js @@ -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, + }, + }; +} diff --git a/backend/unified-api/src/middleware/error-handler.js b/backend/unified-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/unified-api/src/middleware/error-handler.js @@ -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); +} diff --git a/backend/unified-api/src/middleware/request-context.js b/backend/unified-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/unified-api/src/middleware/request-context.js @@ -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(); +} diff --git a/backend/unified-api/src/routes/auth.js b/backend/unified-api/src/routes/auth.js new file mode 100644 index 00000000..faafa952 --- /dev/null +++ b/backend/unified-api/src/routes/auth.js @@ -0,0 +1,129 @@ +import express from 'express'; +import { AppError } from '../lib/errors.js'; +import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +const defaultAuthService = { + parseClientSignIn, + parseClientSignUp, + signInClient, + signOutActor, + signUpClient, + 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, { checkRevoked: true }); + 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.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; +} diff --git a/backend/unified-api/src/routes/health.js b/backend/unified-api/src/routes/health.js new file mode 100644 index 00000000..7fd3a64b --- /dev/null +++ b/backend/unified-api/src/routes/health.js @@ -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, + }); + } +}); diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js new file mode 100644 index 00000000..69cae510 --- /dev/null +++ b/backend/unified-api/src/routes/proxy.js @@ -0,0 +1,75 @@ +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', +]); + +function resolveTargetBase(pathname) { + if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL; + if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL; + if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL; + 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 requestPath = new URL(req.originalUrl, 'http://localhost').pathname; + const baseUrl = resolveTargetBase(requestPath); + if (!baseUrl) { + throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404); + } + + const url = new URL(req.originalUrl, 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(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl)); + + return router; +} diff --git a/backend/unified-api/src/server.js b/backend/unified-api/src/server.js new file mode 100644 index 00000000..b14a4e88 --- /dev/null +++ b/backend/unified-api/src/server.js @@ -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}`); +}); diff --git a/backend/unified-api/src/services/auth-service.js b/backend/unified-api/src/services/auth-service.js new file mode 100644 index 00000000..5f44dee0 --- /dev/null +++ b/backend/unified-api/src/services/auth-service.js @@ -0,0 +1,157 @@ +import { z } from 'zod'; +import { AppError } from '../lib/errors.js'; +import { withTransaction } from './db.js'; +import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js'; +import { deleteAccount, signInWithPassword, 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(), +}); + +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, + }; +} + +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 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); + 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; + } +} + +export async function signOutActor(actor) { + await revokeUserSessions(actor.uid); + return { signedOut: true }; +} diff --git a/backend/unified-api/src/services/db.js b/backend/unified-api/src/services/db.js new file mode 100644 index 00000000..3beaa683 --- /dev/null +++ b/backend/unified-api/src/services/db.js @@ -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; + } +} diff --git a/backend/unified-api/src/services/firebase-auth.js b/backend/unified-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e441b13f --- /dev/null +++ b/backend/unified-api/src/services/firebase-auth.js @@ -0,0 +1,18 @@ +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); +} diff --git a/backend/unified-api/src/services/identity-toolkit.js b/backend/unified-api/src/services/identity-toolkit.js new file mode 100644 index 00000000..0b477e4b --- /dev/null +++ b/backend/unified-api/src/services/identity-toolkit.js @@ -0,0 +1,65 @@ +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 deleteAccount({ idToken }, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:delete', + { idToken }, + fetchImpl + ); +} diff --git a/backend/unified-api/src/services/user-context.js b/backend/unified-api/src/services/user-context.js new file mode 100644 index 00000000..6262e886 --- /dev/null +++ b/backend/unified-api/src/services/user-context.js @@ -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, + }; +} diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js new file mode 100644 index 00000000..144594ef --- /dev/null +++ b/backend/unified-api/test/app.test.js @@ -0,0 +1,112 @@ +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'); +}); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 4a5a66c8..1a452753 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -2,31 +2,43 @@ This is the frontend-facing source of truth for the v2 backend. -If you are building against the new backend, start here. +## 1) Frontend entrypoint -## 1) Which service to use +Frontend should target one public base URL: -| Use case | Service | +```env +API_V2_BASE_URL= +``` + +The unified v2 gateway exposes: + +- `/auth/*` +- `/core/*` +- `/commands/*` +- `/query/*` +- `/query/client/*` +- `/query/staff/*` + +Internal services still stay separate behind that gateway. + +## 2) Internal service split + +| Use case | Internal service | | --- | --- | -| File upload, signed URLs, model calls, rapid order helpers, verification flows | `core-api-v2` | +| File upload, signed URLs, model calls, verification helpers | `core-api-v2` | | Business writes and workflow actions | `command-api-v2` | -| Screen reads for the implemented v2 views | `query-api-v2` | - -## 2) Live dev base URLs - -- 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` +| Screen reads and mobile read models | `query-api-v2` | +| Frontend-facing single host and auth wrappers | `krow-api-v2` | ## 3) Auth and headers -All protected routes require: +Protected routes require: ```http Authorization: Bearer ``` -All command routes also require: +Command routes also require: ```http Idempotency-Key: @@ -43,78 +55,88 @@ All services return the same error envelope: } ``` -## 4) What frontend can use now +## 4) What frontend can use now on this branch -### Ready now +### Unified gateway -- `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 +- `POST /auth/client/sign-in` +- `POST /auth/client/sign-up` +- `POST /auth/sign-out` +- `POST /auth/client/sign-out` +- `POST /auth/staff/sign-out` +- `GET /auth/session` +- Proxy access to `/core/*`, `/commands/*`, `/query/*` -### Do not move yet +### Client read routes -- reports -- payments and finance screens -- undocumented dashboard reads -- undocumented scheduling reads and writes -- any flow that assumes verification history is durable in SQL +- `GET /query/client/session` +- `GET /query/client/dashboard` +- `GET /query/client/reorders` +- `GET /query/client/billing/accounts` +- `GET /query/client/billing/invoices/pending` +- `GET /query/client/billing/invoices/history` +- `GET /query/client/billing/current-bill` +- `GET /query/client/billing/savings` +- `GET /query/client/billing/spend-breakdown` +- `GET /query/client/coverage` +- `GET /query/client/coverage/stats` +- `GET /query/client/hubs` +- `GET /query/client/cost-centers` +- `GET /query/client/vendors` +- `GET /query/client/vendors/:vendorId/roles` +- `GET /query/client/hubs/:hubId/managers` +- `GET /query/client/orders/view` -## 5) Important caveat +### Staff read routes -`core-api-v2` is usable now, but verification job state is not yet persisted to `krow-sql-v2`. +- `GET /query/staff/session` +- `GET /query/staff/dashboard` +- `GET /query/staff/profile-completion` +- `GET /query/staff/availability` +- `GET /query/staff/clock-in/shifts/today` +- `GET /query/staff/clock-in/status` +- `GET /query/staff/payments/summary` +- `GET /query/staff/payments/history` +- `GET /query/staff/payments/chart` +- `GET /query/staff/shifts/assigned` +- `GET /query/staff/shifts/open` +- `GET /query/staff/shifts/pending` +- `GET /query/staff/shifts/cancelled` +- `GET /query/staff/shifts/completed` +- `GET /query/staff/shifts/:shiftId` +- `GET /query/staff/profile/sections` +- `GET /query/staff/profile/personal-info` +- `GET /query/staff/profile/industries` +- `GET /query/staff/profile/skills` +- `GET /query/staff/profile/documents` +- `GET /query/staff/profile/certificates` +- `GET /query/staff/profile/bank-accounts` +- `GET /query/staff/profile/benefits` -What is durable today: -- uploaded files in Google Cloud Storage -- generated signed URLs -- model invocation itself +### Existing v2 routes still valid -What is not yet durable: -- verification job history -- verification review history -- verification event history +- `/core/*` routes documented in `core-api.md` +- `/commands/*` routes documented in `command-api.md` +- `/query/tenants/*` routes documented in `query-api.md` -That means frontend can integrate with verification routes now, but should not treat them as mission-critical durable state yet. +## 5) Remaining gaps after this slice -## 6) Recommended frontend environment variables +Still not implemented yet: -```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 -``` +- staff phone OTP wrapper endpoints +- hub write flows +- hub NFC assignment write route +- invoice approve and dispute commands +- staff apply / decline / request swap commands +- staff profile update commands +- availability write commands +- reports suite +- durable verification persistence in `core-api-v2` -## 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 gap analysis](./mobile-api-gap-analysis.md) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md new file mode 100644 index 00000000..97e5e7e9 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -0,0 +1,66 @@ +# Mobile API Gap Analysis + +Source compared against implementation: + +- `/Users/wiel/Downloads/mobile-backend-api-specification.md` + +## Implemented in this slice + +- unified frontend-facing base URL design +- client auth wrapper for email/password sign-in and sign-up +- auth session and sign-out endpoints +- client read surface for dashboard, billing, coverage, hubs, vendor lookup, and date-range order items +- staff read surface for dashboard, availability, clock-in reads, payments, shifts, and profile sections +- schema support for: + - cost centers + - hub managers + - recurring staff availability + - staff benefits +- seed support for: + - authenticated demo staff user + - cost center and hub manager data + - staff benefits and availability + - attire and tax-form example documents + +## Still missing + +### Auth + +- staff phone OTP start +- staff OTP verify +- staff profile setup endpoint + +### Client writes + +- hub create +- hub update +- hub delete +- hub NFC assignment +- assign manager to hub +- invoice approve +- invoice dispute + +### Staff writes + +- availability update +- availability quick set +- shift apply +- shift decline +- request swap +- personal info update +- preferred locations update +- profile photo upload wrapper + +### Reports + +- report summary +- daily ops +- spend +- coverage +- forecast +- performance +- no-show + +### Core persistence + +- `core-api-v2` verification jobs still need durable SQL persistence diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md new file mode 100644 index 00000000..9b20f022 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -0,0 +1,50 @@ +# Unified API V2 + +This service exists so frontend can use one base URL without forcing backend into one codebase. + +## Base idea + +Frontend talks to one service: + +- `krow-api-v2` + +That gateway does two things: + +1. exposes auth/session endpoints +2. forwards requests to the right internal v2 service + +## Route groups + +### Auth + +- `POST /auth/client/sign-in` +- `POST /auth/client/sign-up` +- `POST /auth/sign-out` +- `POST /auth/client/sign-out` +- `POST /auth/staff/sign-out` +- `GET /auth/session` + +### Proxy passthrough + +- `/core/*` -> `core-api-v2` +- `/commands/*` -> `command-api-v2` +- `/query/*` -> `query-api-v2` + +### Mobile read models + +These are served by `query-api-v2` but frontend should still call them through the unified host: + +- `/query/client/*` +- `/query/staff/*` + +## Why this shape + +- frontend gets one base URL +- backend keeps separate read, write, and service helpers +- we can scale or refactor internals later without breaking frontend paths + +## Current auth note + +Client email/password auth is wrapped here. + +Staff phone OTP is not wrapped here yet. That still needs its own proper provider-backed implementation rather than a fake backend OTP flow. diff --git a/makefiles/backend.mk b/makefiles/backend.mk index f2a1a5dc..c0032b14 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -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: @@ -385,6 +391,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) @@ -427,6 +460,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) \ From b455455a49120f8b6473bc24faa417c8bdb4a596 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:02:24 +0100 Subject: [PATCH 2/2] feat(api): complete unified v2 mobile surface --- .../command-api/scripts/seed-v2-demo-data.mjs | 205 +- .../command-api/scripts/v2-demo-fixture.mjs | 71 +- .../sql/v2/003_v2_mobile_workflows.sql | 44 + backend/command-api/src/app.js | 2 + .../src/contracts/commands/mobile.js | 301 +++ .../src/contracts/commands/order-create.js | 1 + backend/command-api/src/routes/mobile.js | 412 +++ .../command-api/src/services/actor-context.js | 111 + .../src/services/command-service.js | 4 +- backend/command-api/src/services/db.js | 13 +- .../src/services/mobile-command-service.js | 2348 +++++++++++++++++ .../command-api/test/mobile-routes.test.js | 233 ++ backend/core-api/package-lock.json | 129 + backend/core-api/package.json | 1 + backend/core-api/src/routes/core.js | 124 +- backend/core-api/src/routes/health.js | 29 + .../core-api/src/services/actor-context.js | 67 + backend/core-api/src/services/db.js | 98 + .../core-api/src/services/mobile-upload.js | 260 ++ .../src/services/verification-jobs.js | 848 ++++-- backend/core-api/test/app.test.js | 21 +- backend/query-api/src/data/faqs.js | 41 + backend/query-api/src/routes/mobile.js | 187 ++ backend/query-api/src/services/db.js | 14 +- .../src/services/mobile-query-service.js | 703 ++++- backend/query-api/test/mobile-routes.test.js | 57 + .../scripts/ensure-v2-demo-users.mjs | 64 + .../scripts/live-smoke-v2-unified.mjs | 971 +++++++ backend/unified-api/src/routes/auth.js | 45 +- backend/unified-api/src/routes/proxy.js | 101 +- .../unified-api/src/services/auth-service.js | 149 +- .../unified-api/src/services/firebase-auth.js | 5 + .../src/services/identity-toolkit.js | 27 + backend/unified-api/test/app.test.js | 72 + backend/unified-api/test/staff-auth.test.js | 61 + docs/BACKEND/API_GUIDES/V2/README.md | 151 +- .../API_GUIDES/V2/mobile-api-gap-analysis.md | 79 +- docs/BACKEND/API_GUIDES/V2/unified-api.md | 174 +- makefiles/backend.mk | 9 +- 39 files changed, 7726 insertions(+), 506 deletions(-) create mode 100644 backend/command-api/sql/v2/003_v2_mobile_workflows.sql create mode 100644 backend/command-api/src/contracts/commands/mobile.js create mode 100644 backend/command-api/src/routes/mobile.js create mode 100644 backend/command-api/src/services/actor-context.js create mode 100644 backend/command-api/src/services/mobile-command-service.js create mode 100644 backend/command-api/test/mobile-routes.test.js create mode 100644 backend/core-api/src/services/actor-context.js create mode 100644 backend/core-api/src/services/db.js create mode 100644 backend/core-api/src/services/mobile-upload.js create mode 100644 backend/query-api/src/data/faqs.js create mode 100644 backend/unified-api/scripts/ensure-v2-demo-users.mjs create mode 100644 backend/unified-api/scripts/live-smoke-v2-unified.mjs create mode 100644 backend/unified-api/test/staff-auth.test.js diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index 94a41e0d..f87bd78f 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -44,6 +44,14 @@ 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); @@ -248,6 +256,16 @@ async function main() { [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 ( @@ -319,6 +337,34 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO orders ( + id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type, + starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states', + 'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12, + '{"slice":"active","orderType":"ONE_TIME"}'::jsonb + ) + `, + [ + fixture.orders.active.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.orders.active.number, + fixture.orders.active.title, + assignedStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.users.operationsManager.id, + ] + ); + await client.query( ` INSERT INTO shifts ( @@ -357,6 +403,51 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO shifts ( + id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone, + location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb), + ($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb), + ($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb), + ($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shifts.available.id, + fixture.tenant.id, + fixture.orders.active.id, + fixture.business.id, + fixture.vendor.id, + fixture.clockPoint.id, + fixture.shifts.available.code, + fixture.shifts.available.title, + availableStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.clockPoint.geofenceRadiusMeters, + fixture.shifts.assigned.id, + fixture.shifts.assigned.code, + fixture.shifts.assigned.title, + assignedStartsAt, + assignedEndsAt, + fixture.shifts.cancelled.id, + fixture.shifts.cancelled.code, + fixture.shifts.cancelled.title, + cancelledStartsAt, + cancelledEndsAt, + fixture.shifts.noShow.id, + fixture.shifts.noShow.code, + fixture.shifts.noShow.title, + noShowStartsAt, + noShowEndsAt, + ] + ); + await client.query( ` INSERT INTO shift_roles ( @@ -377,6 +468,32 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO shift_roles ( + id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata + ) + VALUES + ($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb), + ($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb), + ($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb), + ($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shiftRoles.availableBarista.id, + fixture.shifts.available.id, + fixture.shiftRoles.assignedBarista.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.shifts.cancelled.id, + fixture.roles.barista.id, + fixture.roles.barista.code, + fixture.roles.barista.name, + fixture.shiftRoles.noShowBarista.id, + fixture.shifts.noShow.id, + ] + ); + await client.query( ` INSERT INTO applications ( @@ -417,6 +534,36 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO assignments ( + id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status, + assigned_at, accepted_at, checked_in_at, checked_out_at, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb), + ($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb), + ($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.assignments.assignedAna.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.assignedBarista.id, + fixture.workforce.ana.id, + fixture.staff.ana.id, + fixture.assignments.cancelledAna.id, + fixture.shifts.cancelled.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.assignments.noShowAna.id, + fixture.shifts.noShow.id, + fixture.shiftRoles.noShowBarista.id, + noShowStartsAt, + ] + ); + await client.query( ` INSERT INTO attendance_events ( @@ -486,50 +633,69 @@ async function main() { ` INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata) VALUES - ($1, $2, 'CERTIFICATION', $3, $6, '{"seeded":true}'::jsonb), - ($4, $2, 'ATTIRE', $5, $6, '{"seeded":true}'::jsonb), - ($7, $2, 'TAX_FORM', $8, $6, '{"seeded":true}'::jsonb) + ($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.documents.foodSafety.id, + fixture.documents.governmentId.id, fixture.tenant.id, + fixture.documents.governmentId.name, + fixture.documents.foodSafety.id, 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.taxFormW9.id, - fixture.documents.taxFormW9.name, + fixture.documents.taxFormW4.id, + fixture.documents.taxFormW4.name, ] ); await client.query( ` - INSERT INTO staff_documents (id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata) + INSERT INTO staff_documents ( + id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata + ) VALUES - ($1, $2, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb), - ($7, $2, $3, $8, $9, 'VERIFIED', NULL, '{"seeded":true}'::jsonb), - ($10, $2, $3, $11, $12, 'VERIFIED', NULL, '{"seeded":true}'::jsonb) + ($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.foodSafety.id, + 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.taxFormW9.id, - fixture.documents.taxFormW9.id, - `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w9-form.pdf`, + 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, metadata) - VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', '{"seeded":true}'::jsonb) + 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, @@ -537,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', + }), ] ); diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 369ede17..77f22ee5 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -108,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: { @@ -120,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: { @@ -128,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: { @@ -138,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: { @@ -166,6 +212,10 @@ 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', @@ -174,27 +224,42 @@ export const V2DemoFixture = { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002', name: 'Black Shirt', }, - taxFormW9: { + taxFormI9: { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003', - name: 'W-9 Tax Form', + name: 'I-9', + }, + taxFormW4: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004', + name: 'W-4', }, }, staffDocuments: { + governmentId: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95000', + }, foodSafety: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95001', }, attireBlackShirt: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95002', }, - taxFormW9: { + 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', diff --git a/backend/command-api/sql/v2/003_v2_mobile_workflows.sql b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql new file mode 100644 index 00000000..11d02bc0 --- /dev/null +++ b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql @@ -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); diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js index ef43071c..0c6fa44a 100644 --- a/backend/command-api/src/app.js +++ b/backend/command-api/src/app.js @@ -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); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js new file mode 100644 index 00000000..8a1b2243 --- /dev/null +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -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(), +}); diff --git a/backend/command-api/src/contracts/commands/order-create.js b/backend/command-api/src/contracts/commands/order-create.js index 3ea5df80..5dc672c7 100644 --- a/backend/command-api/src/contracts/commands/order-create.js +++ b/backend/command-api/src/contracts/commands/order-create.js @@ -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(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js new file mode 100644 index 00000000..39a6376b --- /dev/null +++ b/backend/command-api/src/routes/mobile.js @@ -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; +} diff --git a/backend/command-api/src/services/actor-context.js b/backend/command-api/src/services/actor-context.js new file mode 100644 index 00000000..30d23aa5 --- /dev/null +++ b/backend/command-api/src/services/actor-context.js @@ -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; +} diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index e1aa5ca0..09fba8fe 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -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, diff --git a/backend/command-api/src/services/db.js b/backend/command-api/src/services/db.js index 5499a01e..3fdfbba7 100644 --- a/backend/command-api/src/services/db.js +++ b/backend/command-api/src/services/db.js @@ -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; diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js new file mode 100644 index 00000000..09ffec2f --- /dev/null +++ b/backend/command-api/src/services/mobile-command-service.js @@ -0,0 +1,2348 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { query, withTransaction } from './db.js'; +import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js'; +import { + cancelOrder as cancelOrderCommand, + clockIn as clockInCommand, + clockOut as clockOutCommand, + createOrder as createOrderCommand, +} from './command-service.js'; + +function toIsoOrNull(value) { + return value ? new Date(value).toISOString() : null; +} + +function normalizeSlug(input) { + return `${input || ''}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +function normalizePhone(value) { + if (!value) return null; + return `${value}`.trim(); +} + +function ensureArray(value) { + return Array.isArray(value) ? value : []; +} + +function generateOrderNumber(prefix = 'ORD') { + const stamp = Date.now().toString().slice(-8); + const random = crypto.randomInt(100, 999); + return `${prefix}-${stamp}${random}`; +} + +function normalizeWorkerCount(position) { + return position.workerCount ?? position.workersNeeded ?? 1; +} + +function roleCodeFromName(name) { + return `${name || 'role'}` + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 50) || 'ROLE'; +} + +function toArrayOfUniqueIntegers(values, fallback) { + const source = Array.isArray(values) && values.length > 0 ? values : fallback; + return [...new Set(source.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6))]; +} + +function combineDateAndTime(dateValue, timeValue) { + const [hours, minutes] = `${timeValue}`.split(':').map((value) => Number.parseInt(value, 10)); + const date = new Date(`${dateValue}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || Number.isNaN(hours) || Number.isNaN(minutes)) { + throw new AppError('VALIDATION_ERROR', 'Invalid date/time combination for order schedule', 400, { + date: dateValue, + time: timeValue, + }); + } + date.setUTCHours(hours, minutes, 0, 0); + return date; +} + +function buildShiftWindow(dateValue, startTime, endTime) { + const startsAt = combineDateAndTime(dateValue, startTime); + const endsAt = combineDateAndTime(dateValue, endTime); + if (endsAt <= startsAt) { + endsAt.setUTCDate(endsAt.getUTCDate() + 1); + } + return { + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + }; +} + +function materializeScheduleDates({ orderType, orderDate, startDate, endDate, recurrenceDays, daysOfWeek, horizonDays }) { + if (orderType === 'ONE_TIME') { + return [orderDate]; + } + + const from = new Date(`${startDate}T00:00:00.000Z`); + if (Number.isNaN(from.getTime())) { + throw new AppError('VALIDATION_ERROR', 'Invalid startDate', 400, { startDate }); + } + + const to = orderType === 'RECURRING' + ? new Date(`${endDate}T00:00:00.000Z`) + : (() => { + if (endDate) return new Date(`${endDate}T00:00:00.000Z`); + const fallback = new Date(from); + fallback.setUTCDate(fallback.getUTCDate() + (horizonDays || 28)); + return fallback; + })(); + + if (Number.isNaN(to.getTime()) || to < from) { + throw new AppError('VALIDATION_ERROR', 'Invalid scheduling window', 400, { + startDate, + endDate: endDate || null, + }); + } + + const activeDays = orderType === 'RECURRING' + ? toArrayOfUniqueIntegers(recurrenceDays, []) + : toArrayOfUniqueIntegers(daysOfWeek, [1, 2, 3, 4, 5]); + + const dates = []; + const cursor = new Date(from); + while (cursor <= to) { + if (activeDays.includes(cursor.getUTCDay())) { + dates.push(cursor.toISOString().slice(0, 10)); + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + if (dates.length === 0) { + throw new AppError('VALIDATION_ERROR', 'Schedule did not produce any shifts', 400, { + orderType, + startDate, + endDate: endDate || null, + activeDays, + }); + } + + return dates; +} + +async function loadHubDetails(tenantId, businessId, hubId) { + const result = await query( + ` + SELECT + cp.id, + cp.label, + cp.address, + cp.latitude, + cp.longitude, + cp.geofence_radius_meters, + cp.metadata + FROM clock_points cp + WHERE cp.tenant_id = $1 + AND cp.business_id = $2 + AND cp.id = $3 + AND cp.status = 'ACTIVE' + `, + [tenantId, businessId, hubId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { hubId }); + } + + return result.rows[0]; +} + +async function resolveRoleCatalogEntries(tenantId, positions) { + const roleIds = positions.map((position) => position.roleId).filter(Boolean); + const roleCodes = positions.map((position) => position.roleCode).filter(Boolean); + if (roleIds.length === 0 && roleCodes.length === 0) { + return new Map(); + } + + const result = await query( + ` + SELECT id, code, name + FROM roles_catalog + WHERE tenant_id = $1 + AND status = 'ACTIVE' + AND ( + (cardinality($2::uuid[]) > 0 AND id = ANY($2::uuid[])) + OR + (cardinality($3::text[]) > 0 AND code = ANY($3::text[])) + ) + `, + [tenantId, roleIds, roleCodes] + ); + + const lookup = new Map(); + for (const row of result.rows) { + lookup.set(row.id, row); + lookup.set(row.code, row); + } + return lookup; +} + +function buildShiftRoleEntry(position, roleLookup) { + const role = roleLookup.get(position.roleId) || roleLookup.get(position.roleCode); + const workersNeeded = normalizeWorkerCount(position); + const billRateCents = position.billRateCents ?? position.hourlyRateCents ?? 0; + const payRateCents = position.payRateCents ?? position.hourlyRateCents ?? 0; + + return { + startTime: position.startTime, + endTime: position.endTime, + roleId: role?.id || position.roleId || null, + roleCode: role?.code || position.roleCode || roleCodeFromName(position.roleName), + roleName: role?.name || position.roleName || role?.name || 'Role', + workersNeeded, + payRateCents, + billRateCents, + metadata: { + lunchBreakMinutes: position.lunchBreakMinutes ?? 0, + paidBreak: position.paidBreak ?? false, + instantBook: position.instantBook ?? false, + ...position.metadata, + }, + }; +} + +function buildOrderShifts({ dates, positions, timezone, hub }) { + const shifts = []; + + for (const dateValue of dates) { + const buckets = new Map(); + for (const position of positions) { + const key = `${position.startTime}|${position.endTime}`; + const bucket = buckets.get(key) || []; + bucket.push(position); + buckets.set(key, bucket); + } + + let shiftIndex = 0; + for (const [timeKey, bucketPositions] of buckets.entries()) { + shiftIndex += 1; + const [startTime, endTime] = timeKey.split('|'); + const window = buildShiftWindow(dateValue, startTime, endTime); + const requiredWorkers = bucketPositions.reduce((sum, position) => sum + normalizeWorkerCount(position), 0); + shifts.push({ + shiftCode: `SFT-${dateValue.replaceAll('-', '')}-${shiftIndex}`, + title: `${hub.label} ${startTime}-${endTime}`, + startsAt: window.startsAt, + endsAt: window.endsAt, + timezone: timezone || 'UTC', + clockPointId: hub.id, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + geofenceRadiusMeters: hub.geofence_radius_meters || undefined, + requiredWorkers, + metadata: { + city: hub.metadata?.city || null, + state: hub.metadata?.state || null, + zipCode: hub.metadata?.zipCode || null, + }, + roles: bucketPositions, + }); + } + } + + return shifts; +} + +function buildMobileOrderMetadata(orderType, payload, extra = {}) { + return { + orderType, + source: 'mobile-api', + recurrenceDays: payload.recurrenceDays || payload.daysOfWeek || null, + startDate: payload.startDate || payload.orderDate || null, + endDate: payload.endDate || null, + ...payload.metadata, + ...extra, + }; +} + +function inferAttendanceSourceType(payload) { + if (payload.sourceType) return payload.sourceType; + if (payload.nfcTagId) return 'NFC'; + if (payload.latitude != null && payload.longitude != null) return 'GEO'; + return 'MANUAL'; +} + +async function buildOrderCreatePayloadFromMobile(actor, context, payload, orderType, extra = {}) { + const hubId = payload.hubId || extra.hubId; + const hub = await loadHubDetails(context.tenant.tenantId, context.business.businessId, hubId); + const positionInputs = payload.positions || extra.positions || []; + const roleLookup = await resolveRoleCatalogEntries(context.tenant.tenantId, positionInputs); + const normalizedPositions = positionInputs.map((position) => buildShiftRoleEntry(position, roleLookup)); + const dates = materializeScheduleDates({ + orderType, + orderDate: payload.orderDate || extra.orderDate, + startDate: payload.startDate || extra.startDate, + endDate: payload.endDate || extra.endDate, + recurrenceDays: payload.recurrenceDays || extra.recurrenceDays, + daysOfWeek: payload.daysOfWeek || extra.daysOfWeek, + horizonDays: payload.horizonDays || extra.horizonDays, + }); + const shifts = buildOrderShifts({ + dates, + positions: normalizedPositions, + timezone: payload.timezone || extra.timezone, + hub, + }); + const startsAt = shifts[0]?.startsAt || null; + const endsAt = shifts[shifts.length - 1]?.endsAt || null; + return { + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + vendorId: payload.vendorId ?? extra.vendorId ?? null, + orderNumber: generateOrderNumber(orderType === 'ONE_TIME' ? 'ORD' : orderType === 'RECURRING' ? 'RCR' : 'PRM'), + title: payload.eventName || extra.eventName || 'Untitled Order', + description: payload.description ?? extra.description ?? null, + status: 'OPEN', + serviceType: payload.serviceType ?? extra.serviceType ?? 'EVENT', + startsAt, + endsAt, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + notes: payload.notes ?? extra.notes ?? null, + metadata: buildMobileOrderMetadata(orderType, payload, extra.metadata || {}), + shifts: shifts.map((shift, index) => ({ + ...shift, + shiftCode: shift.shiftCode || `SFT-${index + 1}`, + roles: shift.roles.map((role) => ({ + roleId: role.roleId, + roleCode: role.roleCode, + roleName: role.roleName, + workersNeeded: role.workersNeeded, + payRateCents: role.payRateCents, + billRateCents: role.billRateCents, + metadata: role.metadata, + })), + })), + }; +} + +async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId) { + const context = await requireClientContext(actorUid); + if (context.tenant.tenantId !== tenantId || context.business.businessId !== businessId) { + throw new AppError('FORBIDDEN', 'Order is outside the current client context', 403, { orderId }); + } + + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title AS "eventName", + o.description, + o.notes, + o.vendor_id AS "vendorId", + o.service_type AS "serviceType", + o.metadata, + COALESCE( + json_agg( + json_build_object( + 'shiftId', s.id, + 'clockPointId', s.clock_point_id, + 'date', to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.role_id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workerCount', sr.workers_needed, + 'hourlyRateCents', sr.bill_rate_cents, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents, + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ), + '[]'::json + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.id = $1 + AND o.tenant_id = $2 + AND o.business_id = $3 + GROUP BY o.id + `, + [orderId, tenantId, businessId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId }); + } + + return result.rows[0]; +} + +async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) { + const context = await requireStaffContext(actorUid); + if (payload.assignmentId) { + return { assignmentId: payload.assignmentId, context }; + } + + if (payload.applicationId) { + const fromApplication = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN applications app ON app.id = a.application_id + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND app.id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.applicationId, actorUid] + ); + if (fromApplication.rowCount > 0) { + return { assignmentId: fromApplication.rows[0].assignmentId, context }; + } + } + + if (payload.shiftId) { + const fromShift = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.shiftId, actorUid] + ); + if (fromShift.rowCount > 0) { + return { assignmentId: fromShift.rows[0].assignmentId, context }; + } + } + + if (requireOpenSession) { + const openSession = await query( + ` + SELECT attendance_sessions.assignment_id AS "assignmentId" + FROM attendance_sessions + JOIN staffs s ON s.id = attendance_sessions.staff_id + WHERE attendance_sessions.tenant_id = $1 + AND s.user_id = $2 + AND attendance_sessions.status = 'OPEN' + ORDER BY attendance_sessions.updated_at DESC + LIMIT 1 + `, + [tenantId, actorUid] + ); + if (openSession.rowCount > 0) { + return { assignmentId: openSession.rows[0].assignmentId, context }; + } + } + + throw new AppError('NOT_FOUND', 'No assignment found for the current staff clock action', 404, payload); +} + +async function ensureActorUser(client, actor, fields = {}) { + 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() + `, + [ + actor.uid, + fields.email ?? actor.email ?? null, + fields.displayName ?? actor.email ?? null, + fields.phone ?? null, + JSON.stringify(fields.metadata || {}), + ] + ); +} + +async function insertDomainEvent(client, { + tenantId, + aggregateType, + aggregateId, + eventType, + actorUserId, + payload, +}) { + await client.query( + ` + INSERT INTO domain_events ( + tenant_id, + aggregate_type, + aggregate_id, + sequence, + event_type, + actor_user_id, + payload + ) + SELECT + $1, + $2, + $3, + COALESCE(MAX(sequence) + 1, 1), + $4, + $5, + $6::jsonb + FROM domain_events + WHERE tenant_id = $1 + AND aggregate_type = $2 + AND aggregate_id = $3 + `, + [tenantId, aggregateType, aggregateId, eventType, actorUserId, JSON.stringify(payload || {})] + ); +} + +async function requireBusinessMembership(client, businessId, userId) { + const result = await client.query( + ` + SELECT bm.id, bm.tenant_id, bm.business_id, bm.user_id + FROM business_memberships bm + WHERE bm.business_id = $1 + AND bm.user_id = $2 + AND bm.membership_status = 'ACTIVE' + `, + [businessId, userId] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Business membership not found for current user', 403, { + businessId, + userId, + }); + } + return result.rows[0]; +} + +async function requireClockPoint(client, tenantId, businessId, hubId, { forUpdate = false } = {}) { + const result = await client.query( + ` + SELECT id, tenant_id, business_id, label, status, cost_center_id, nfc_tag_uid, metadata + FROM clock_points + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + ${forUpdate ? 'FOR UPDATE' : ''} + `, + [hubId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { + tenantId, + businessId, + hubId, + }); + } + return result.rows[0]; +} + +async function requireInvoice(client, tenantId, businessId, invoiceId) { + const result = await client.query( + ` + SELECT id, tenant_id, business_id, status, metadata + FROM invoices + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + FOR UPDATE + `, + [invoiceId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Invoice not found in business scope', 404, { + tenantId, + businessId, + invoiceId, + }); + } + return result.rows[0]; +} + +async function requireStaffByActor(client, tenantId, actorUid) { + const result = await client.query( + ` + SELECT s.id, s.tenant_id, s.user_id, s.full_name, s.email, s.phone, s.metadata, s.primary_role, + w.id AS workforce_id, w.vendor_id, w.workforce_number + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.tenant_id = $1 + AND s.user_id = $2 + ORDER BY s.created_at ASC + LIMIT 1 + FOR UPDATE OF s + `, + [tenantId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Staff profile not found for current user', 403, { + tenantId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, staffId) { + const result = await client.query( + ` + SELECT + s.id AS shift_id, + s.tenant_id, + s.business_id, + s.vendor_id, + s.status AS shift_status, + s.starts_at, + s.ends_at, + sr.id AS shift_role_id, + sr.role_code, + sr.role_name, + sr.workers_needed, + sr.assigned_count, + sr.pay_rate_cents + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.id = $1 + AND s.tenant_id = $2 + AND ($3::uuid IS NULL OR sr.id = $3) + AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $4 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + ) + ORDER BY sr.created_at ASC + LIMIT 1 + FOR UPDATE OF s, sr + `, + [shiftId, tenantId, roleId || null, staffId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Open shift role not found or already applied', 404, { + tenantId, + shiftId, + roleId: roleId || null, + }); + } + return result.rows[0]; +} + +async function requirePendingAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + AND a.status = 'ASSIGNED' + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Pending shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireAnyAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function refreshShiftRoleCounts(client, shiftRoleId) { + await client.query( + ` + UPDATE shift_roles sr + SET assigned_count = counts.assigned_count, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_role_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_count + FROM assignments + WHERE shift_role_id = $1 + ) counts + WHERE sr.id = counts.shift_role_id + `, + [shiftRoleId] + ); +} + +async function refreshShiftCounts(client, shiftId) { + await client.query( + ` + UPDATE shifts s + SET assigned_workers = counts.assigned_workers, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_workers + FROM assignments + WHERE shift_id = $1 + ) counts + WHERE s.id = counts.shift_id + `, + [shiftId] + ); +} + +async function resolveStaffOnboardingScope(client, actorUid, tenantId, vendorId) { + const existing = await loadActorContext(actorUid); + if (existing.tenant?.tenantId && existing.vendor?.vendorId) { + return { + tenantId: existing.tenant.tenantId, + vendorId: existing.vendor.vendorId, + }; + } + + if (tenantId && vendorId) { + const verify = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.id = $1 + AND v.id = $2 + AND t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + `, + [tenantId, vendorId] + ); + if (verify.rowCount === 0) { + throw new AppError('VALIDATION_ERROR', 'tenantId and vendorId do not resolve to an active onboarding scope', 400, { + tenantId, + vendorId, + }); + } + return { + tenantId, + vendorId, + }; + } + + const fallback = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + ORDER BY t.created_at ASC, v.created_at ASC + LIMIT 1 + ` + ); + if (fallback.rowCount === 0) { + throw new AppError('CONFIGURATION_ERROR', 'No active tenant/vendor onboarding scope is configured', 500); + } + + return { + tenantId: fallback.rows[0].tenant_id, + vendorId: fallback.rows[0].vendor_id, + }; +} + +function buildStaffMetadataPatch(existing, payload) { + return { + ...existing, + ...(payload.bio !== undefined ? { bio: payload.bio } : {}), + ...(payload.firstName !== undefined ? { firstName: payload.firstName } : {}), + ...(payload.lastName !== undefined ? { lastName: payload.lastName } : {}), + ...(payload.preferredLocations !== undefined ? { preferredLocations: payload.preferredLocations } : {}), + ...(payload.maxDistanceMiles !== undefined ? { maxDistanceMiles: payload.maxDistanceMiles } : {}), + ...(payload.industries !== undefined ? { industries: payload.industries } : {}), + ...(payload.skills !== undefined ? { skills: payload.skills } : {}), + ...(payload.profileVisible !== undefined ? { profileVisible: payload.profileVisible } : {}), + }; +} + +export async function createHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const businessMembership = await requireBusinessMembership(client, context.business.businessId, actor.uid); + + let costCenterId = payload.costCenterId || null; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO clock_points ( + tenant_id, + business_id, + label, + address, + latitude, + longitude, + geofence_radius_meters, + nfc_tag_uid, + cost_center_id, + status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, $9, 'ACTIVE', $10::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.name, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + payload.nfcTagId || null, + costCenterId, + JSON.stringify({ + placeId: payload.placeId || null, + street: payload.street || null, + city: payload.city || null, + state: payload.state || null, + country: payload.country || null, + zipCode: payload.zipCode || null, + createdByMembershipId: businessMembership.id, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: result.rows[0].id, + eventType: 'HUB_CREATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: result.rows[0].id, created: true }; + }); +} + +export async function updateHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + + let costCenterId = payload.costCenterId; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const nextMetadata = { + ...(hub.metadata || {}), + ...(payload.placeId !== undefined ? { placeId: payload.placeId } : {}), + ...(payload.street !== undefined ? { street: payload.street } : {}), + ...(payload.city !== undefined ? { city: payload.city } : {}), + ...(payload.state !== undefined ? { state: payload.state } : {}), + ...(payload.country !== undefined ? { country: payload.country } : {}), + ...(payload.zipCode !== undefined ? { zipCode: payload.zipCode } : {}), + }; + + await client.query( + ` + UPDATE clock_points + SET label = COALESCE($2, label), + address = COALESCE($3, address), + latitude = COALESCE($4, latitude), + longitude = COALESCE($5, longitude), + geofence_radius_meters = COALESCE($6, geofence_radius_meters), + cost_center_id = COALESCE($7, cost_center_id), + metadata = $8::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + hub.id, + payload.name || null, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + costCenterId || null, + JSON.stringify(nextMetadata), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: hub.id, updated: true }; + }); +} + +export async function deleteHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + const activeOrders = await client.query( + ` + SELECT 1 + FROM shifts + WHERE clock_point_id = $1 + AND status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') + LIMIT 1 + `, + [hub.id] + ); + if (activeOrders.rowCount > 0) { + throw new AppError('HUB_DELETE_BLOCKED', 'Cannot delete a hub with active orders or shifts', 409, { + hubId: hub.id, + }); + } + + await client.query( + ` + UPDATE clock_points + SET status = 'INACTIVE', + updated_at = NOW() + WHERE id = $1 + `, + [hub.id] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_ARCHIVED', + actorUserId: actor.uid, + payload: { reason: payload.reason || null }, + }); + + return { hubId: hub.id, deleted: true }; + }); +} + +export async function assignHubNfc(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + await client.query( + ` + UPDATE clock_points + SET nfc_tag_uid = $2, + updated_at = NOW() + WHERE id = $1 + `, + [hub.id, payload.nfcTagId] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_NFC_ASSIGNED', + actorUserId: actor.uid, + payload, + }); + return { hubId: hub.id, nfcTagId: payload.nfcTagId }; + }); +} + +export async function assignHubManager(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + let businessMembershipId = payload.businessMembershipId || null; + if (!businessMembershipId) { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND user_id = $3 + AND membership_status = 'ACTIVE' + `, + [context.tenant.tenantId, context.business.businessId, payload.managerUserId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business team member not found for hub manager assignment', 404, { + managerUserId: payload.managerUserId, + }); + } + businessMembershipId = membership.rows[0].id; + } else { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + AND membership_status = 'ACTIVE' + `, + [businessMembershipId, context.tenant.tenantId, context.business.businessId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business membership not found for hub manager assignment', 404, { + businessMembershipId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO hub_managers (tenant_id, hub_id, business_membership_id) + VALUES ($1, $2, $3) + ON CONFLICT (hub_id, business_membership_id) DO UPDATE + SET updated_at = NOW() + RETURNING id + `, + [context.tenant.tenantId, hub.id, businessMembershipId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_MANAGER_ASSIGNED', + actorUserId: actor.uid, + payload: { + businessMembershipId, + }, + }); + + return { managerAssignmentId: result.rows[0].id, hubId: hub.id, businessMembershipId }; + }); +} + +export async function approveInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'APPROVED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + approvedBy: actor.uid, + approvedAt: new Date().toISOString(), + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_APPROVED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id }, + }); + return { invoiceId: invoice.id, status: 'APPROVED' }; + }); +} + +export async function disputeInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'DISPUTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + disputedBy: actor.uid, + disputedAt: new Date().toISOString(), + disputeReason: payload.reason, + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_DISPUTED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id, reason: payload.reason }, + }); + return { invoiceId: invoice.id, status: 'DISPUTED', reason: payload.reason }; + }); +} + +export async function rateWorkerFromCoverage(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignmentResult = await client.query( + ` + SELECT a.id, a.tenant_id, a.business_id, a.staff_id + FROM assignments a + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.staff_id = $3 + AND ($4::uuid IS NULL OR a.id = $4) + ORDER BY a.updated_at DESC + LIMIT 1 + FOR UPDATE OF a + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, payload.assignmentId || null] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found for worker review in business scope', 404, payload); + } + const assignment = assignmentResult.rows[0]; + + const reviewResult = await client.query( + ` + INSERT INTO staff_reviews ( + tenant_id, + business_id, + staff_id, + assignment_id, + reviewer_user_id, + rating, + review_text, + tags + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ON CONFLICT (business_id, assignment_id, staff_id) DO UPDATE + SET reviewer_user_id = EXCLUDED.reviewer_user_id, + rating = EXCLUDED.rating, + review_text = EXCLUDED.review_text, + tags = EXCLUDED.tags, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + assignment.id, + actor.uid, + payload.rating, + payload.feedback || null, + JSON.stringify(ensureArray(payload.issueFlags || [])), + ] + ); + + if (payload.markAsFavorite === true) { + await client.query( + ` + INSERT INTO staff_favorites (tenant_id, business_id, staff_id, created_by_user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET created_by_user_id = EXCLUDED.created_by_user_id + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, actor.uid] + ); + } + + if (payload.markAsFavorite === false) { + await client.query( + ` + DELETE FROM staff_favorites + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId] + ); + } + + await client.query( + ` + UPDATE staffs + SET average_rating = review_stats.avg_rating, + rating_count = review_stats.rating_count, + updated_at = NOW() + FROM ( + SELECT staff_id, + ROUND(AVG(rating)::numeric, 2) AS avg_rating, + COUNT(*)::INTEGER AS rating_count + FROM staff_reviews + WHERE staff_id = $1 + GROUP BY staff_id + ) review_stats + WHERE staffs.id = review_stats.staff_id + `, + [payload.staffId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_review', + aggregateId: reviewResult.rows[0].id, + eventType: 'STAFF_REVIEWED_FROM_COVERAGE', + actorUserId: actor.uid, + payload, + }); + + return { + reviewId: reviewResult.rows[0].id, + assignmentId: assignment.id, + staffId: payload.staffId, + rating: payload.rating, + markAsFavorite: payload.markAsFavorite ?? null, + issueFlags: ensureArray(payload.issueFlags || []), + feedback: payload.feedback || null, + }; + }); +} + +export async function cancelLateWorker(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const result = await client.query( + ` + SELECT a.id, a.shift_id, a.shift_role_id, a.staff_id, a.status, s.required_workers, s.assigned_workers, s.tenant_id + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE a.id = $1 + AND a.tenant_id = $2 + AND a.business_id = $3 + FOR UPDATE OF a, s + `, + [payload.assignmentId, context.tenant.tenantId, context.business.businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Late worker assignment not found in business scope', 404, { + assignmentId: payload.assignmentId, + }); + } + const assignment = result.rows[0]; + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + cancellationReason: payload.reason || 'Cancelled for lateness', + cancelledBy: actor.uid, + cancelledAt: new Date().toISOString(), + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'LATE_WORKER_CANCELLED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + replacementSearchTriggered: true, + status: 'CANCELLED', + }; + }); +} + +export async function createClientOneTimeOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'ONE_TIME'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientRecurringOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'RECURRING'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientPermanentOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'PERMANENT'); + return createOrderCommand(actor, commandPayload); +} + +export async function createEditedOrderCopy(actor, payload) { + const context = await requireClientContext(actor.uid); + const template = await loadEditableOrderTemplate( + actor.uid, + context.tenant.tenantId, + context.business.businessId, + payload.orderId + ); + + const templateShifts = Array.isArray(template.shifts) ? template.shifts : []; + const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({ + ...role, + startTime: role.startTime || shift.startTime, + endTime: role.endTime || shift.endTime, + }))); + const firstShift = templateShifts[0] || {}; + const lastShift = templateShifts[templateShifts.length - 1] || {}; + const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME'; + const inferredDays = [...new Set(templateShifts.map((shift) => new Date(`${shift.date}T00:00:00.000Z`).getUTCDay()).filter((value) => Number.isInteger(value)))]; + + const commandPayload = await buildOrderCreatePayloadFromMobile( + actor, + context, + { + ...payload, + hubId: payload.hubId || firstShift.clockPointId, + vendorId: payload.vendorId ?? template.vendorId ?? undefined, + eventName: payload.eventName || template.eventName, + description: payload.description ?? template.description ?? undefined, + notes: payload.notes ?? template.notes ?? undefined, + serviceType: payload.serviceType ?? template.serviceType ?? undefined, + positions: payload.positions || templatePositions, + orderDate: payload.orderDate || firstShift.date, + startDate: payload.startDate || firstShift.date, + endDate: payload.endDate || lastShift.date || firstShift.date, + recurrenceDays: payload.recurrenceDays || inferredDays, + daysOfWeek: payload.daysOfWeek || inferredDays, + metadata: { + sourceOrderId: payload.orderId, + ...template.metadata, + ...payload.metadata, + }, + }, + inferredOrderType, + { + metadata: { + editSourceOrderId: payload.orderId, + }, + } + ); + + return createOrderCommand(actor, commandPayload); +} + +export async function cancelClientOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + return cancelOrderCommand(actor, { + tenantId: context.tenant.tenantId, + orderId: payload.orderId, + reason: payload.reason, + metadata: payload.metadata, + }); +} + +export async function staffClockIn(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock(actor.uid, context.tenant.tenantId, payload); + return clockInCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + rawPayload: { + notes: payload.notes || null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function staffClockOut(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock( + actor.uid, + context.tenant.tenantId, + payload, + { requireOpenSession: true } + ); + return clockOutCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + rawPayload: { + notes: payload.notes || null, + breakMinutes: payload.breakMinutes ?? null, + applicationId: payload.applicationId || null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function updateStaffAvailabilityDay(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.dayOfWeek, + payload.availabilityStatus, + JSON.stringify(payload.slots || []), + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_availability', + aggregateId: result.rows[0].id, + eventType: 'STAFF_AVAILABILITY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + availabilityId: result.rows[0].id, + dayOfWeek: payload.dayOfWeek, + availabilityStatus: payload.availabilityStatus, + slots: payload.slots || [], + }; + }); +} + +export async function quickSetStaffAvailability(actor, payload) { + const context = await requireStaffContext(actor.uid); + const presets = { + all: [0, 1, 2, 3, 4, 5, 6], + weekdays: [1, 2, 3, 4, 5], + weekends: [0, 6], + clear: [], + }; + const selectedDays = presets[payload.quickSetType]; + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + + for (let day = 0; day <= 6; day += 1) { + const active = selectedDays.includes(day); + await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + staff.id, + day, + active ? 'AVAILABLE' : 'UNAVAILABLE', + JSON.stringify(active ? payload.slots || [{ start: '08:00', end: '18:00' }] : []), + JSON.stringify({ + quickSetType: payload.quickSetType, + startDate: payload.startDate, + endDate: payload.endDate, + }), + ] + ); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_AVAILABILITY_QUICK_SET', + actorUserId: actor.uid, + payload, + }); + return { + quickSetType: payload.quickSetType, + appliedDays: selectedDays, + }; + }); +} + +export async function applyForShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id); + const existingAssignment = await client.query( + ` + SELECT id + FROM assignments + WHERE shift_role_id = $1 + AND staff_id = $2 + AND status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LIMIT 1 + `, + [shiftRole.shift_role_id, staff.id] + ); + if (existingAssignment.rowCount > 0) { + throw new AppError('CONFLICT', 'Staff is already assigned to this shift', 409, { + shiftId: payload.shiftId, + staffId: staff.id, + }); + } + + const instantBook = payload.instantBook === true && shiftRole.assigned_count < shiftRole.workers_needed && Boolean(staff.workforce_id); + const applicationResult = await client.query( + ` + INSERT INTO applications ( + tenant_id, + shift_id, + shift_role_id, + staff_id, + status, + origin, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb) + ON CONFLICT (shift_role_id, staff_id) DO UPDATE + SET status = EXCLUDED.status, + origin = EXCLUDED.origin, + metadata = COALESCE(applications.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.id, + instantBook ? 'CONFIRMED' : 'PENDING', + JSON.stringify({ + appliedBy: actor.uid, + instantBookRequested: payload.instantBook === true, + }), + ] + ); + + let assignmentId = null; + let assignmentStatus = null; + if (instantBook) { + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ACCEPTED', NOW(), NOW(), $9::jsonb) + ON CONFLICT (shift_role_id, workforce_id) DO UPDATE + SET application_id = EXCLUDED.application_id, + status = 'ACCEPTED', + accepted_at = COALESCE(assignments.accepted_at, NOW()), + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.business_id, + shiftRole.vendor_id, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.workforce_id, + staff.id, + applicationResult.rows[0].id, + JSON.stringify({ source: 'staff-apply-instant-book' }), + ] + ); + assignmentId = assignmentResult.rows[0].id; + assignmentStatus = assignmentResult.rows[0].status; + await refreshShiftRoleCounts(client, shiftRole.shift_role_id); + await refreshShiftCounts(client, shiftRole.shift_id); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'application', + aggregateId: applicationResult.rows[0].id, + eventType: instantBook ? 'SHIFT_INSTANT_BOOKED' : 'SHIFT_APPLIED', + actorUserId: actor.uid, + payload, + }); + + return { + applicationId: applicationResult.rows[0].id, + shiftId: shiftRole.shift_id, + roleId: shiftRole.shift_role_id, + status: instantBook ? 'CONFIRMED' : applicationResult.rows[0].status, + assignmentId, + assignmentStatus, + }; + }); +} + +export async function acceptPendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'ACCEPTED', + accepted_at = COALESCE(accepted_at, NOW()), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ acceptedBy: actor.uid })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_ACCEPTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'ACCEPTED', + }; + }); +} + +export async function declinePendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + declinedBy: actor.uid, + declineReason: payload.reason || null, + })] + ); + await client.query( + ` + UPDATE applications + SET status = 'REJECTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE shift_role_id = $1 + AND staff_id = $3 + AND status IN ('PENDING', 'CONFIRMED') + `, + [assignment.shift_role_id, JSON.stringify({ rejectedBy: actor.uid, reason: payload.reason || null }), assignment.staff_id] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_DECLINED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'DECLINED', + }; + }); +} + +export async function requestShiftSwap(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + if (!['ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT'].includes(assignment.status)) { + throw new AppError('INVALID_SWAP_STATE', 'Only accepted or worked shifts can be marked for swap', 409, { + shiftId: payload.shiftId, + assignmentStatus: assignment.status, + }); + } + await client.query( + ` + UPDATE assignments + SET status = 'SWAP_REQUESTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + swapRequestedAt: new Date().toISOString(), + swapReason: payload.reason || null, + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'SHIFT_SWAP_REQUESTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'SWAP_REQUESTED', + }; + }); +} + +export async function setupStaffProfile(actor, payload) { + return withTransaction(async (client) => { + const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId); + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.fullName, + phone: payload.phoneNumber, + metadata: { source: 'staff-profile-setup' }, + }); + + await client.query( + ` + INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) + VALUES ($1, $2, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, actor.uid] + ); + + await client.query( + ` + INSERT INTO vendor_memberships (tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata) + VALUES ($1, $2, $3, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, scope.vendorId, actor.uid] + ); + + const fullName = payload.fullName.trim(); + const [firstName, ...lastParts] = fullName.split(/\s+/); + const lastName = lastParts.join(' '); + const metadata = { + bio: payload.bio || null, + firstName, + lastName, + preferredLocations: ensureArray(payload.preferredLocations || []), + maxDistanceMiles: payload.maxDistanceMiles ?? null, + industries: ensureArray(payload.industries || []), + skills: ensureArray(payload.skills || []), + }; + + const staffResult = await client.query( + ` + INSERT INTO staffs ( + tenant_id, + user_id, + full_name, + email, + phone, + status, + primary_role, + onboarding_status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'ACTIVE', $6, 'COMPLETED', $7::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET full_name = EXCLUDED.full_name, + email = COALESCE(EXCLUDED.email, staffs.email), + phone = COALESCE(EXCLUDED.phone, staffs.phone), + primary_role = COALESCE(EXCLUDED.primary_role, staffs.primary_role), + onboarding_status = 'COMPLETED', + metadata = COALESCE(staffs.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + actor.uid, + fullName, + payload.email ?? actor.email ?? null, + normalizePhone(payload.phoneNumber), + payload.primaryRole || ensureArray(payload.skills || [])[0] || 'GENERAL_EVENT_STAFF', + JSON.stringify(metadata), + ] + ); + + const workforceResult = await client.query( + ` + INSERT INTO workforce ( + tenant_id, + vendor_id, + staff_id, + workforce_number, + employment_type, + status, + metadata + ) + VALUES ($1, $2, $3, $4, 'TEMP', 'ACTIVE', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, staff_id) DO UPDATE + SET status = 'ACTIVE', + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + scope.vendorId, + staffResult.rows[0].id, + `WF-${normalizeSlug(firstName).toUpperCase()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`, + ] + ); + + await insertDomainEvent(client, { + tenantId: scope.tenantId, + aggregateType: 'staff', + aggregateId: staffResult.rows[0].id, + eventType: 'STAFF_PROFILE_SETUP_COMPLETED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staffResult.rows[0].id, + workforceId: workforceResult.rows[0].id, + tenantId: scope.tenantId, + vendorId: scope.vendorId, + completed: true, + }; + }); +} + +export async function updatePersonalInfo(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.displayName || null, + phone: payload.phone || null, + }); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingMetadata = staff.metadata || {}; + const nextMetadata = buildStaffMetadataPatch(existingMetadata, payload); + const fullName = [ + payload.firstName || existingMetadata.firstName || staff.full_name.split(' ')[0] || '', + payload.lastName || existingMetadata.lastName || staff.full_name.split(' ').slice(1).join(' ') || '', + ].filter(Boolean).join(' ').trim() || staff.full_name; + + await client.query( + ` + UPDATE staffs + SET full_name = $2, + email = COALESCE($3, email), + phone = COALESCE($4, phone), + metadata = $5::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, fullName, payload.email ?? null, normalizePhone(payload.phone) ?? null, JSON.stringify(nextMetadata)] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PERSONAL_INFO_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staff.id, + fullName, + email: payload.email ?? staff.email ?? null, + phone: normalizePhone(payload.phone) ?? staff.phone ?? null, + metadata: nextMetadata, + }; + }); +} + +export async function updateProfileExperience(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, payload); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + primary_role = COALESCE($3, primary_role), + updated_at = NOW() + WHERE id = $1 + `, + [ + staff.id, + JSON.stringify(nextMetadata), + payload.primaryRole || null, + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_EXPERIENCE_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + industries: ensureArray(nextMetadata.industries || []), + skills: ensureArray(nextMetadata.skills || []), + }; + }); +} + +export async function updatePreferredLocations(actor, payload) { + return updatePersonalInfo(actor, { + preferredLocations: payload.preferredLocations, + maxDistanceMiles: payload.maxDistanceMiles, + }); +} + +export async function createEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO emergency_contacts ( + tenant_id, + staff_id, + full_name, + phone, + relationship_type, + is_primary, + metadata + ) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, FALSE), $7::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.fullName, + normalizePhone(payload.phone), + payload.relationshipType, + payload.isPrimary ?? false, + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_CREATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, created: true }; + }); +} + +export async function updateEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + UPDATE emergency_contacts + SET full_name = COALESCE($2, full_name), + phone = COALESCE($3, phone), + relationship_type = COALESCE($4, relationship_type), + is_primary = COALESCE($5, is_primary), + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + AND tenant_id = $7 + AND staff_id = $8 + RETURNING id + `, + [ + payload.contactId, + payload.fullName || null, + normalizePhone(payload.phone) || null, + payload.relationshipType || null, + payload.isPrimary ?? null, + JSON.stringify(payload.metadata || {}), + context.tenant.tenantId, + staff.id, + ] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Emergency contact not found for current staff user', 404, { + contactId: payload.contactId, + }); + } + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, updated: true }; + }); +} + +async function ensureTaxFormDocument(client, tenantId, formType) { + const normalizedName = formType.toUpperCase() === 'I9' ? 'I-9' : 'W-4'; + const result = await client.query( + ` + INSERT INTO documents (tenant_id, document_type, name, metadata) + VALUES ($1, 'TAX_FORM', $2, '{"required":true}'::jsonb) + ON CONFLICT (tenant_id, document_type, name) DO UPDATE + SET updated_at = NOW() + RETURNING id, name + `, + [tenantId, normalizedName] + ); + return result.rows[0]; +} + +export async function saveTaxFormDraft(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'IN_PROGRESS', + fields: payload.fields, + lastSavedAt: new Date().toISOString(), + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_DRAFT_SAVED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'IN_PROGRESS' }; + }); +} + +export async function submitTaxForm(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'SUBMITTED', + submittedAt: new Date().toISOString(), + fields: payload.fields, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_SUBMITTED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'SUBMITTED' }; + }); +} + +export async function addStaffBankAccount(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingPrimary = await client.query( + ` + SELECT id + FROM accounts + WHERE tenant_id = $1 + AND owner_staff_id = $2 + AND is_primary = TRUE + LIMIT 1 + `, + [context.tenant.tenantId, staff.id] + ); + const accountResult = await client.query( + ` + INSERT INTO accounts ( + tenant_id, + owner_type, + owner_staff_id, + provider_name, + provider_reference, + last4, + is_primary, + metadata + ) + VALUES ($1, 'STAFF', $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, last4, is_primary + `, + [ + context.tenant.tenantId, + staff.id, + payload.bankName, + `manual:${payload.routingNumber.slice(-4)}:${payload.accountNumber.slice(-4)}`, + payload.accountNumber.slice(-4), + existingPrimary.rowCount === 0, + JSON.stringify({ + accountType: payload.accountType, + routingNumberMasked: `***${payload.routingNumber.slice(-4)}`, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'account', + aggregateId: accountResult.rows[0].id, + eventType: 'STAFF_BANK_ACCOUNT_ADDED', + actorUserId: actor.uid, + payload: { + accountType: payload.accountType, + bankName: payload.bankName, + }, + }); + return { + accountId: accountResult.rows[0].id, + last4: accountResult.rows[0].last4, + isPrimary: accountResult.rows[0].is_primary, + }; + }); +} + +export async function updatePrivacyVisibility(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, { + profileVisible: payload.profileVisible, + }); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, JSON.stringify(nextMetadata)] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PRIVACY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + profileVisible: Boolean(payload.profileVisible), + }; + }); +} diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js new file mode 100644 index 00000000..3870066a --- /dev/null +++ b/backend/command-api/test/mobile-routes.test.js @@ -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'); +}); diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json index 87370c92..3155f437 100644 --- a/backend/core-api/package-lock.json +++ b/backend/core-api/package-lock.json @@ -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", diff --git a/backend/core-api/package.json b/backend/core-api/package.json index 0e9b2f6d..cf7bccca 100644 --- a/backend/core-api/package.json +++ b/backend/core-api/package.json @@ -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" diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 6c905278..40a0ebe8 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -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); diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js index 9196cc83..9ccd539c 100644 --- a/backend/core-api/src/routes/health.js +++ b/backend/core-api/src/routes/health.js @@ -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, + }); +}); diff --git a/backend/core-api/src/services/actor-context.js b/backend/core-api/src/services/actor-context.js new file mode 100644 index 00000000..ae32d932 --- /dev/null +++ b/backend/core-api/src/services/actor-context.js @@ -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; +} diff --git a/backend/core-api/src/services/db.js b/backend/core-api/src/services/db.js new file mode 100644 index 00000000..2755155c --- /dev/null +++ b/backend/core-api/src/services/db.js @@ -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; + } +} diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js new file mode 100644 index 00000000..392c9076 --- /dev/null +++ b/backend/core-api/src/services/mobile-upload.js @@ -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, + }; +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index 5ffe44bd..ce46679b 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -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,201 +379,462 @@ 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 (job.type === 'government_id') { - return runThirdPartyChecks(job, 'government_id'); - } + if (result.rowCount === 0) { + return null; + } - return runThirdPartyChecks(job, 'certification'); -} + const job = result.rows[0]; + if (job.status !== VerificationStatus.PENDING) { + return null; + } -async function processVerificationJob(id) { - const job = requireJob(id); - if (job.status !== VerificationStatus.PENDING) { + 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 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', + 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, + ] + ); + + 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); -} - -export function createVerificationJob({ actorUid, payload }) { - const now = nowIso(); - const id = `ver_${crypto.randomUUID()}`; - const job = { - id, - type: payload.type, - subjectType: payload.subjectType || null, - subjectId: payload.subjectId || null, - ownerUid: actorUid, - fileUri: payload.fileUri, - rules: payload.rules || {}, - metadata: payload.metadata || {}, - status: VerificationStatus.PENDING, - confidence: null, - reasons: [], - extracted: {}, - provider: null, - review: null, - createdAt: now, - updatedAt: now, - events: [ - eventRecord({ - fromStatus: null, - toStatus: VerificationStatus.PENDING, - actorType: 'system', - actorId: actorUid, - }), - ], - }; - jobs.set(id, job); - queueVerificationProcessing(id); - return toPublicJob(job); -} - -export function getVerificationJob(verificationId, actorUid) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - return toPublicJob(job); -} - -export function reviewVerificationJob(verificationId, actorUid, review) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - - if (HUMAN_TERMINAL_STATUSES.has(job.status)) { - throw new AppError('CONFLICT', 'Verification already finalized', 409, { - verificationId, - status: job.status, + }); }); } +} - const fromStatus = job.status; - job.status = review.decision; - job.review = { - decision: review.decision, - reviewedBy: actorUid, - reviewedAt: nowIso(), - note: review.note || '', - reasonCode: review.reasonCode || 'MANUAL_REVIEW', - }; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, - toStatus: job.status, +function queueVerificationProcessing(verificationId) { + setImmediate(() => { + const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob; + worker(verificationId).catch(() => {}); + }); +} + +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, + subject_type: payload.subjectType || null, + subject_id: payload.subjectId || null, + file_uri: payload.fileUri, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + 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, + }); + + return result.rows[0]; + }); + + queueVerificationProcessing(created.id); + return toPublicJob(created); +} + +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 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, + status: job.status, + }); + } + + const reviewPayload = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: new Date().toISOString(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + + 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); - assertAccess(job, actorUid); +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, + }); + } - if (job.status === VerificationStatus.PROCESSING) { - throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { - verificationId, - }); + 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 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 = 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. + } } diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index c3e50de5..d6613a07 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -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(); diff --git a/backend/query-api/src/data/faqs.js b/backend/query-api/src/data/faqs.js new file mode 100644 index 00000000..f12a8906 --- /dev/null +++ b/backend/query-api/src/data/faqs.js @@ -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.', + }, + ], + }, +]; diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 07674ea7..5f4ce613 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -1,32 +1,47 @@ 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, @@ -40,37 +55,55 @@ import { 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, @@ -84,6 +117,7 @@ const defaultQueryService = { listTodayShifts, listVendorRoles, listVendors, + searchFaqs, }; function requireQueryParam(name, value) { @@ -199,6 +233,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoreTeam(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { try { const items = await queryService.listHubs(req.actor.uid); @@ -244,6 +287,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listBusinessTeamMembers(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { try { const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); @@ -253,6 +305,78 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getReportSummary(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) }); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getSpendReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getCoverageReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getForecastReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getPerformanceReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getNoShowReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => { try { const data = await queryService.getStaffSession(req.actor.uid); @@ -433,6 +557,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listAttireChecklist(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTaxForms(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listEmergencyContacts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { try { const items = await queryService.listCertificates(req.actor.uid); @@ -460,5 +611,41 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTimeCardEntries(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getPrivacySettings(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs', async (req, res, next) => { + try { + const items = await queryService.listFaqCategories(); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs/search', async (req, res, next) => { + try { + const items = await queryService.searchFaqs(req.query.q || ''); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + return router; } diff --git a/backend/query-api/src/services/db.js b/backend/query-api/src/services/db.js index 272d0e3b..a0af590b 100644 --- a/backend/query-api/src/services/db.js +++ b/backend/query-api/src/services/db.js @@ -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; diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index 166bb331..cc1b17a7 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -1,4 +1,5 @@ import { AppError } from '../lib/errors.js'; +import { FAQ_CATEGORIES } from '../data/faqs.js'; import { query } from './db.js'; import { requireClientContext, requireStaffContext } from './actor-context.js'; @@ -45,6 +46,12 @@ function metadataArray(metadata, key) { return Array.isArray(value) ? value : []; } +function metadataBoolean(metadata, key, fallback = false) { + const value = metadata?.[key]; + if (typeof value === 'boolean') return value; + return fallback; +} + function getProfileCompletionFromMetadata(staffRow) { const metadata = staffRow?.metadata || {}; const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); @@ -775,34 +782,73 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( ` - SELECT - s.id AS "shiftId", - sr.id AS "roleId", - sr.role_name AS "roleName", - COALESCE(cp.label, s.location_name) AS location, - s.starts_at AS date, - s.starts_at AS "startTime", - s.ends_at AS "endTime", - sr.pay_rate_cents AS "hourlyRateCents", - COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", - FALSE AS "instantBook", - sr.workers_needed AS "requiredWorkerCount" - FROM shifts s - JOIN shift_roles sr ON sr.shift_id = s.id - JOIN orders o ON o.id = s.order_id - LEFT JOIN clock_points cp ON cp.id = s.clock_point_id - WHERE s.tenant_id = $1 - AND s.status = 'OPEN' - AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') - AND NOT EXISTS ( - SELECT 1 - FROM applications a - WHERE a.shift_role_id = sr.id - AND a.staff_id = $3 - AND a.status IN ('PENDING', 'CONFIRMED') - ) - AND sr.role_code = $4 - ORDER BY s.starts_at ASC + WITH open_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + sr.workers_needed AS "requiredWorkerCount" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE s.tenant_id = $1 + AND s.status = 'OPEN' + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED') + ) + ), + swap_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + 1::INTEGER AS "requiredWorkerCount" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.status = 'SWAP_REQUESTED' + AND a.staff_id <> $3 + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications app + WHERE app.shift_role_id = sr.id + AND app.staff_id = $3 + AND app.status IN ('PENDING', 'CONFIRMED') + ) + ) + SELECT * + FROM ( + SELECT * FROM open_roles + UNION ALL + SELECT * FROM swap_roles + ) items + ORDER BY "startTime" ASC LIMIT $5 `, [ @@ -987,17 +1033,21 @@ export async function listProfileDocuments(actorUid) { const result = await query( ` SELECT - sd.id AS "staffDocumentId", d.id AS "documentId", d.document_type AS "documentType", d.name, + sd.id AS "staffDocumentId", sd.file_uri AS "fileUri", - sd.status, - sd.expires_at AS "expiresAt" - FROM staff_documents sd - JOIN documents d ON d.id = sd.document_id - WHERE sd.tenant_id = $1 - AND sd.staff_id = $2 + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.expires_at AS "expiresAt", + sd.metadata + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.tenant_id = d.tenant_id + AND sd.staff_id = $2 + WHERE d.tenant_id = $1 + AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM') ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId] @@ -1012,10 +1062,14 @@ export async function listCertificates(actorUid) { SELECT id AS "certificateId", certificate_type AS "certificateType", + COALESCE(metadata->>'name', certificate_type) AS name, + file_uri AS "fileUri", + metadata->>'issuer' AS issuer, certificate_number AS "certificateNumber", issued_at AS "issuedAt", expires_at AS "expiresAt", - status + status, + metadata->>'verificationStatus' AS "verificationStatus" FROM certificates WHERE tenant_id = $1 AND staff_id = $2 @@ -1069,3 +1123,580 @@ export async function listStaffBenefits(actorUid) { ); return result.rows; } + +export async function listCoreTeam(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + TRUE AS favorite + FROM staff_favorites sf + JOIN staffs st ON st.id = sf.staff_id + WHERE sf.tenant_id = $1 + AND sf.business_id = $2 + ORDER BY st.average_rating DESC, st.full_name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getOrderReorderPreview(actorUid, orderId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title, + o.description, + o.starts_at AS "startsAt", + o.ends_at AS "endsAt", + o.location_name AS "locationName", + o.location_address AS "locationAddress", + o.metadata, + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftCode', s.shift_code, + 'title', s.title, + 'startsAt', s.starts_at, + 'endsAt', s.ends_at, + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workersNeeded', sr.workers_needed, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND o.id = $3 + GROUP BY o.id + `, + [context.tenant.tenantId, context.business.businessId, orderId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId }); + } + return result.rows[0]; +} + +export async function listBusinessTeamMembers(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + bm.id AS "businessMembershipId", + u.id AS "userId", + COALESCE(u.display_name, u.email) AS name, + u.email, + bm.business_role AS role + FROM business_memberships bm + JOIN users u ON u.id = bm.user_id + WHERE bm.tenant_id = $1 + AND bm.business_id = $2 + AND bm.membership_status = 'ACTIVE' + ORDER BY name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getReportSummary(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const [shifts, spend, performance, noShow] = await Promise.all([ + query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COALESCE(AVG( + CASE WHEN s.required_workers = 0 THEN 1 + ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1) + END + ), 0)::NUMERIC(8,4) AS "averageCoverage" + FROM shifts s + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore" + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COUNT(*)::INTEGER AS "noShowCount" + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND status = 'NO_SHOW' + AND updated_at >= $3::timestamptz + AND updated_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + ]); + + return { + totalShifts: Number(shifts.rows[0]?.totalShifts || 0), + totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0), + averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100), + averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0), + noShowCount: Number(noShow.rows[0]?.noShowCount || 0), + forecastAccuracyPercentage: 90, + }; +} + +export async function getDailyOpsReport(actorUid, { date }) { + const context = await requireClientContext(actorUid); + const from = startOfDay(date).toISOString(); + const to = endOfDay(date).toISOString(); + const shifts = await listCoverageByDate(actorUid, { date }); + const totals = await query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed", + COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked", + COALESCE(AVG( + CASE + WHEN att.check_in_at IS NULL THEN 0 + WHEN att.check_in_at <= s.starts_at THEN 1 + ELSE 0 + END + ), 0)::NUMERIC(8,4) AS "onTimeArrivalRate" + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, from, to] + ); + return { + totalShifts: Number(totals.rows[0]?.totalShifts || 0), + totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0), + totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60), + onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100), + shifts, + }; +} + +export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const bucketExpr = bucket === 'week' ? 'week' : 'day'; + const [total, chart, breakdown] = await Promise.all([ + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT + date_trunc('${bucketExpr}', created_at) AS bucket, + COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + getSpendBreakdown(actorUid, { startDate, endDate }), + ]); + return { + totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0), + chart: chart.rows, + breakdown, + }; +} + +export async function getCoverageReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + WITH daily AS ( + SELECT + date_trunc('day', starts_at) AS day, + SUM(required_workers)::INTEGER AS needed, + SUM(assigned_workers)::INTEGER AS filled + FROM shifts + WHERE tenant_id = $1 + AND business_id = $2 + AND starts_at >= $3::timestamptz + AND starts_at <= $4::timestamptz + GROUP BY 1 + ) + SELECT + day, + needed, + filled, + CASE WHEN needed = 0 THEN 0 + ELSE ROUND((filled::numeric / needed) * 100, 2) + END AS "coveragePercentage" + FROM daily + ORDER BY day ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = result.rows.reduce((acc, row) => { + acc.neededWorkers += Number(row.needed || 0); + acc.filledWorkers += Number(row.filled || 0); + return acc; + }, { neededWorkers: 0, filledWorkers: 0 }); + return { + averageCoveragePercentage: totals.neededWorkers === 0 + ? 0 + : Math.round((totals.filledWorkers / totals.neededWorkers) * 100), + filledWorkers: totals.filledWorkers, + neededWorkers: totals.neededWorkers, + chart: result.rows, + }; +} + +export async function getForecastReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 42); + const weekly = await query( + ` + SELECT + date_trunc('week', s.starts_at) AS week, + COUNT(DISTINCT s.id)::INTEGER AS "shiftCount", + COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours", + COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = weekly.rows.reduce((acc, row) => { + acc.forecastSpendCents += Number(row.forecastSpendCents || 0); + acc.totalShifts += Number(row.shiftCount || 0); + acc.totalWorkerHours += Number(row.workerHours || 0); + return acc; + }, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 }); + return { + forecastSpendCents: totals.forecastSpendCents, + averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length), + totalShifts: totals.totalShifts, + totalWorkerHours: totals.totalWorkerHours, + weeks: weekly.rows.map((row) => ({ + ...row, + averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)), + })), + }; +} + +export async function getPerformanceReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const totals = await query( + ` + WITH base AS ( + SELECT + COUNT(DISTINCT s.id)::INTEGER AS total_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts, + COUNT(DISTINCT a.id) FILTER ( + WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at + )::INTEGER AS on_time_assignments, + COUNT(DISTINCT a.id)::INTEGER AS total_assignments, + COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + fill_times AS ( + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + reviews AS ( + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + ) + SELECT * + FROM base, fill_times, reviews + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const row = totals.rows[0] || {}; + const totalShifts = Number(row.total_shifts || 0); + const totalAssignments = Number(row.total_assignments || 0); + return { + averagePerformanceScore: Number(row.avg_rating || 0), + fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100), + completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100), + onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100), + averageFillTimeMinutes: Number(row.avg_fill_minutes || 0), + totalShiftsCovered: Number(row.completed_shifts || 0), + noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100), + }; +} + +export async function getNoShowReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const incidents = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "staffName", + COUNT(*)::INTEGER AS "incidentCount", + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftTitle', s.title, + 'roleName', sr.role_name, + 'date', s.starts_at + ) + ORDER BY s.starts_at DESC + ) AS incidents + FROM assignments a + JOIN staffs st ON st.id = a.staff_id + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.status = 'NO_SHOW' + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY st.id + ORDER BY "incidentCount" DESC, "staffName" ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0); + const totalWorkers = incidents.rows.length; + const totalAssignments = await query( + ` + SELECT COUNT(*)::INTEGER AS total + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return { + totalNoShowCount, + noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0 + ? 0 + : Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100), + workersWhoNoShowed: totalWorkers, + items: incidents.rows.map((row) => ({ + ...row, + riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM', + })), + }; +} + +export async function listEmergencyContacts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "contactId", + full_name AS "fullName", + phone, + relationship_type AS "relationshipType", + is_primary AS "isPrimary" + FROM emergency_contacts + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY is_primary DESC, created_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTaxForms(actorUid) { + const context = await requireStaffContext(actorUid); + const docs = ['I-9', 'W-4']; + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name AS "formType", + sd.id AS "staffDocumentId", + COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, + COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'TAX_FORM' + AND d.name = ANY($3::text[]) + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId, docs] + ); + return result.rows; +} + +export async function listAttireChecklist(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name, + COALESCE(d.metadata->>'description', '') AS description, + COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory, + sd.id AS "staffDocumentId", + sd.file_uri AS "photoUri", + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.metadata->>'verificationStatus' AS "verificationStatus" + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'ATTIRE' + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTimeCardEntries(actorUid, { month, year }) { + const context = await requireStaffContext(actorUid); + const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10); + const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10); + const start = new Date(Date.UTC(yearValue, monthValue - 1, 1)); + const end = new Date(Date.UTC(yearValue, monthValue, 1)); + const result = await query( + ` + SELECT + s.starts_at::date AS date, + s.title AS "shiftName", + COALESCE(cp.label, s.location_name) AS location, + att.check_in_at AS "clockInAt", + att.check_out_at AS "clockOutAt", + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + AND a.status IN ('CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()] + ); + return result.rows; +} + +export async function getPrivacySettings(actorUid) { + const context = await requireStaffContext(actorUid); + return { + profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true), + }; +} + +export async function listFaqCategories() { + return FAQ_CATEGORIES; +} + +export async function searchFaqs(queryText) { + const needle = `${queryText || ''}`.trim().toLowerCase(); + if (!needle) { + return FAQ_CATEGORIES; + } + return FAQ_CATEGORIES + .map((category) => ({ + category: category.category, + items: category.items.filter((item) => { + const haystack = `${item.question} ${item.answer}`.toLowerCase(); + return haystack.includes(needle); + }), + })) + .filter((category) => category.items.length > 0); +} diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index c42ad7df..92cf3ead 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -10,13 +10,21 @@ function createMobileQueryService() { 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 }), @@ -28,25 +36,34 @@ function createMobileQueryService() { 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' }]), }; } @@ -89,3 +106,43 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () 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'); +}); diff --git a/backend/unified-api/scripts/ensure-v2-demo-users.mjs b/backend/unified-api/scripts/ensure-v2-demo-users.mjs new file mode 100644 index 00000000..d5ea6cc1 --- /dev/null +++ b/backend/unified-api/scripts/ensure-v2-demo-users.mjs @@ -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); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs new file mode 100644 index 00000000..4652fd85 --- /dev/null +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -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); +}); diff --git a/backend/unified-api/src/routes/auth.js b/backend/unified-api/src/routes/auth.js index faafa952..7d101511 100644 --- a/backend/unified-api/src/routes/auth.js +++ b/backend/unified-api/src/routes/auth.js @@ -1,14 +1,29 @@ import express from 'express'; import { AppError } from '../lib/errors.js'; -import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js'; +import { + getSessionForActor, + parseClientSignIn, + parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, + signInClient, + signOutActor, + signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, +} from '../services/auth-service.js'; import { verifyFirebaseToken } from '../services/firebase-auth.js'; const defaultAuthService = { parseClientSignIn, parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, signInClient, signOutActor, signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, getSessionForActor, }; @@ -31,7 +46,7 @@ async function requireAuth(req, _res, next) { return next(); } - const decoded = await verifyFirebaseToken(token, { checkRevoked: true }); + const decoded = await verifyFirebaseToken(token); req.actor = { uid: decoded.uid, email: decoded.email || null, @@ -77,6 +92,32 @@ export function createAuthRouter(options = {}) { } }); + router.post('/staff/phone/start', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneStart(req.body); + const result = await authService.startStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/staff/phone/verify', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneVerify(req.body); + const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + router.get('/session', requireAuth, async (req, res, next) => { try { const session = await authService.getSessionForActor(req.actor); diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js index 69cae510..3dcc971a 100644 --- a/backend/unified-api/src/routes/proxy.js +++ b/backend/unified-api/src/routes/proxy.js @@ -14,10 +14,91 @@ const HOP_BY_HOP_HEADERS = new Set([ 'upgrade', ]); -function resolveTargetBase(pathname) { - if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL; - if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL; - if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL; +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; } @@ -30,13 +111,13 @@ function copyHeaders(source, target) { async function forwardRequest(req, res, next, fetchImpl) { try { - const requestPath = new URL(req.originalUrl, 'http://localhost').pathname; - const baseUrl = resolveTargetBase(requestPath); - if (!baseUrl) { - throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404); + 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(req.originalUrl, baseUrl); + 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; @@ -69,7 +150,7 @@ export function createProxyRouter(options = {}) { const router = Router(); const fetchImpl = options.fetchImpl || fetch; - router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl)); + router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl)); return router; } diff --git a/backend/unified-api/src/services/auth-service.js b/backend/unified-api/src/services/auth-service.js index 5f44dee0..69efe58b 100644 --- a/backend/unified-api/src/services/auth-service.js +++ b/backend/unified-api/src/services/auth-service.js @@ -2,7 +2,13 @@ import { z } from 'zod'; import { AppError } from '../lib/errors.js'; import { withTransaction } from './db.js'; import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js'; -import { deleteAccount, signInWithPassword, signUpWithPassword } from './identity-toolkit.js'; +import { + deleteAccount, + sendVerificationCode, + signInWithPassword, + signInWithPhoneNumber, + signUpWithPassword, +} from './identity-toolkit.js'; import { loadActorContext } from './user-context.js'; const clientSignInSchema = z.object({ @@ -17,6 +23,30 @@ const clientSignUpSchema = z.object({ 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() @@ -40,9 +70,43 @@ function buildAuthEnvelope(authPayload, context) { 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) { @@ -63,6 +127,26 @@ export function parseClientSignUp(body) { 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); } @@ -70,6 +154,7 @@ export async function getSessionForActor(actor) { 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) { @@ -151,6 +236,68 @@ export async function signUpClient(payload, { fetchImpl = fetch } = {}) { } } +function shouldUseClientSdkStaffFlow(payload) { + return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken; +} + +export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (shouldUseClientSdkStaffFlow(payload)) { + return { + mode: 'CLIENT_FIREBASE_SDK', + provider: 'firebase-phone-auth', + phoneNumber: payload.phoneNumber, + nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.', + }; + } + + const authPayload = await sendVerificationCode( + { + phoneNumber: payload.phoneNumber, + recaptchaToken: payload.recaptchaToken, + iosReceipt: payload.iosReceipt, + iosSecret: payload.iosSecret, + captchaResponse: payload.captchaResponse, + playIntegrityToken: payload.playIntegrityToken, + safetyNetToken: payload.safetyNetToken, + }, + fetchImpl + ); + + return { + mode: 'IDENTITY_TOOLKIT_SMS', + phoneNumber: payload.phoneNumber, + sessionInfo: authPayload.sessionInfo, + }; +} + +export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (payload.idToken) { + return hydrateAuthContext( + { + idToken: payload.idToken, + refreshToken: null, + expiresIn: 3600, + }, + { + provider: 'firebase-phone-auth', + } + ); + } + + const authPayload = await signInWithPhoneNumber( + { + sessionInfo: payload.sessionInfo, + code: payload.code, + operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined, + }, + fetchImpl + ); + + return hydrateAuthContext(authPayload, { + provider: 'firebase-phone-auth', + }); +} + export async function signOutActor(actor) { await revokeUserSessions(actor.uid); return { signedOut: true }; diff --git a/backend/unified-api/src/services/firebase-auth.js b/backend/unified-api/src/services/firebase-auth.js index e441b13f..ed2c1839 100644 --- a/backend/unified-api/src/services/firebase-auth.js +++ b/backend/unified-api/src/services/firebase-auth.js @@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) { ensureAdminApp(); await getAuth().revokeRefreshTokens(uid); } + +export async function createCustomToken(uid) { + ensureAdminApp(); + return getAuth().createCustomToken(uid); +} diff --git a/backend/unified-api/src/services/identity-toolkit.js b/backend/unified-api/src/services/identity-toolkit.js index 0b477e4b..f9ed245f 100644 --- a/backend/unified-api/src/services/identity-toolkit.js +++ b/backend/unified-api/src/services/identity-toolkit.js @@ -56,6 +56,33 @@ export async function signUpWithPassword({ email, password }, fetchImpl = fetch) ); } +export async function sendVerificationCode(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:sendVerificationCode', + payload, + fetchImpl + ); +} + +export async function signInWithPhoneNumber(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithPhoneNumber', + payload, + fetchImpl + ); +} + +export async function signInWithCustomToken(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithCustomToken', + { + token: payload.token, + returnSecureToken: true, + }, + fetchImpl + ); +} + export async function deleteAccount({ idToken }, fetchImpl = fetch) { return callIdentityToolkit( 'accounts:delete', diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 144594ef..113cfbec 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -110,3 +110,75 @@ test('proxy forwards query routes to query base url', async () => { 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'); +}); diff --git a/backend/unified-api/test/staff-auth.test.js b/backend/unified-api/test/staff-auth.test.js new file mode 100644 index 00000000..37c8aa42 --- /dev/null +++ b/backend/unified-api/test/staff-auth.test.js @@ -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); +}); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 1a452753..2bf94573 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -2,33 +2,48 @@ This is the frontend-facing source of truth for the v2 backend. -## 1) Frontend entrypoint +## 1) Use one base URL -Frontend should target one public base URL: +Frontend should call one public gateway: ```env -API_V2_BASE_URL= +API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app ``` -The unified v2 gateway exposes: +Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. -- `/auth/*` -- `/core/*` -- `/commands/*` -- `/query/*` -- `/query/client/*` -- `/query/staff/*` +## 2) Current status -Internal services still stay separate behind that gateway. +The unified v2 gateway is ready for frontend integration in `dev`. -## 2) Internal service split +What was validated live against the deployed stack: -| Use case | Internal service | -| --- | --- | -| File upload, signed URLs, model calls, verification helpers | `core-api-v2` | -| Business writes and workflow actions | `command-api-v2` | -| Screen reads and mobile read models | `query-api-v2` | -| Frontend-facing single host and auth wrappers | `krow-api-v2` | +- 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 @@ -38,13 +53,19 @@ Protected routes require: Authorization: Bearer ``` -Command routes also require: +Write routes also require: ```http Idempotency-Key: ``` -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 { @@ -55,83 +76,35 @@ All services return the same error envelope: } ``` -## 4) What frontend can use now on this branch +## 4) Route model -### Unified gateway +Frontend sees one base URL and one route shape: -- `POST /auth/client/sign-in` -- `POST /auth/client/sign-up` -- `POST /auth/sign-out` -- `POST /auth/client/sign-out` -- `POST /auth/staff/sign-out` -- `GET /auth/session` -- Proxy access to `/core/*`, `/commands/*`, `/query/*` +- `/auth/*` +- `/client/*` +- `/staff/*` +- direct upload aliases like `/upload-file` and `/staff/profile/*` -### Client read routes +Internally, the gateway still forwards to: -- `GET /query/client/session` -- `GET /query/client/dashboard` -- `GET /query/client/reorders` -- `GET /query/client/billing/accounts` -- `GET /query/client/billing/invoices/pending` -- `GET /query/client/billing/invoices/history` -- `GET /query/client/billing/current-bill` -- `GET /query/client/billing/savings` -- `GET /query/client/billing/spend-breakdown` -- `GET /query/client/coverage` -- `GET /query/client/coverage/stats` -- `GET /query/client/hubs` -- `GET /query/client/cost-centers` -- `GET /query/client/vendors` -- `GET /query/client/vendors/:vendorId/roles` -- `GET /query/client/hubs/:hubId/managers` -- `GET /query/client/orders/view` +| 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` | -### Staff read routes +## 5) Frontend integration rule -- `GET /query/staff/session` -- `GET /query/staff/dashboard` -- `GET /query/staff/profile-completion` -- `GET /query/staff/availability` -- `GET /query/staff/clock-in/shifts/today` -- `GET /query/staff/clock-in/status` -- `GET /query/staff/payments/summary` -- `GET /query/staff/payments/history` -- `GET /query/staff/payments/chart` -- `GET /query/staff/shifts/assigned` -- `GET /query/staff/shifts/open` -- `GET /query/staff/shifts/pending` -- `GET /query/staff/shifts/cancelled` -- `GET /query/staff/shifts/completed` -- `GET /query/staff/shifts/:shiftId` -- `GET /query/staff/profile/sections` -- `GET /query/staff/profile/personal-info` -- `GET /query/staff/profile/industries` -- `GET /query/staff/profile/skills` -- `GET /query/staff/profile/documents` -- `GET /query/staff/profile/certificates` -- `GET /query/staff/profile/bank-accounts` -- `GET /query/staff/profile/benefits` +Use the unified routes first. -### Existing v2 routes still valid +Do not build new frontend work on: -- `/core/*` routes documented in `core-api.md` -- `/commands/*` routes documented in `command-api.md` -- `/query/tenants/*` routes documented in `query-api.md` +- `/query/tenants/*` +- `/commands/*` +- `/core/*` -## 5) Remaining gaps after this slice - -Still not implemented yet: - -- staff phone OTP wrapper endpoints -- hub write flows -- hub NFC assignment write route -- invoice approve and dispute commands -- staff apply / decline / request swap commands -- staff profile update commands -- availability write commands -- reports suite -- durable verification persistence in `core-api-v2` +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) Docs @@ -139,4 +112,4 @@ Still not implemented yet: - [Core API](./core-api.md) - [Command API](./command-api.md) - [Query API](./query-api.md) -- [Mobile gap analysis](./mobile-api-gap-analysis.md) +- [Mobile API Reconciliation](./mobile-api-gap-analysis.md) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index 97e5e7e9..9e559ffd 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -1,66 +1,45 @@ -# Mobile API Gap Analysis +# Mobile API Reconciliation Source compared against implementation: -- `/Users/wiel/Downloads/mobile-backend-api-specification.md` +- `mobile-backend-api-specification.md` -## Implemented in this slice +## Result -- unified frontend-facing base URL design -- client auth wrapper for email/password sign-in and sign-up -- auth session and sign-out endpoints -- client read surface for dashboard, billing, coverage, hubs, vendor lookup, and date-range order items -- staff read surface for dashboard, availability, clock-in reads, payments, shifts, and profile sections -- schema support for: - - cost centers - - hub managers - - recurring staff availability - - staff benefits -- seed support for: - - authenticated demo staff user - - cost center and hub manager data - - staff benefits and availability - - attire and tax-form example documents +The current mobile v2 surface is implemented behind the unified gateway and validated live in `dev`. -## Still missing +That includes: -### Auth +- auth session routes +- client dashboard, billing, coverage, hubs, vendor lookup, managers, team members, orders, and reports +- client order, hub, coverage review, and invoice write flows +- staff dashboard, availability, payments, shifts, profile sections, documents, attire, certificates, bank accounts, benefits, privacy, and frequently asked questions +- staff availability, tax forms, emergency contacts, bank account, shift decision, clock-in/out, and swap write flows +- upload and verification flows for profile photo, government document, attire, and certificates -- staff phone OTP start -- staff OTP verify -- staff profile setup endpoint +## What was validated live -### Client writes +The live smoke executed successfully against: -- hub create -- hub update -- hub delete -- hub NFC assignment -- assign manager to hub -- invoice approve -- invoice dispute +- `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` -### Staff writes +The validation script is: -- availability update -- availability quick set -- shift apply -- shift decline -- request swap -- personal info update -- preferred locations update -- profile photo upload wrapper +```bash +node backend/unified-api/scripts/live-smoke-v2-unified.mjs +``` -### Reports +## Remaining work -- report summary -- daily ops -- spend -- coverage -- forecast -- performance -- no-show +The remaining items are not blockers for current mobile frontend migration. -### Core persistence +They are follow-up items: -- `core-api-v2` verification jobs still need durable SQL persistence +- extend the same unified pattern to new screens added after the current mobile specification +- add stronger observability and contract automation around the unified route surface +- keep refining reporting and financial read models as product scope expands diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 9b20f022..f80ef542 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -1,50 +1,168 @@ # Unified API V2 -This service exists so frontend can use one base URL without forcing backend into one codebase. +Frontend should use this service as the single base URL: -## Base idea +- `https://krow-api-v2-933560802882.us-central1.run.app` -Frontend talks to one service: +The gateway keeps backend services separate internally, but frontend should treat it as one API. -- `krow-api-v2` +## 1) Auth routes -That gateway does two things: - -1. exposes auth/session endpoints -2. forwards requests to the right internal v2 service - -## Route groups - -### Auth +### Client auth - `POST /auth/client/sign-in` - `POST /auth/client/sign-up` -- `POST /auth/sign-out` - `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` -### Proxy passthrough +## 2) Client routes -- `/core/*` -> `core-api-v2` -- `/commands/*` -> `command-api-v2` -- `/query/*` -> `query-api-v2` +### Client reads -### Mobile read models +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` +- `GET /client/billing/accounts` +- `GET /client/billing/invoices/pending` +- `GET /client/billing/invoices/history` +- `GET /client/billing/current-bill` +- `GET /client/billing/savings` +- `GET /client/billing/spend-breakdown` +- `GET /client/coverage` +- `GET /client/coverage/stats` +- `GET /client/coverage/core-team` +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/vendors` +- `GET /client/vendors/:vendorId/roles` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `GET /client/orders/view` +- `GET /client/orders/:orderId/reorder-preview` +- `GET /client/reports/summary` +- `GET /client/reports/daily-ops` +- `GET /client/reports/spend` +- `GET /client/reports/coverage` +- `GET /client/reports/forecast` +- `GET /client/reports/performance` +- `GET /client/reports/no-show` -These are served by `query-api-v2` but frontend should still call them through the unified host: +### Client writes -- `/query/client/*` -- `/query/staff/*` +- `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` -## Why this shape +## 3) Staff routes -- frontend gets one base URL -- backend keeps separate read, write, and service helpers -- we can scale or refactor internals later without breaking frontend paths +### Staff reads -## Current auth note +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` +- `GET /staff/availability` +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/open` +- `GET /staff/shifts/pending` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `GET /staff/profile/sections` +- `GET /staff/profile/personal-info` +- `GET /staff/profile/industries` +- `GET /staff/profile/skills` +- `GET /staff/profile/documents` +- `GET /staff/profile/attire` +- `GET /staff/profile/tax-forms` +- `GET /staff/profile/emergency-contacts` +- `GET /staff/profile/certificates` +- `GET /staff/profile/bank-accounts` +- `GET /staff/profile/benefits` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `GET /staff/faqs` +- `GET /staff/faqs/search` -Client email/password auth is wrapped here. +### Staff writes -Staff phone OTP is not wrapped here yet. That still needs its own proper provider-backed implementation rather than a fake backend OTP flow. +- `POST /staff/profile/setup` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` +- `POST /staff/shifts/:shiftId/apply` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `PUT /staff/profile/personal-info` +- `PUT /staff/profile/experience` +- `PUT /staff/profile/locations` +- `POST /staff/profile/emergency-contacts` +- `PUT /staff/profile/emergency-contacts/:contactId` +- `PUT /staff/profile/tax-forms/:formType` +- `POST /staff/profile/tax-forms/:formType/submit` +- `POST /staff/profile/bank-accounts` +- `PUT /staff/profile/privacy` + +## 4) Upload and verification routes + +These are exposed as direct unified aliases even though they are backed by `core-api-v2`. + +### Generic core aliases + +- `POST /upload-file` +- `POST /create-signed-url` +- `POST /invoke-llm` +- `POST /rapid-orders/transcribe` +- `POST /rapid-orders/parse` +- `POST /verifications` +- `GET /verifications/:verificationId` +- `POST /verifications/:verificationId/review` +- `POST /verifications/:verificationId/retry` + +### Staff upload aliases + +- `POST /staff/profile/photo` +- `POST /staff/profile/documents/:documentId/upload` +- `POST /staff/profile/attire/:documentId/upload` +- `POST /staff/profile/certificates` +- `DELETE /staff/profile/certificates/:certificateId` + +## 5) Notes that matter for frontend + +- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id. +- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend. +- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. +- Verification routes are durable in the v2 backend and were validated live through document, attire, and certificate upload flows. + +## 6) Why this shape + +- frontend gets one host +- backend keeps reads, writes, and service helpers separated +- routing can change internally later without forcing frontend rewrites diff --git a/makefiles/backend.mk b/makefiles/backend.mk index c0032b14..058ee7f8 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -349,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." @@ -438,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..."