@@ -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