feat(backend): implement v2 domain slice and live smoke
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user