Skip to content

Commit de92c89

Browse files
committed
fix: default 30-day window for analytics queries + scope exemptions for /leaderboard and /profile/me
- analytics.service: add effectiveFrom() helper that defaults to 30 days ago (90 for trend) to prevent unbounded table scans crashing the process on large orgs (502 Bad Gateway) - analytics.service: apply safeFrom to getOrgSummary, getTopPerformers, getSessionTrend, getLeaderboard - api-key-scope: add null scope for GET /leaderboard and GET /profile/me so any authenticated API key can call these non-admin-gated endpoints
1 parent 37278d4 commit de92c89

2 files changed

Lines changed: 31 additions & 8 deletions

File tree

src/middleware/api-key-scope.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { ForbiddenError } from "../utils/errors.js";
44
function routeScope(method: string, routePath: string): ApiKeyScope | "admin:all" | null {
55
// null = any authenticated API key may call this endpoint (no additional scope needed)
66
if (routePath === "/auth/me") return null;
7+
if (method === "GET" && routePath === "/leaderboard") return null;
8+
if (method === "GET" && routePath === "/profile/me") return null;
79

810
if (method === "GET" && routePath.startsWith("/admin/employees")) return "read:employees";
911
if (method === "GET" && (routePath.startsWith("/admin/sessions") || routePath === "/attendance/my-sessions")) {

src/modules/analytics/analytics.service.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ function validateDateRange(
3434
}
3535
}
3636

37+
/**
38+
* Returns a bounded ISO datetime string: the provided `from` if given, or
39+
* midnight UTC exactly `daysBack` days ago. Prevents unbounded table scans
40+
* when callers omit the date range on analytics endpoints.
41+
*/
42+
function effectiveFrom(from: string | undefined, daysBack = 30): string {
43+
if (from !== undefined) return from;
44+
const d = new Date();
45+
d.setUTCDate(d.getUTCDate() - daysBack);
46+
d.setUTCHours(0, 0, 0, 0);
47+
return d.toISOString();
48+
}
49+
3750
/**
3851
* Aggregate expense rows into counts and amounts by status.
3952
* Pure function — no DB access.
@@ -88,13 +101,15 @@ export const analyticsService = {
88101
to: string | undefined,
89102
): Promise<OrgSummaryData> {
90103
validateDateRange(from, to);
104+
// Apply default to prevent unbounded scans on large orgs
105+
const safeFrom = effectiveFrom(from);
91106

92-
const cacheKey = `org:${request.organizationId}:analytics:summary:${from ?? "all"}:${to ?? "all"}`;
107+
const cacheKey = `org:${request.organizationId}:analytics:summary:${safeFrom}:${to ?? "all"}`;
93108
return getCached(cacheKey, ANALYTICS_CACHE_TTL, async () => {
94109
// Step 1: aggregate session stats from pre-computed org_daily_metrics.
95110
// Date filter uses YYYY-MM-DD format; from/to are full ISO datetimes so
96111
// we strip to the date portion for the gte/lte comparison.
97-
const dailyFrom = from ? from.substring(0, 10) : undefined;
112+
const dailyFrom = safeFrom.substring(0, 10);
98113
const dailyTo = to ? to.substring(0, 10) : undefined;
99114
const dailyMetrics = await analyticsRepository.getOrgDailyMetrics(
100115
request,
@@ -113,7 +128,7 @@ export const analyticsService = {
113128

114129
// Step 2: expense aggregation and active employee count — independent, run in parallel
115130
const [expenseRows, activeEmployeesCount] = await Promise.all([
116-
analyticsRepository.getExpensesInRange(request, from, to),
131+
analyticsRepository.getExpensesInRange(request, safeFrom, to),
117132
analyticsRepository.getActiveEmployeesCount(request),
118133
]);
119134

@@ -257,11 +272,13 @@ export const analyticsService = {
257272
limit: number,
258273
): Promise<TopPerformerEntry[]> {
259274
validateDateRange(from, to);
275+
// Apply default to prevent unbounded scans on large orgs
276+
const safeFrom = effectiveFrom(from);
260277

261278
// Single JOIN query — sessions + employee names in one round-trip.
262279
const sessions = await analyticsRepository.getSessionsWithEmployeeNames(
263280
request,
264-
from,
281+
safeFrom,
265282
to,
266283
);
267284

@@ -342,9 +359,11 @@ export const analyticsService = {
342359
to: string | undefined,
343360
): Promise<SessionTrendEntry[]> {
344361
validateDateRange(from, to);
345-
const cacheKey = `org:${request.organizationId}:analytics:trend:${from ?? "all"}:${to ?? "all"}`;
362+
// Apply default to prevent unbounded scans; trend charts benefit from a 90-day window
363+
const safeFrom = effectiveFrom(from, 90);
364+
const cacheKey = `org:${request.organizationId}:analytics:trend:${safeFrom}:${to ?? "all"}`;
346365
return getCached(cacheKey, ANALYTICS_CACHE_TTL, () =>
347-
analyticsRepository.getOrgDailyMetrics(request, from, to),
366+
analyticsRepository.getOrgDailyMetrics(request, safeFrom, to),
348367
);
349368
},
350369

@@ -366,14 +385,16 @@ export const analyticsService = {
366385
limit: number,
367386
): Promise<LeaderboardEntry[]> {
368387
validateDateRange(from, to);
388+
// Apply default to prevent unbounded scans on large orgs
389+
const safeFrom = effectiveFrom(from);
369390

370-
const cacheKey = `org:${request.organizationId}:analytics:leaderboard:${metric}:${limit}:${from ?? "all"}:${to ?? "all"}`;
391+
const cacheKey = `org:${request.organizationId}:analytics:leaderboard:${metric}:${limit}:${safeFrom}:${to ?? "all"}`;
371392
return getCached(cacheKey, ANALYTICS_CACHE_TTL, async () => {
372393
// All metrics — distance / duration / sessions / expenses — read from
373394
// employee_daily_metrics so no GPS or expenses table scans are needed.
374395
const aggregated = await analyticsRepository.getEmployeeMetricsAggregated(
375396
request,
376-
from,
397+
safeFrom,
377398
to,
378399
);
379400

0 commit comments

Comments
 (0)