feat(api): add unified v2 gateway and mobile read slice

This commit is contained in:
zouantchaw
2026-03-13 15:17:00 +01:00
parent 13bcfc9d40
commit 817a39e305
29 changed files with 6788 additions and 87 deletions

View File

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

View File

@@ -20,6 +20,11 @@ export const V2DemoFixture = {
email: 'vendor+v2@krowd.com',
displayName: 'Vendor Manager',
},
staffAna: {
id: process.env.V2_DEMO_STAFF_UID || 'demo-staff-ana',
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
displayName: 'Ana Barista',
},
},
business: {
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
@@ -31,6 +36,12 @@ export const V2DemoFixture = {
slug: 'legendary-pool-a',
name: 'Legendary Staffing Pool A',
},
costCenters: {
cafeOps: {
id: '31db54dd-9b32-4504-9056-9c71a9f73001',
name: 'Cafe Operations',
},
},
roles: {
barista: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
@@ -67,6 +78,25 @@ export const V2DemoFixture = {
geofenceRadiusMeters: 120,
nfcTagUid: 'NFC-DEMO-ANA-001',
},
hubManagers: {
opsLead: {
id: '3f2dfd17-e6b4-4fe4-9fea-3c91c7ca8001',
},
},
availability: {
monday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902001',
},
friday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902002',
},
},
benefits: {
commuter: {
id: 'dbd28438-66b0-452f-a5fc-dd0f3ea61001',
title: 'Commuter Support',
},
},
orders: {
open: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
@@ -140,11 +170,25 @@ export const V2DemoFixture = {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
name: 'Food Handler Card',
},
attireBlackShirt: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
name: 'Black Shirt',
},
taxFormW9: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
name: 'W-9 Tax Form',
},
},
staffDocuments: {
foodSafety: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
},
attireBlackShirt: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
},
taxFormW9: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
},
},
certificates: {
foodSafety: {

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

@@ -0,0 +1,111 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT bm.id AS "membershipId",
bm.business_id AS "businessId",
bm.business_role AS role,
b.business_name AS "businessName",
b.slug AS "businessSlug",
bm.tenant_id AS "tenantId"
FROM business_memberships bm
JOIN businesses b ON b.id = bm.business_id
WHERE bm.user_id = $1
AND bm.membership_status = 'ACTIVE'
ORDER BY bm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT vm.id AS "membershipId",
vm.vendor_id AS "vendorId",
vm.vendor_role AS role,
v.company_name AS "vendorName",
v.slug AS "vendorSlug",
vm.tenant_id AS "tenantId"
FROM vendor_memberships vm
JOIN vendors v ON v.id = vm.vendor_id
WHERE vm.user_id = $1
AND vm.membership_status = 'ACTIVE'
ORDER BY vm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.email,
s.phone,
s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
s.metadata,
w.id AS "workforceId",
w.vendor_id AS "vendorId",
w.workforce_number AS "workforceNumber"
FROM staffs s
LEFT JOIN workforce w ON w.staff_id = s.id
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
business: businessResult.rows[0] || null,
vendor: vendorResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}
export async function requireClientContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.business) {
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
}
return context;
}
export async function requireStaffContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.staff) {
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
}
return context;
}

File diff suppressed because it is too large Load Diff

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

@@ -0,0 +1,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 };
}

View File

@@ -0,0 +1,87 @@
import { Pool } from 'pg';
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveDatabasePoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function withTransaction(work) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,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);
}

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

View File

@@ -0,0 +1,91 @@
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT bm.id AS "membershipId",
bm.business_id AS "businessId",
bm.business_role AS role,
b.business_name AS "businessName",
b.slug AS "businessSlug",
bm.tenant_id AS "tenantId"
FROM business_memberships bm
JOIN businesses b ON b.id = bm.business_id
WHERE bm.user_id = $1
AND bm.membership_status = 'ACTIVE'
ORDER BY bm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT vm.id AS "membershipId",
vm.vendor_id AS "vendorId",
vm.vendor_role AS role,
v.company_name AS "vendorName",
v.slug AS "vendorSlug",
vm.tenant_id AS "tenantId"
FROM vendor_memberships vm
JOIN vendors v ON v.id = vm.vendor_id
WHERE vm.user_id = $1
AND vm.membership_status = 'ACTIVE'
ORDER BY vm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
w.id AS "workforceId",
w.vendor_id AS "vendorId",
w.workforce_number AS "workforceNumber"
FROM staffs s
LEFT JOIN workforce w ON w.staff_id = s.id
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
business: businessResult.rows[0] || null,
vendor: vendorResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}

View File

@@ -0,0 +1,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');
});

View File

@@ -2,31 +2,43 @@
This is the frontend-facing source of truth for the v2 backend.
If you are building against the new backend, start here.
## 1) Frontend entrypoint
## 1) Which service to use
Frontend should target one public base URL:
| Use case | Service |
```env
API_V2_BASE_URL=<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` |
| Screen reads for the implemented v2 views | `query-api-v2` |
## 2) Live dev base URLs
- Core API: `https://krow-core-api-v2-e3g6witsvq-uc.a.run.app`
- Command API: `https://krow-command-api-v2-e3g6witsvq-uc.a.run.app`
- Query API: `https://krow-query-api-v2-e3g6witsvq-uc.a.run.app`
| Screen reads and mobile read models | `query-api-v2` |
| Frontend-facing single host and auth wrappers | `krow-api-v2` |
## 3) Auth and headers
All protected routes require:
Protected routes require:
```http
Authorization: Bearer <firebase-id-token>
```
All command routes also require:
Command routes also require:
```http
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`
- upload file
- create signed URL
- invoke model
- rapid order transcribe
- rapid order parse
- create verification
- get verification
- review verification
- retry verification
- `command-api-v2`
- create order
- update order
- cancel order
- assign staff to shift
- accept shift
- change shift status
- clock in
- clock out
- favorite and unfavorite staff
- create staff review
- `query-api-v2`
- order list
- order detail
- favorite staff list
- staff review summary
- assignment attendance detail
- `POST /auth/client/sign-in`
- `POST /auth/client/sign-up`
- `POST /auth/sign-out`
- `POST /auth/client/sign-out`
- `POST /auth/staff/sign-out`
- `GET /auth/session`
- Proxy access to `/core/*`, `/commands/*`, `/query/*`
### Do not move yet
### Client read routes
- reports
- payments and finance screens
- undocumented dashboard reads
- undocumented scheduling reads and writes
- any flow that assumes verification history is durable in SQL
- `GET /query/client/session`
- `GET /query/client/dashboard`
- `GET /query/client/reorders`
- `GET /query/client/billing/accounts`
- `GET /query/client/billing/invoices/pending`
- `GET /query/client/billing/invoices/history`
- `GET /query/client/billing/current-bill`
- `GET /query/client/billing/savings`
- `GET /query/client/billing/spend-breakdown`
- `GET /query/client/coverage`
- `GET /query/client/coverage/stats`
- `GET /query/client/hubs`
- `GET /query/client/cost-centers`
- `GET /query/client/vendors`
- `GET /query/client/vendors/:vendorId/roles`
- `GET /query/client/hubs/:hubId/managers`
- `GET /query/client/orders/view`
## 5) Important caveat
### Staff read routes
`core-api-v2` is usable now, but verification job state is not yet persisted to `krow-sql-v2`.
- `GET /query/staff/session`
- `GET /query/staff/dashboard`
- `GET /query/staff/profile-completion`
- `GET /query/staff/availability`
- `GET /query/staff/clock-in/shifts/today`
- `GET /query/staff/clock-in/status`
- `GET /query/staff/payments/summary`
- `GET /query/staff/payments/history`
- `GET /query/staff/payments/chart`
- `GET /query/staff/shifts/assigned`
- `GET /query/staff/shifts/open`
- `GET /query/staff/shifts/pending`
- `GET /query/staff/shifts/cancelled`
- `GET /query/staff/shifts/completed`
- `GET /query/staff/shifts/:shiftId`
- `GET /query/staff/profile/sections`
- `GET /query/staff/profile/personal-info`
- `GET /query/staff/profile/industries`
- `GET /query/staff/profile/skills`
- `GET /query/staff/profile/documents`
- `GET /query/staff/profile/certificates`
- `GET /query/staff/profile/bank-accounts`
- `GET /query/staff/profile/benefits`
What is durable today:
- uploaded files in Google Cloud Storage
- generated signed URLs
- model invocation itself
### Existing v2 routes still valid
What is not yet durable:
- verification job history
- verification review history
- verification event history
- `/core/*` routes documented in `core-api.md`
- `/commands/*` routes documented in `command-api.md`
- `/query/tenants/*` routes documented in `query-api.md`
That means frontend can integrate with verification routes now, but should not treat them as mission-critical durable state yet.
## 5) Remaining gaps after this slice
## 6) Recommended frontend environment variables
Still not implemented yet:
```env
CORE_API_V2_BASE_URL=https://krow-core-api-v2-e3g6witsvq-uc.a.run.app
COMMAND_API_V2_BASE_URL=https://krow-command-api-v2-e3g6witsvq-uc.a.run.app
QUERY_API_V2_BASE_URL=https://krow-query-api-v2-e3g6witsvq-uc.a.run.app
```
- staff phone OTP wrapper endpoints
- hub write flows
- hub NFC assignment write route
- invoice approve and dispute commands
- staff apply / decline / request swap commands
- staff profile update commands
- availability write commands
- reports suite
- durable verification persistence in `core-api-v2`
## 7) Service docs
## 6) Docs
- [Unified API](./unified-api.md)
- [Core API](./core-api.md)
- [Command API](./command-api.md)
- [Query API](./query-api.md)
## 8) Frontend integration rule
Do not point screens directly at database access just because a route does not exist yet.
If a screen is missing from the docs, the next step is to define the route contract and add it to `query-api-v2` or `command-api-v2`.
- [Mobile gap analysis](./mobile-api-gap-analysis.md)

View 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

View 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.

View File

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