feat(api): add unified v2 gateway and mobile read slice
This commit is contained in:
@@ -49,6 +49,7 @@ async function main() {
|
|||||||
await upsertUser(client, fixture.users.businessOwner);
|
await upsertUser(client, fixture.users.businessOwner);
|
||||||
await upsertUser(client, fixture.users.operationsManager);
|
await upsertUser(client, fixture.users.operationsManager);
|
||||||
await upsertUser(client, fixture.users.vendorManager);
|
await upsertUser(client, fixture.users.vendorManager);
|
||||||
|
await upsertUser(client, fixture.users.staffAna);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
@@ -64,13 +65,15 @@ async function main() {
|
|||||||
VALUES
|
VALUES
|
||||||
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
|
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
|
||||||
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
|
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
|
||||||
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb)
|
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb),
|
||||||
|
($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
fixture.tenant.id,
|
fixture.tenant.id,
|
||||||
fixture.users.businessOwner.id,
|
fixture.users.businessOwner.id,
|
||||||
fixture.users.operationsManager.id,
|
fixture.users.operationsManager.id,
|
||||||
fixture.users.vendorManager.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]
|
[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(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata)
|
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,
|
id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
|
||||||
average_rating, rating_count, metadata
|
average_rating, rating_count, metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, 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.staff.ana.id,
|
||||||
fixture.tenant.id,
|
fixture.tenant.id,
|
||||||
|
fixture.users.staffAna.id,
|
||||||
fixture.staff.ana.fullName,
|
fixture.staff.ana.fullName,
|
||||||
fixture.staff.ana.email,
|
fixture.staff.ana.email,
|
||||||
fixture.staff.ana.phone,
|
fixture.staff.ana.phone,
|
||||||
fixture.staff.ana.primaryRole,
|
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(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO clock_points (
|
INSERT INTO staff_availability (
|
||||||
id, tenant_id, business_id, label, address, latitude, longitude, geofence_radius_meters, nfc_tag_uid, status, metadata
|
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.clockPoint.id,
|
||||||
fixture.tenant.id,
|
fixture.tenant.id,
|
||||||
fixture.business.id,
|
fixture.business.id,
|
||||||
|
fixture.costCenters.cafeOps.id,
|
||||||
fixture.clockPoint.label,
|
fixture.clockPoint.label,
|
||||||
fixture.clockPoint.address,
|
fixture.clockPoint.address,
|
||||||
fixture.clockPoint.latitude,
|
fixture.clockPoint.latitude,
|
||||||
fixture.clockPoint.longitude,
|
fixture.clockPoint.longitude,
|
||||||
fixture.clockPoint.geofenceRadiusMeters,
|
fixture.clockPoint.geofenceRadiusMeters,
|
||||||
fixture.clockPoint.nfcTagUid,
|
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
|
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
|
||||||
)
|
)
|
||||||
VALUES
|
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),
|
($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"}'::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,
|
fixture.orders.open.id,
|
||||||
@@ -411,15 +485,30 @@ async function main() {
|
|||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
|
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
|
||||||
VALUES ($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(
|
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)
|
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,
|
fixture.staffDocuments.foodSafety.id,
|
||||||
@@ -428,6 +517,12 @@ async function main() {
|
|||||||
fixture.documents.foodSafety.id,
|
fixture.documents.foodSafety.id,
|
||||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
||||||
hoursFromNow(24 * 180),
|
hoursFromNow(24 * 180),
|
||||||
|
fixture.staffDocuments.attireBlackShirt.id,
|
||||||
|
fixture.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
|
provider_name, provider_reference, last4, is_primary, metadata
|
||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', 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}'::jsonb)
|
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0002"}'::jsonb)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
fixture.accounts.businessPrimary.id,
|
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,
|
id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code,
|
||||||
subtotal_cents, tax_cents, total_cents, due_at, metadata
|
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,
|
fixture.invoices.completed.id,
|
||||||
@@ -573,6 +668,7 @@ async function main() {
|
|||||||
businessId: fixture.business.id,
|
businessId: fixture.business.id,
|
||||||
vendorId: fixture.vendor.id,
|
vendorId: fixture.vendor.id,
|
||||||
staffId: fixture.staff.ana.id,
|
staffId: fixture.staff.ana.id,
|
||||||
|
staffUserId: fixture.users.staffAna.id,
|
||||||
workforceId: fixture.workforce.ana.id,
|
workforceId: fixture.workforce.ana.id,
|
||||||
openOrderId: fixture.orders.open.id,
|
openOrderId: fixture.orders.open.id,
|
||||||
openShiftId: fixture.shifts.open.id,
|
openShiftId: fixture.shifts.open.id,
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export const V2DemoFixture = {
|
|||||||
email: 'vendor+v2@krowd.com',
|
email: 'vendor+v2@krowd.com',
|
||||||
displayName: 'Vendor Manager',
|
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: {
|
business: {
|
||||||
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
|
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
|
||||||
@@ -31,6 +36,12 @@ export const V2DemoFixture = {
|
|||||||
slug: 'legendary-pool-a',
|
slug: 'legendary-pool-a',
|
||||||
name: 'Legendary Staffing Pool A',
|
name: 'Legendary Staffing Pool A',
|
||||||
},
|
},
|
||||||
|
costCenters: {
|
||||||
|
cafeOps: {
|
||||||
|
id: '31db54dd-9b32-4504-9056-9c71a9f73001',
|
||||||
|
name: 'Cafe Operations',
|
||||||
|
},
|
||||||
|
},
|
||||||
roles: {
|
roles: {
|
||||||
barista: {
|
barista: {
|
||||||
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
|
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
|
||||||
@@ -67,6 +78,25 @@ export const V2DemoFixture = {
|
|||||||
geofenceRadiusMeters: 120,
|
geofenceRadiusMeters: 120,
|
||||||
nfcTagUid: 'NFC-DEMO-ANA-001',
|
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: {
|
orders: {
|
||||||
open: {
|
open: {
|
||||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
|
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
|
||||||
@@ -140,11 +170,25 @@ export const V2DemoFixture = {
|
|||||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
|
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
|
||||||
name: 'Food Handler Card',
|
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: {
|
staffDocuments: {
|
||||||
foodSafety: {
|
foodSafety: {
|
||||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
|
||||||
},
|
},
|
||||||
|
attireBlackShirt: {
|
||||||
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
|
||||||
|
},
|
||||||
|
taxFormW9: {
|
||||||
|
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
certificates: {
|
certificates: {
|
||||||
foodSafety: {
|
foodSafety: {
|
||||||
|
|||||||
64
backend/command-api/sql/v2/002_v2_mobile_support.sql
Normal file
64
backend/command-api/sql/v2/002_v2_mobile_support.sql
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS cost_centers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
code TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||||
|
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_business_name
|
||||||
|
ON cost_centers (business_id, name);
|
||||||
|
|
||||||
|
ALTER TABLE clock_points
|
||||||
|
ADD COLUMN IF NOT EXISTS cost_center_id UUID REFERENCES cost_centers(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hub_managers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
hub_id UUID NOT NULL REFERENCES clock_points(id) ON DELETE CASCADE,
|
||||||
|
business_membership_id UUID NOT NULL REFERENCES business_memberships(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_managers_hub_membership
|
||||||
|
ON hub_managers (hub_id, business_membership_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS staff_availability (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||||
|
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
|
||||||
|
availability_status TEXT NOT NULL DEFAULT 'UNAVAILABLE'
|
||||||
|
CHECK (availability_status IN ('AVAILABLE', 'UNAVAILABLE', 'PARTIAL')),
|
||||||
|
time_slots JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_availability_staff_day
|
||||||
|
ON staff_availability (staff_id, day_of_week);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS staff_benefits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||||
|
benefit_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||||
|
CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING')),
|
||||||
|
tracked_hours INTEGER NOT NULL DEFAULT 0 CHECK (tracked_hours >= 0),
|
||||||
|
target_hours INTEGER NOT NULL DEFAULT 0 CHECK (target_hours >= 0),
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_benefits_staff_type
|
||||||
|
ON staff_benefits (staff_id, benefit_type);
|
||||||
@@ -5,6 +5,7 @@ import { requestContext } from './middleware/request-context.js';
|
|||||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createQueryRouter } from './routes/query.js';
|
import { createQueryRouter } from './routes/query.js';
|
||||||
|
import { createMobileQueryRouter } from './routes/mobile.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function createApp(options = {}) {
|
|||||||
|
|
||||||
app.use(healthRouter);
|
app.use(healthRouter);
|
||||||
app.use('/query', createQueryRouter(options.queryService));
|
app.use('/query', createQueryRouter(options.queryService));
|
||||||
|
app.use('/query', createMobileQueryRouter(options.mobileQueryService));
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
464
backend/query-api/src/routes/mobile.js
Normal file
464
backend/query-api/src/routes/mobile.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
111
backend/query-api/src/services/actor-context.js
Normal file
111
backend/query-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { AppError } from '../lib/errors.js';
|
||||||
|
import { query } from './db.js';
|
||||||
|
|
||||||
|
export async function loadActorContext(uid) {
|
||||||
|
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT tm.id AS "membershipId",
|
||||||
|
tm.tenant_id AS "tenantId",
|
||||||
|
tm.base_role AS role,
|
||||||
|
t.name AS "tenantName",
|
||||||
|
t.slug AS "tenantSlug"
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT bm.id AS "membershipId",
|
||||||
|
bm.business_id AS "businessId",
|
||||||
|
bm.business_role AS role,
|
||||||
|
b.business_name AS "businessName",
|
||||||
|
b.slug AS "businessSlug",
|
||||||
|
bm.tenant_id AS "tenantId"
|
||||||
|
FROM business_memberships bm
|
||||||
|
JOIN businesses b ON b.id = bm.business_id
|
||||||
|
WHERE bm.user_id = $1
|
||||||
|
AND bm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY bm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT vm.id AS "membershipId",
|
||||||
|
vm.vendor_id AS "vendorId",
|
||||||
|
vm.vendor_role AS role,
|
||||||
|
v.company_name AS "vendorName",
|
||||||
|
v.slug AS "vendorSlug",
|
||||||
|
vm.tenant_id AS "tenantId"
|
||||||
|
FROM vendor_memberships vm
|
||||||
|
JOIN vendors v ON v.id = vm.vendor_id
|
||||||
|
WHERE vm.user_id = $1
|
||||||
|
AND vm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY vm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT s.id AS "staffId",
|
||||||
|
s.tenant_id AS "tenantId",
|
||||||
|
s.full_name AS "fullName",
|
||||||
|
s.email,
|
||||||
|
s.phone,
|
||||||
|
s.primary_role AS "primaryRole",
|
||||||
|
s.onboarding_status AS "onboardingStatus",
|
||||||
|
s.status,
|
||||||
|
s.metadata,
|
||||||
|
w.id AS "workforceId",
|
||||||
|
w.vendor_id AS "vendorId",
|
||||||
|
w.workforce_number AS "workforceNumber"
|
||||||
|
FROM staffs s
|
||||||
|
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResult.rows[0] || null,
|
||||||
|
tenant: tenantResult.rows[0] || null,
|
||||||
|
business: businessResult.rows[0] || null,
|
||||||
|
vendor: vendorResult.rows[0] || null,
|
||||||
|
staff: staffResult.rows[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireClientContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant || !context.business) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireStaffContext(uid) {
|
||||||
|
const context = await loadActorContext(uid);
|
||||||
|
if (!context.user || !context.tenant || !context.staff) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
1071
backend/query-api/src/services/mobile-query-service.js
Normal file
1071
backend/query-api/src/services/mobile-query-service.js
Normal file
File diff suppressed because it is too large
Load Diff
91
backend/query-api/test/mobile-routes.test.js
Normal file
91
backend/query-api/test/mobile-routes.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
13
backend/unified-api/Dockerfile
Normal file
13
backend/unified-api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
3661
backend/unified-api/package-lock.json
generated
Normal file
3661
backend/unified-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
backend/unified-api/package.json
Normal file
24
backend/unified-api/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@krow/unified-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"firebase-admin": "^13.0.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-http": "^10.3.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/unified-api/src/app.js
Normal file
31
backend/unified-api/src/app.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import pino from 'pino';
|
||||||
|
import pinoHttp from 'pino-http';
|
||||||
|
import { requestContext } from './middleware/request-context.js';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||||
|
import { healthRouter } from './routes/health.js';
|
||||||
|
import { createAuthRouter } from './routes/auth.js';
|
||||||
|
import { createProxyRouter } from './routes/proxy.js';
|
||||||
|
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
export function createApp(options = {}) {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(requestContext);
|
||||||
|
app.use(
|
||||||
|
pinoHttp({
|
||||||
|
logger,
|
||||||
|
customProps: (req) => ({ requestId: req.requestId }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(healthRouter);
|
||||||
|
app.use('/auth', createAuthRouter({ fetchImpl: options.fetchImpl, authService: options.authService }));
|
||||||
|
app.use(createProxyRouter(options));
|
||||||
|
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
26
backend/unified-api/src/lib/errors.js
Normal file
26
backend/unified-api/src/lib/errors.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
constructor(code, message, status = 400, details = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toErrorEnvelope(error, requestId) {
|
||||||
|
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
||||||
|
const code = error?.code || 'INTERNAL_ERROR';
|
||||||
|
const message = error?.message || 'Unexpected error';
|
||||||
|
const details = error?.details || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
25
backend/unified-api/src/middleware/error-handler.js
Normal file
25
backend/unified-api/src/middleware/error-handler.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { toErrorEnvelope } from '../lib/errors.js';
|
||||||
|
|
||||||
|
export function notFoundHandler(req, res) {
|
||||||
|
res.status(404).json({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `Route not found: ${req.method} ${req.path}`,
|
||||||
|
details: {},
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(error, req, res, _next) {
|
||||||
|
const envelope = toErrorEnvelope(error, req.requestId);
|
||||||
|
if (req.log) {
|
||||||
|
req.log.error(
|
||||||
|
{
|
||||||
|
errCode: envelope.body.code,
|
||||||
|
status: envelope.status,
|
||||||
|
details: envelope.body.details,
|
||||||
|
},
|
||||||
|
envelope.body.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.status(envelope.status).json(envelope.body);
|
||||||
|
}
|
||||||
9
backend/unified-api/src/middleware/request-context.js
Normal file
9
backend/unified-api/src/middleware/request-context.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export function requestContext(req, res, next) {
|
||||||
|
const incoming = req.get('X-Request-Id');
|
||||||
|
req.requestId = incoming || randomUUID();
|
||||||
|
res.setHeader('X-Request-Id', req.requestId);
|
||||||
|
res.locals.startedAt = Date.now();
|
||||||
|
next();
|
||||||
|
}
|
||||||
129
backend/unified-api/src/routes/auth.js
Normal file
129
backend/unified-api/src/routes/auth.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
45
backend/unified-api/src/routes/health.js
Normal file
45
backend/unified-api/src/routes/health.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
function healthHandler(req, res) {
|
||||||
|
res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
service: 'krow-api-v2',
|
||||||
|
version: process.env.SERVICE_VERSION || 'dev',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
healthRouter.get('/health', healthHandler);
|
||||||
|
healthRouter.get('/healthz', healthHandler);
|
||||||
|
|
||||||
|
healthRouter.get('/readyz', async (req, res) => {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
service: 'krow-api-v2',
|
||||||
|
status: 'DATABASE_NOT_CONFIGURED',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await checkDatabaseHealth();
|
||||||
|
return res.status(ok ? 200 : 503).json({
|
||||||
|
ok,
|
||||||
|
service: 'krow-api-v2',
|
||||||
|
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
service: 'krow-api-v2',
|
||||||
|
status: 'DATABASE_UNAVAILABLE',
|
||||||
|
details: { message: error.message },
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
75
backend/unified-api/src/routes/proxy.js
Normal file
75
backend/unified-api/src/routes/proxy.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
9
backend/unified-api/src/server.js
Normal file
9
backend/unified-api/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createApp } from './app.js';
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 8080);
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`krow-api-v2 listening on port ${port}`);
|
||||||
|
});
|
||||||
157
backend/unified-api/src/services/auth-service.js
Normal file
157
backend/unified-api/src/services/auth-service.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
87
backend/unified-api/src/services/db.js
Normal file
87
backend/unified-api/src/services/db.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function parseIntOrDefault(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDatabasePoolConfig() {
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return {
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||||
|
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = process.env.DB_USER;
|
||||||
|
const password = process.env.DB_PASSWORD;
|
||||||
|
const database = process.env.DB_NAME;
|
||||||
|
const host = process.env.DB_HOST || (
|
||||||
|
process.env.INSTANCE_CONNECTION_NAME
|
||||||
|
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || password == null || !database || !host) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||||
|
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDatabaseConfigured() {
|
||||||
|
return Boolean(resolveDatabasePoolConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
const resolved = resolveDatabasePoolConfig();
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error('Database connection settings are required');
|
||||||
|
}
|
||||||
|
pool = new Pool(resolved);
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(text, params = []) {
|
||||||
|
return getPool().query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTransaction(work) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await work(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkDatabaseHealth() {
|
||||||
|
const result = await query('SELECT 1 AS ok');
|
||||||
|
return result.rows[0]?.ok === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool() {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/unified-api/src/services/firebase-auth.js
Normal file
18
backend/unified-api/src/services/firebase-auth.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
65
backend/unified-api/src/services/identity-toolkit.js
Normal file
65
backend/unified-api/src/services/identity-toolkit.js
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
91
backend/unified-api/src/services/user-context.js
Normal file
91
backend/unified-api/src/services/user-context.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { query } from './db.js';
|
||||||
|
|
||||||
|
export async function loadActorContext(uid) {
|
||||||
|
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT tm.id AS "membershipId",
|
||||||
|
tm.tenant_id AS "tenantId",
|
||||||
|
tm.base_role AS role,
|
||||||
|
t.name AS "tenantName",
|
||||||
|
t.slug AS "tenantSlug"
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT bm.id AS "membershipId",
|
||||||
|
bm.business_id AS "businessId",
|
||||||
|
bm.business_role AS role,
|
||||||
|
b.business_name AS "businessName",
|
||||||
|
b.slug AS "businessSlug",
|
||||||
|
bm.tenant_id AS "tenantId"
|
||||||
|
FROM business_memberships bm
|
||||||
|
JOIN businesses b ON b.id = bm.business_id
|
||||||
|
WHERE bm.user_id = $1
|
||||||
|
AND bm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY bm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT vm.id AS "membershipId",
|
||||||
|
vm.vendor_id AS "vendorId",
|
||||||
|
vm.vendor_role AS role,
|
||||||
|
v.company_name AS "vendorName",
|
||||||
|
v.slug AS "vendorSlug",
|
||||||
|
vm.tenant_id AS "tenantId"
|
||||||
|
FROM vendor_memberships vm
|
||||||
|
JOIN vendors v ON v.id = vm.vendor_id
|
||||||
|
WHERE vm.user_id = $1
|
||||||
|
AND vm.membership_status = 'ACTIVE'
|
||||||
|
ORDER BY vm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT s.id AS "staffId",
|
||||||
|
s.tenant_id AS "tenantId",
|
||||||
|
s.full_name AS "fullName",
|
||||||
|
s.primary_role AS "primaryRole",
|
||||||
|
s.onboarding_status AS "onboardingStatus",
|
||||||
|
s.status,
|
||||||
|
w.id AS "workforceId",
|
||||||
|
w.vendor_id AS "vendorId",
|
||||||
|
w.workforce_number AS "workforceNumber"
|
||||||
|
FROM staffs s
|
||||||
|
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[uid]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResult.rows[0] || null,
|
||||||
|
tenant: tenantResult.rows[0] || null,
|
||||||
|
business: businessResult.rows[0] || null,
|
||||||
|
vendor: vendorResult.rows[0] || null,
|
||||||
|
staff: staffResult.rows[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
backend/unified-api/test/app.test.js
Normal file
112
backend/unified-api/test/app.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
@@ -2,31 +2,43 @@
|
|||||||
|
|
||||||
This is the frontend-facing source of truth for the v2 backend.
|
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=<krow-api-v2-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` |
|
| Business writes and workflow actions | `command-api-v2` |
|
||||||
| Screen reads for the implemented v2 views | `query-api-v2` |
|
| Screen reads and mobile read models | `query-api-v2` |
|
||||||
|
| Frontend-facing single host and auth wrappers | `krow-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`
|
|
||||||
|
|
||||||
## 3) Auth and headers
|
## 3) Auth and headers
|
||||||
|
|
||||||
All protected routes require:
|
Protected routes require:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Authorization: Bearer <firebase-id-token>
|
Authorization: Bearer <firebase-id-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
All command routes also require:
|
Command routes also require:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Idempotency-Key: <unique-per-user-action>
|
Idempotency-Key: <unique-per-user-action>
|
||||||
@@ -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`
|
- `POST /auth/client/sign-in`
|
||||||
- upload file
|
- `POST /auth/client/sign-up`
|
||||||
- create signed URL
|
- `POST /auth/sign-out`
|
||||||
- invoke model
|
- `POST /auth/client/sign-out`
|
||||||
- rapid order transcribe
|
- `POST /auth/staff/sign-out`
|
||||||
- rapid order parse
|
- `GET /auth/session`
|
||||||
- create verification
|
- Proxy access to `/core/*`, `/commands/*`, `/query/*`
|
||||||
- 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
|
|
||||||
|
|
||||||
### Do not move yet
|
### Client read routes
|
||||||
|
|
||||||
- reports
|
- `GET /query/client/session`
|
||||||
- payments and finance screens
|
- `GET /query/client/dashboard`
|
||||||
- undocumented dashboard reads
|
- `GET /query/client/reorders`
|
||||||
- undocumented scheduling reads and writes
|
- `GET /query/client/billing/accounts`
|
||||||
- any flow that assumes verification history is durable in SQL
|
- `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:
|
### Existing v2 routes still valid
|
||||||
- uploaded files in Google Cloud Storage
|
|
||||||
- generated signed URLs
|
|
||||||
- model invocation itself
|
|
||||||
|
|
||||||
What is not yet durable:
|
- `/core/*` routes documented in `core-api.md`
|
||||||
- verification job history
|
- `/commands/*` routes documented in `command-api.md`
|
||||||
- verification review history
|
- `/query/tenants/*` routes documented in `query-api.md`
|
||||||
- verification event history
|
|
||||||
|
|
||||||
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
|
- staff phone OTP wrapper endpoints
|
||||||
CORE_API_V2_BASE_URL=https://krow-core-api-v2-e3g6witsvq-uc.a.run.app
|
- hub write flows
|
||||||
COMMAND_API_V2_BASE_URL=https://krow-command-api-v2-e3g6witsvq-uc.a.run.app
|
- hub NFC assignment write route
|
||||||
QUERY_API_V2_BASE_URL=https://krow-query-api-v2-e3g6witsvq-uc.a.run.app
|
- 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)
|
- [Core API](./core-api.md)
|
||||||
- [Command API](./command-api.md)
|
- [Command API](./command-api.md)
|
||||||
- [Query API](./query-api.md)
|
- [Query API](./query-api.md)
|
||||||
|
- [Mobile gap analysis](./mobile-api-gap-analysis.md)
|
||||||
## 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`.
|
|
||||||
|
|||||||
66
docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md
Normal file
66
docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md
Normal file
@@ -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
|
||||||
50
docs/BACKEND/API_GUIDES/V2/unified-api.md
Normal file
50
docs/BACKEND/API_GUIDES/V2/unified-api.md
Normal file
@@ -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.
|
||||||
@@ -40,12 +40,14 @@ BACKEND_V2_ARTIFACT_REPO ?= krow-backend-v2
|
|||||||
BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2
|
BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2
|
||||||
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
|
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
|
||||||
BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-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_NAME ?= krow-backend-v2-runtime
|
||||||
BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
|
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_CORE_DIR ?= backend/core-api
|
||||||
BACKEND_V2_COMMAND_DIR ?= backend/command-api
|
BACKEND_V2_COMMAND_DIR ?= backend/command-api
|
||||||
BACKEND_V2_QUERY_DIR ?= backend/query-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_INSTANCE ?= krow-sql-v2
|
||||||
BACKEND_V2_SQL_DATABASE ?= krow_v2_db
|
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_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_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_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:
|
backend-help:
|
||||||
@echo "--> Backend Foundation Commands"
|
@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-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-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-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-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-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-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-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-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"
|
@echo " make backend-logs-core-v2 [ENV=dev] Read core API v2 logs"
|
||||||
|
|
||||||
backend-enable-apis:
|
backend-enable-apis:
|
||||||
@@ -385,6 +391,33 @@ backend-deploy-query-v2:
|
|||||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||||
@echo "✅ Query backend v2 service deployed."
|
@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:
|
backend-v2-migrate-idempotency:
|
||||||
@echo "--> Applying idempotency table migration for command API v2..."
|
@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)
|
@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); \
|
TOKEN=$$(gcloud auth print-identity-token); \
|
||||||
curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Query v2 smoke check passed: $$URL/readyz"
|
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:
|
backend-logs-core-v2:
|
||||||
@echo "--> Reading logs for core backend v2 service [$(BACKEND_V2_CORE_SERVICE_NAME)]..."
|
@echo "--> Reading logs for core backend v2 service [$(BACKEND_V2_CORE_SERVICE_NAME)]..."
|
||||||
@gcloud run services logs read $(BACKEND_V2_CORE_SERVICE_NAME) \
|
@gcloud run services logs read $(BACKEND_V2_CORE_SERVICE_NAME) \
|
||||||
|
|||||||
Reference in New Issue
Block a user