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

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