Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/backend/cache/redis.service.ts
Original file line number Diff line number Diff line change
@@ -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<T>(key: string): Promise<T | null> {
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<void> {
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<void> {
try {
await this.client.del(key);
} catch (err) {
this.logger.warn(`Redis DEL failed for key "${key}": ${String(err)}`);
}
}
}
120 changes: 91 additions & 29 deletions app/backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalStatsDto> {
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<MapDataDto> {
const query: MapDataQuery = { region, token, status };
this.logger.log(`GET /analytics/map-data ${JSON.stringify(query)}`);
return this.analyticsService.getMapData(query);
}
}
}
16 changes: 14 additions & 2 deletions app/backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading