feat(backend): implement v2 domain slice and live smoke

This commit is contained in:
zouantchaw
2026-03-11 18:23:55 +01:00
parent bc068373e9
commit fe43ff23cf
40 changed files with 5191 additions and 99 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0"
},
@@ -1991,6 +1992,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -2040,6 +2130,45 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -2839,6 +2968,15 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0"
},

View File

@@ -4,10 +4,11 @@ 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 { createQueryRouter } from './routes/query.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp() {
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
@@ -20,6 +21,7 @@ export function createApp() {
app.use(express.json({ limit: '2mb' }));
app.use(healthRouter);
app.use('/query', createQueryRouter(options.queryService));
app.use(notFoundHandler);
app.use(errorHandler);

View File

@@ -0,0 +1,45 @@
import { AppError } from '../lib/errors.js';
import { can } from '../services/policy.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
function getBearerToken(header) {
if (!header) return null;
const [scheme, token] = header.split(' ');
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
return token;
}
export async function requireAuth(req, _res, next) {
try {
const token = getBearerToken(req.get('Authorization'));
if (!token) {
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
return next();
}
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
role: decoded.role || null,
};
return next();
} catch (error) {
if (error instanceof AppError) return next(error);
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
}
}
export function requirePolicy(action, resource) {
return (req, _res, next) => {
if (!can(action, resource, req.actor)) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
}
return next();
};
}

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
@@ -13,3 +14,32 @@ function healthHandler(req, res) {
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-query-api',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,138 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
} from '../services/query-service.js';
const defaultQueryService = {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
};
function requireUuid(value, field) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new AppError('VALIDATION_ERROR', `${field} must be a UUID`, 400, { field });
}
return value;
}
export function createQueryRouter(queryService = defaultQueryService) {
const router = Router();
router.get(
'/tenants/:tenantId/orders',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
const orders = await queryService.listOrders({
tenantId,
businessId: req.query.businessId,
status: req.query.status,
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items: orders,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/orders/:orderId',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const order = await queryService.getOrderDetail({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
orderId: requireUuid(req.params.orderId, 'orderId'),
});
return res.status(200).json({
...order,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/businesses/:businessId/favorite-staff',
requireAuth,
requirePolicy('business.favorite-staff.read', 'staff'),
async (req, res, next) => {
try {
const items = await queryService.listFavoriteStaff({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
businessId: requireUuid(req.params.businessId, 'businessId'),
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/staff/:staffId/review-summary',
requireAuth,
requirePolicy('staff.reviews.read', 'staff'),
async (req, res, next) => {
try {
const summary = await queryService.getStaffReviewSummary({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
staffId: requireUuid(req.params.staffId, 'staffId'),
limit: req.query.limit,
});
return res.status(200).json({
...summary,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/assignments/:assignmentId/attendance',
requireAuth,
requirePolicy('attendance.read', 'attendance'),
async (req, res, next) => {
try {
const attendance = await queryService.getAssignmentAttendance({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
});
return res.status(200).json({
...attendance,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
return router;
}

View File

@@ -0,0 +1,72 @@
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 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,13 @@
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) {
ensureAdminApp();
return getAuth().verifyIdToken(token);
}

View File

@@ -0,0 +1,5 @@
export function can(action, resource, actor) {
void action;
void resource;
return Boolean(actor?.uid);
}

View File

@@ -0,0 +1,285 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
function parseLimit(value, fallback = 20, max = 100) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, max);
}
function parseOffset(value) {
const parsed = Number.parseInt(`${value || 0}`, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
}
export async function listOrders({ tenantId, businessId, status, limit, offset }) {
const result = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName",
COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount",
COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers",
COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
LEFT JOIN shifts s ON s.order_id = o.id
WHERE o.tenant_id = $1
AND ($2::uuid IS NULL OR o.business_id = $2::uuid)
AND ($3::text IS NULL OR o.status = $3::text)
GROUP BY o.id, b.id, v.id
ORDER BY o.created_at DESC
LIMIT $4 OFFSET $5
`,
[
tenantId,
businessId || null,
status || null,
parseLimit(limit),
parseOffset(offset),
]
);
return result.rows;
}
export async function getOrderDetail({ tenantId, orderId }) {
const orderResult = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.description,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.latitude,
o.longitude,
o.notes,
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
WHERE o.tenant_id = $1
AND o.id = $2
`,
[tenantId, orderId]
);
if (orderResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId });
}
const shiftsResult = await query(
`
SELECT
s.id,
s.shift_code AS "shiftCode",
s.title,
s.status,
s.starts_at AS "startsAt",
s.ends_at AS "endsAt",
s.timezone,
s.location_name AS "locationName",
s.location_address AS "locationAddress",
s.required_workers AS "requiredWorkers",
s.assigned_workers AS "assignedWorkers",
cp.id AS "clockPointId",
cp.label AS "clockPointLabel"
FROM shifts s
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE s.tenant_id = $1
AND s.order_id = $2
ORDER BY s.starts_at ASC
`,
[tenantId, orderId]
);
const shiftIds = shiftsResult.rows.map((row) => row.id);
let rolesByShiftId = new Map();
if (shiftIds.length > 0) {
const rolesResult = await query(
`
SELECT
sr.id,
sr.shift_id AS "shiftId",
sr.role_code AS "roleCode",
sr.role_name AS "roleName",
sr.workers_needed AS "workersNeeded",
sr.assigned_count AS "assignedCount",
sr.pay_rate_cents AS "payRateCents",
sr.bill_rate_cents AS "billRateCents"
FROM shift_roles sr
WHERE sr.shift_id = ANY($1::uuid[])
ORDER BY sr.role_name ASC
`,
[shiftIds]
);
rolesByShiftId = rolesResult.rows.reduce((map, row) => {
const list = map.get(row.shiftId) || [];
list.push(row);
map.set(row.shiftId, list);
return map;
}, new Map());
}
return {
...orderResult.rows[0],
shifts: shiftsResult.rows.map((shift) => ({
...shift,
roles: rolesByShiftId.get(shift.id) || [],
})),
};
}
export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) {
const result = await query(
`
SELECT
sf.id AS "favoriteId",
sf.created_at AS "favoritedAt",
s.id AS "staffId",
s.full_name AS "fullName",
s.primary_role AS "primaryRole",
s.average_rating AS "averageRating",
s.rating_count AS "ratingCount",
s.status
FROM staff_favorites sf
JOIN staffs s ON s.id = sf.staff_id
WHERE sf.tenant_id = $1
AND sf.business_id = $2
ORDER BY sf.created_at DESC
LIMIT $3 OFFSET $4
`,
[tenantId, businessId, parseLimit(limit), parseOffset(offset)]
);
return result.rows;
}
export async function getStaffReviewSummary({ tenantId, staffId, limit }) {
const staffResult = await query(
`
SELECT
id AS "staffId",
full_name AS "fullName",
average_rating AS "averageRating",
rating_count AS "ratingCount",
primary_role AS "primaryRole",
status
FROM staffs
WHERE tenant_id = $1
AND id = $2
`,
[tenantId, staffId]
);
if (staffResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId });
}
const reviewsResult = await query(
`
SELECT
sr.id AS "reviewId",
sr.rating,
sr.review_text AS "reviewText",
sr.tags,
sr.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
sr.assignment_id AS "assignmentId"
FROM staff_reviews sr
JOIN businesses b ON b.id = sr.business_id
WHERE sr.tenant_id = $1
AND sr.staff_id = $2
ORDER BY sr.created_at DESC
LIMIT $3
`,
[tenantId, staffId, parseLimit(limit, 10, 50)]
);
return {
...staffResult.rows[0],
reviews: reviewsResult.rows,
};
}
export async function getAssignmentAttendance({ tenantId, assignmentId }) {
const assignmentResult = await query(
`
SELECT
a.id AS "assignmentId",
a.status,
a.shift_id AS "shiftId",
a.staff_id AS "staffId",
s.title AS "shiftTitle",
s.starts_at AS "shiftStartsAt",
s.ends_at AS "shiftEndsAt",
attendance_sessions.id AS "sessionId",
attendance_sessions.status AS "sessionStatus",
attendance_sessions.check_in_at AS "checkInAt",
attendance_sessions.check_out_at AS "checkOutAt",
attendance_sessions.worked_minutes AS "workedMinutes"
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id
WHERE a.id = $1
AND a.tenant_id = $2
`,
[assignmentId, tenantId]
);
if (assignmentResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId });
}
const eventsResult = await query(
`
SELECT
id AS "attendanceEventId",
event_type AS "eventType",
source_type AS "sourceType",
source_reference AS "sourceReference",
nfc_tag_uid AS "nfcTagUid",
latitude,
longitude,
distance_to_clock_point_meters AS "distanceToClockPointMeters",
within_geofence AS "withinGeofence",
validation_status AS "validationStatus",
validation_reason AS "validationReason",
captured_at AS "capturedAt"
FROM attendance_events
WHERE assignment_id = $1
ORDER BY captured_at ASC
`,
[assignmentId]
);
return {
...assignmentResult.rows[0],
events: eventsResult.rows,
};
}

View File

@@ -3,6 +3,14 @@ import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
process.env.AUTH_BYPASS = 'true';
const tenantId = '11111111-1111-4111-8111-111111111111';
const orderId = '22222222-2222-4222-8222-222222222222';
const businessId = '33333333-3333-4333-8333-333333333333';
const staffId = '44444444-4444-4444-8444-444444444444';
const assignmentId = '55555555-5555-4555-8555-555555555555';
test('GET /healthz returns healthy response', async () => {
const app = createApp();
const res = await request(app).get('/healthz');
@@ -14,6 +22,21 @@ test('GET /healthz returns healthy response', async () => {
assert.equal(typeof res.headers['x-request-id'], 'string');
});
test('GET /readyz reports database not configured when no database env is present', 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('GET unknown route returns not found envelope', async () => {
const app = createApp();
const res = await request(app).get('/query/unknown');
@@ -22,3 +45,82 @@ test('GET unknown route returns not found envelope', async () => {
assert.equal(res.body.code, 'NOT_FOUND');
assert.equal(typeof res.body.requestId, 'string');
});
test('GET /query/tenants/:tenantId/orders returns injected query result', async () => {
const app = createApp({
queryService: {
listOrders: async (params) => {
assert.equal(params.tenantId, tenantId);
return [{
id: orderId,
orderNumber: 'ORD-1001',
title: 'Cafe Event Staffing',
status: 'OPEN',
}];
},
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/orders`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items.length, 1);
assert.equal(res.body.items[0].id, orderId);
});
test('GET /query/tenants/:tenantId/assignments/:assignmentId/attendance returns injected attendance', async () => {
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.assignmentId, assignmentId);
return {
assignmentId,
sessionStatus: 'OPEN',
events: [],
};
},
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/assignments/${assignmentId}/attendance`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.assignmentId, assignmentId);
assert.equal(res.body.sessionStatus, 'OPEN');
});
test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validates auth and handler wiring', async () => {
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.businessId, businessId);
return [{ staffId, fullName: 'Ana Barista' }];
},
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/businesses/${businessId}/favorite-staff`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, staffId);
});