From 332854bb29c43b835df44d84e23bfa2e2b1b461f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 7 Apr 2026 13:40:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(trends):=20=E2=9C=A8=20Add=20real-time=20t?= =?UTF-8?q?rend=20update=20support=20with=20new=20calculation=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- services/api/src/trends/trends.service.ts | 62 +++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/services/api/src/trends/trends.service.ts b/services/api/src/trends/trends.service.ts index fcdcff4..274920b 100644 --- a/services/api/src/trends/trends.service.ts +++ b/services/api/src/trends/trends.service.ts @@ -22,6 +22,8 @@ export interface TrendsResult { }; } +const VALID_GRANULARITIES = new Set(Object.values(TimeGranularity)); + @Injectable() export class TrendsService { constructor( @@ -32,12 +34,66 @@ export class TrendsService { async getTrends(query: TrendsQueryDto): Promise { const { metric, startDate, endDate, granularity = 'day' } = query; + // Whitelist granularity before interpolating into SQL + const safeBucket = VALID_GRANULARITIES.has(granularity) ? granularity : 'day'; + const bucketExpr = `date_trunc('${safeBucket}', timestamp AT TIME ZONE 'UTC')`; + + let rawSql: string | null = null; + + if (metric === MetricType.PAGE_VIEWS) { + rawSql = ` + SELECT ${bucketExpr} AS timestamp, COUNT(*)::bigint AS value + FROM raw_events + WHERE "eventType" IN ('pageview', 'pageView') + AND timestamp BETWEEN $1 AND $2 + GROUP BY ${bucketExpr} + ORDER BY timestamp ASC + `; + } else if (metric === MetricType.SESSIONS) { + rawSql = ` + SELECT ${bucketExpr} AS timestamp, COUNT(DISTINCT "sessionId")::bigint AS value + FROM raw_events + WHERE timestamp BETWEEN $1 AND $2 + GROUP BY ${bucketExpr} + ORDER BY timestamp ASC + `; + } else if (metric === MetricType.UNIQUE_VISITORS) { + rawSql = ` + SELECT ${bucketExpr} AS timestamp, + COUNT(DISTINCT COALESCE("userId", "sessionId"))::bigint AS value + FROM raw_events + WHERE timestamp BETWEEN $1 AND $2 + GROUP BY ${bucketExpr} + ORDER BY timestamp ASC + `; + } + + if (rawSql) { + const rows = await this.metricsRepository.query(rawSql, [startDate, endDate]) as Array<{ + timestamp: Date; + value: string; + }>; + const values = rows.map((r) => Number(r.value)); + const total = values.reduce((sum, v) => sum + v, 0); + return { + metric, + granularity: safeBucket, + data: rows.map((r) => ({ timestamp: r.timestamp, value: Number(r.value), count: Number(r.value) })), + summary: { + total, + average: values.length > 0 ? total / values.length : 0, + min: values.length > 0 ? Math.min(...values) : 0, + max: values.length > 0 ? Math.max(...values) : 0, + }, + }; + } + + // Fallback: pre-aggregated table for other metrics (bounce_rate, avg_session_duration, etc.) const data = await this.metricsRepository.find({ where: { metricType: metric as MetricType, - granularity: granularity as TimeGranularity, + granularity: safeBucket as TimeGranularity, timestamp: Between(new Date(startDate), new Date(endDate)), - dimension: undefined, }, order: { timestamp: 'ASC' }, }); @@ -47,7 +103,7 @@ export class TrendsService { return { metric, - granularity, + granularity: safeBucket, data: data.map((d) => ({ timestamp: d.timestamp, value: Number(d.value),