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