diff --git a/app/backend/cache/redis.service.ts b/app/backend/cache/redis.service.ts new file mode 100644 index 0000000..7703f64 --- /dev/null +++ b/app/backend/cache/redis.service.ts @@ -0,0 +1,64 @@ + + +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + private client: Redis; + + onModuleInit() { + this.client = new Redis({ + host: process.env.REDIS_HOST ?? 'localhost', + port: parseInt(process.env.REDIS_PORT ?? '6379', 10), + maxRetriesPerRequest: 3, + retryStrategy: (times) => (times <= 3 ? 200 : null), + }); + + this.client.on('connect', () => this.logger.log('Redis connected')); + this.client.on('error', (err) => this.logger.error('Redis error', err)); + } + + onModuleDestroy() { + this.client?.disconnect(); + } + + /** + * Retrieve and deserialise a cached value. + * Returns `null` on cache miss or if Redis is unavailable. + */ + async get(key: string): Promise { + try { + const raw = await this.client.get(key); + return raw ? (JSON.parse(raw) as T) : null; + } catch (err) { + this.logger.warn(`Redis GET failed for key "${key}": ${String(err)}`); + return null; + } + } + + /** + * Serialize and store a value with a TTL. + * + * @param key - Redis key + * @param value - Any JSON-serialisable value + * @param ttlSeconds - Expiry in seconds (e.g. 300 = 5 minutes) + */ + async set(key: string, value: unknown, ttlSeconds: number): Promise { + try { + await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds); + } catch (err) { + this.logger.warn(`Redis SET failed for key "${key}": ${String(err)}`); + } + } + + + async del(key: string): Promise { + try { + await this.client.del(key); + } catch (err) { + this.logger.warn(`Redis DEL failed for key "${key}": ${String(err)}`); + } + } +} \ No newline at end of file diff --git a/app/backend/src/analytics/analytics.controller.ts b/app/backend/src/analytics/analytics.controller.ts index d73bdd5..d87d8ce 100644 --- a/app/backend/src/analytics/analytics.controller.ts +++ b/app/backend/src/analytics/analytics.controller.ts @@ -1,36 +1,98 @@ -import { Controller, Get, Version } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { API_VERSIONS } from '../common/constants/api-version.constants'; -import { Public } from '../common/decorators/public.decorator'; -import { AnalyticsService, MapDataPoint } from './analytics.service'; +// import { Controller, Get, Version } from '@nestjs/common'; +// import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +// import { API_VERSIONS } from '../common/constants/api-version.constants'; +// import { Public } from '../common/decorators/public.decorator'; +// import { AnalyticsService, MapDataPoint } from './analytics.service'; + +// @ApiTags('Analytics') +// @Controller('analytics') +// export class AnalyticsController { +// constructor(private readonly analyticsService: AnalyticsService) {} + +// @Public() +// @Get('map-data') +// @Version(API_VERSIONS.V1) +// @ApiOperation({ +// summary: 'Get anonymized distribution data for the global dashboard map', +// }) +// @ApiOkResponse({ +// description: 'List of anonymized aid package distribution points.', +// schema: { +// example: [ +// { +// id: 'pkg-001', +// lat: 6.5244, +// lng: 3.3792, +// amount: 250, +// token: 'USDC', +// status: 'delivered', +// }, +// ], +// }, +// }) +// getMapData(): MapDataPoint[] { +// return this.analyticsService.getMapData(); +// } +// } + +/** + * @file analytics.controller.ts + * + * Exposes the two analytics endpoints consumed by the global dashboard: + * + * GET /analytics/global-stats — aggregated totals + breakdowns + * GET /analytics/map-data — anonymised Leaflet map points + * + * Both endpoints are read-only and accept optional query parameters for + * filtering by region, token type, and timeframe. + */ + +import { + Controller, + Get, + Query, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { + GlobalStatsDto, + GlobalStatsQuery, + MapDataDto, + MapDataQuery, +} from './dto'; -@ApiTags('Analytics') @Controller('analytics') export class AnalyticsController { + private readonly logger = new Logger(AnalyticsController.name); + constructor(private readonly analyticsService: AnalyticsService) {} - @Public() + + @Get('global-stats') + @HttpCode(HttpStatus.OK) + async getGlobalStats( + @Query('from') from?: string, + @Query('to') to?: string, + @Query('region') region?: string, + @Query('token') token?: string, + ): Promise { + const query: GlobalStatsQuery = { from, to, region, token }; + this.logger.log(`GET /analytics/global-stats ${JSON.stringify(query)}`); + return this.analyticsService.getGlobalStats(query); + } + + @Get('map-data') - @Version(API_VERSIONS.V1) - @ApiOperation({ - summary: 'Get anonymized distribution data for the global dashboard map', - }) - @ApiOkResponse({ - description: 'List of anonymized aid package distribution points.', - schema: { - example: [ - { - id: 'pkg-001', - lat: 6.5244, - lng: 3.3792, - amount: 250, - token: 'USDC', - status: 'delivered', - }, - ], - }, - }) - getMapData(): MapDataPoint[] { - return this.analyticsService.getMapData(); + @HttpCode(HttpStatus.OK) + async getMapData( + @Query('region') region?: string, + @Query('token') token?: string, + @Query('status') status?: string, + ): Promise { + const query: MapDataQuery = { region, token, status }; + this.logger.log(`GET /analytics/map-data ${JSON.stringify(query)}`); + return this.analyticsService.getMapData(query); } -} +} \ No newline at end of file diff --git a/app/backend/src/analytics/analytics.module.ts b/app/backend/src/analytics/analytics.module.ts index 481085d..8416d80 100644 --- a/app/backend/src/analytics/analytics.module.ts +++ b/app/backend/src/analytics/analytics.module.ts @@ -1,9 +1,21 @@ import { Module } from '@nestjs/common'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { RedisService } from '../../cache/redis.service'; @Module({ + imports: [PrismaModule], controllers: [AnalyticsController], - providers: [AnalyticsService], + providers: [ + AnalyticsService, + /** + * RedisService manages its own ioredis client and is provided here as a + * plain class — no CacheModule or external adapter needed. + * Connection is opened in onModuleInit and closed in onModuleDestroy. + */ + RedisService, + ], + exports: [AnalyticsService], }) -export class AnalyticsModule {} +export class AnalyticsModule {} \ No newline at end of file diff --git a/app/backend/src/analytics/analytics.service.ts b/app/backend/src/analytics/analytics.service.ts index e658205..a7e950f 100644 --- a/app/backend/src/analytics/analytics.service.ts +++ b/app/backend/src/analytics/analytics.service.ts @@ -1,58 +1,384 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { ClaimStatus } from '@prisma/client'; +import { + GlobalStatsDto, + GlobalStatsQuery, + MapDataDto, + MapDataPoint, + MapDataQuery, + BreakdownEntry, + TimeframeBucket, +} from './dto'; +import { RedisService } from '../../cache/redis.service'; -export type MapDataPoint = { - id: string; - lat: number; - lng: number; - amount: number; - token: string; - status: string; -}; +// export type MapDataPoint = { +// id: string; +// lat: number; +// lng: number; +// amount: number; +// token: string; +// status: string; +// }; + +// @Injectable() +// export class AnalyticsService { +// getMapData(): MapDataPoint[] { +// return [ +// { +// id: 'pkg-001', +// lat: 6.5244, +// lng: 3.3792, +// amount: 250, +// token: 'USDC', +// status: 'delivered', +// }, +// { +// id: 'pkg-002', +// lat: 9.0765, +// lng: 7.3986, +// amount: 120, +// token: 'USDC', +// status: 'pending', +// }, +// { +// id: 'pkg-003', +// lat: -1.286389, +// lng: 36.817223, +// amount: 560, +// token: 'XLM', +// status: 'in_transit', +// }, +// { +// id: 'pkg-004', +// lat: 14.716677, +// lng: -17.467686, +// amount: 90, +// token: 'USDC', +// status: 'delivered', +// }, +// { +// id: 'pkg-005', +// lat: -26.204103, +// lng: 28.047305, +// amount: 310, +// token: 'XLM', +// status: 'delivered', +// }, +// ]; +// } + +// } + + + +const CACHE_TTL_SECONDS = 300; // 5 minutes + +const DEFAULT_LOOKBACK_DAYS = 30; + +/** Fallback values when campaign metadata fields are absent. */ +const FALLBACK_REGION = 'Unknown'; +const FALLBACK_TOKEN = 'UNKNOWN'; +const FALLBACK_LAT = 0; +const FALLBACK_LNG = 0; + + +interface CampaignMetadata { + region?: string; + token?: string; + lat?: number; + lng?: number; +} @Injectable() export class AnalyticsService { - getMapData(): MapDataPoint[] { - return [ - { - id: 'pkg-001', - lat: 6.5244, - lng: 3.3792, - amount: 250, - token: 'USDC', - status: 'delivered', + private readonly logger = new Logger(AnalyticsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) {} + + + /** + * Return aggregated totals for the global dashboard. + * + * Results are cached in Redis for `CACHE_TTL_SECONDS`. The cache key + * includes every query parameter so different filter combinations are + * cached independently. + * + * @example + * GET /analytics/global-stats?from=2024-01-01&to=2024-03-31&token=USDC + */ + async getGlobalStats(query: GlobalStatsQuery = {}): Promise { + const cacheKey = this.buildCacheKey('global-stats', query as Record); + + const cached = await this.redis.get(cacheKey); + if (cached) { + this.logger.debug(`Cache hit: ${cacheKey}`); + return cached; + } + + this.logger.debug(`Cache miss: ${cacheKey} — querying database`); + const result = await this.computeGlobalStats(query); + + await this.redis.set(cacheKey, result, CACHE_TTL_SECONDS); + return result; + } + + /** + * Return anonymised geo-coordinates of disbursements for the Leaflet map. + * + * Only claims with status `disbursed` are included. Coordinates are + * derived from the parent campaign's metadata centroid and truncated to + * 2 decimal places before being returned. + * + * @example + * GET /analytics/map-data?region=West+Africa&token=USDC + */ + async getMapData(query: MapDataQuery = {}): Promise { + const cacheKey = this.buildCacheKey('map-data', query as Record); + + const cached = await this.redis.get(cacheKey); + if (cached) { + this.logger.debug(`Cache hit: ${cacheKey}`); + return cached; + } + + this.logger.debug(`Cache miss: ${cacheKey} — querying database`); + const result = await this.computeMapData(query); + + await this.redis.set(cacheKey, result, CACHE_TTL_SECONDS); + return result; + } + + + private async computeGlobalStats( + query: GlobalStatsQuery, + ): Promise { + const { from, to, region, token } = query; + const { startDate, endDate } = this.resolveDateRange(from, to); + + // Fetch all disbursed claims within the time window, including their + // parent campaign so we can read metadata. + const claims = await this.prisma.claim.findMany({ + where: { + status: ClaimStatus.disbursed, + createdAt: { gte: startDate, lte: endDate }, + campaign: { + ...(region || token + ? this.buildMetadataFilter(region, token) + : {}), + }, }, - { - id: 'pkg-002', - lat: 9.0765, - lng: 7.3986, - amount: 120, - token: 'USDC', - status: 'pending', + select: { + id: true, + amount: true, + recipientRef: true, + status: true, + createdAt: true, + campaign: { + select: { metadata: true }, + }, }, - { - id: 'pkg-003', - lat: -1.286389, - lng: 36.817223, - amount: 560, - token: 'XLM', - status: 'in_transit', + }); + + // Count active campaigns (optionally filtered by region / token). + const activeCampaigns = await this.prisma.campaign.count({ + where: { + status: 'active', + ...(region || token ? this.buildMetadataFilter(region, token) : {}), }, - { - id: 'pkg-004', - lat: 14.716677, - lng: -17.467686, - amount: 90, - token: 'USDC', - status: 'delivered', + }); + + // Aggregate in JS (avoids complex Prisma JSON path queries) + + let totalAidDisbursed = 0; + const uniqueRecipients = new Set(); + const tokenMap = new Map(); + const regionMap = new Map(); + // date string (YYYY-MM-DD) → { amount, count } + const dateMap = new Map(); + + for (const claim of claims) { + const meta = (claim.campaign.metadata ?? {}) as CampaignMetadata; + const claimToken = meta.token ?? FALLBACK_TOKEN; + const claimRegion = meta.region ?? FALLBACK_REGION; + const claimAmount = Number(claim.amount); + const dateKey = claim.createdAt.toISOString().slice(0, 10); + + totalAidDisbursed += claimAmount; + uniqueRecipients.add(claim.recipientRef); + + // Token breakdown + const tok = tokenMap.get(claimToken) ?? { amount: 0, count: 0 }; + tok.amount += claimAmount; + tok.count += 1; + tokenMap.set(claimToken, tok); + + // Region breakdown + const reg = regionMap.get(claimRegion) ?? { amount: 0, count: 0 }; + reg.amount += claimAmount; + reg.count += 1; + regionMap.set(claimRegion, reg); + + // Daily time series + const day = dateMap.get(dateKey) ?? { amount: 0, count: 0 }; + day.amount += claimAmount; + day.count += 1; + dateMap.set(dateKey, day); + } + + const byToken: BreakdownEntry[] = Array.from(tokenMap.entries()).map( + ([label, { amount, count }]) => ({ + label, + totalAmount: Math.round(amount * 100) / 100, + count, + }), + ); + + const byRegion: BreakdownEntry[] = Array.from(regionMap.entries()).map( + ([label, { amount, count }]) => ({ + label, + totalAmount: Math.round(amount * 100) / 100, + count, + }), + ); + + const timeSeries: TimeframeBucket[] = Array.from(dateMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, { amount, count }]) => ({ + date, + totalAmount: Math.round(amount * 100) / 100, + count, + })); + + return { + totalAidDisbursed: Math.round(totalAidDisbursed * 100) / 100, + totalRecipients: uniqueRecipients.size, + activeCampaigns, + byToken, + byRegion, + timeSeries, + computedAt: new Date().toISOString(), + }; + } + + // Private — map data computation + + private async computeMapData(query: MapDataQuery): Promise { + const { region, token, status } = query; + + // Resolve the Prisma ClaimStatus filter. + const claimStatus = + status && Object.values(ClaimStatus).includes(status as ClaimStatus) + ? (status as ClaimStatus) + : ClaimStatus.disbursed; + + const claims = await this.prisma.claim.findMany({ + where: { + status: claimStatus, + campaign: { + ...(region || token + ? this.buildMetadataFilter(region, token) + : {}), + }, }, - { - id: 'pkg-005', - lat: -26.204103, - lng: 28.047305, - amount: 310, - token: 'XLM', - status: 'delivered', + select: { + id: true, + amount: true, + status: true, + campaign: { + select: { metadata: true }, + }, }, - ]; + }); + + const points: MapDataPoint[] = claims.map((claim) => { + const meta = (claim.campaign.metadata ?? {}) as CampaignMetadata; + + return { + // Anonymise: 12-char hex prefix of SHA-256(claimId) + id: this.anonymiseId(claim.id), + // Truncate to 2 d.p. (~1 km resolution) + lat: this.truncate2dp(meta.lat ?? FALLBACK_LAT), + lng: this.truncate2dp(meta.lng ?? FALLBACK_LNG), + amount: Number(claim.amount), + token: meta.token ?? FALLBACK_TOKEN, + status: claim.status, + region: meta.region ?? FALLBACK_REGION, + }; + }); + + return { points, computedAt: new Date().toISOString() }; } -} + + + private buildMetadataFilter( + region?: string, + token?: string, + ): Record { + const conditions: Record[] = []; + + if (region) { + conditions.push({ + metadata: { + path: ['region'], + equals: region, + }, + }); + } + + if (token) { + conditions.push({ + metadata: { + path: ['token'], + equals: token, + }, + }); + } + + return conditions.length === 1 ? conditions[0] : { AND: conditions }; + } + + + private resolveDateRange( + from?: string, + to?: string, + ): { startDate: Date; endDate: Date } { + const endDate = to ? new Date(to) : new Date(); + const startDate = from + ? new Date(from) + : new Date( + endDate.getTime() - DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + + return { startDate, endDate }; + } + + /** + * Build a stable, namespaced Redis cache key from an endpoint name and + * query params. Params are sorted so key('a=1&b=2') === key('b=2&a=1'). + * + * Example: "analytics:global-stats:from=2024-01-01:token=USDC" + */ + private buildCacheKey(endpoint: string, query: Record): string { + const sorted = Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${String(v)}`) + .join(':'); + + return `analytics:${endpoint}${sorted ? ':' + sorted : ''}`; + } + + + private anonymiseId(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 12); + } + + private truncate2dp(n: number): number { + return Math.trunc(n * 100) / 100; + } +} \ No newline at end of file diff --git a/app/backend/src/analytics/dto/index.ts b/app/backend/src/analytics/dto/index.ts new file mode 100644 index 0000000..20a6e04 --- /dev/null +++ b/app/backend/src/analytics/dto/index.ts @@ -0,0 +1,51 @@ + +export interface BreakdownEntry { + label: string; + totalAmount: number; + count: number; +} + +export interface TimeframeBucket { + date: string; + totalAmount: number; + count: number; +} + +export interface GlobalStatsDto { + totalAidDisbursed: number; + totalRecipients: number; + activeCampaigns: number; + byToken: BreakdownEntry[]; + byRegion: BreakdownEntry[]; + timeSeries: TimeframeBucket[]; + computedAt: string; +} + + +export interface MapDataPoint { + id: string; + lat: number; + lng: number; + amount: number; + token: string; + status: string; + region: string; +} + +export interface MapDataDto { + points: MapDataPoint[]; + computedAt: string; +} + +export interface GlobalStatsQuery { + from?: string; + to?: string; + region?: string; + token?: string; +} + +export interface MapDataQuery { + region?: string; + token?: string; + status?: string; +} \ No newline at end of file