feat(backend): implement v2 domain slice and live smoke
This commit is contained in:
138
backend/query-api/package-lock.json
generated
138
backend/query-api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
45
backend/query-api/src/middleware/auth.js
Normal file
45
backend/query-api/src/middleware/auth.js
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
138
backend/query-api/src/routes/query.js
Normal file
138
backend/query-api/src/routes/query.js
Normal 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;
|
||||
}
|
||||
72
backend/query-api/src/services/db.js
Normal file
72
backend/query-api/src/services/db.js
Normal 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;
|
||||
}
|
||||
}
|
||||
13
backend/query-api/src/services/firebase-auth.js
Normal file
13
backend/query-api/src/services/firebase-auth.js
Normal 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);
|
||||
}
|
||||
5
backend/query-api/src/services/policy.js
Normal file
5
backend/query-api/src/services/policy.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function can(action, resource, actor) {
|
||||
void action;
|
||||
void resource;
|
||||
return Boolean(actor?.uid);
|
||||
}
|
||||
285
backend/query-api/src/services/query-service.js
Normal file
285
backend/query-api/src/services/query-service.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user