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
7 changes: 1 addition & 6 deletions app/backend/src/aid/aid.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
Controller,
Body,
Param,
Post,
} from '@nestjs/common';
import { Controller, Body, Param, Post } from '@nestjs/common';
import { AidService } from './aid.service';
import { AiTaskWebhookDto } from './dto/ai-task-webhook.dto';
import {
Expand Down
19 changes: 14 additions & 5 deletions app/backend/src/aid/aid.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ export class AidService {

async handleTaskWebhook(payload: AiTaskWebhookDto) {
// Log the task notification
console.log(`[AI Webhook] Task ${payload.taskId} completed with status: ${payload.status}`);

console.log(
`[AI Webhook] Task ${payload.taskId} completed with status: ${payload.status}`,
);

// Record audit log for the task completion
await this.auditService.record({
actorId: 'ai-service',
Expand All @@ -72,20 +74,27 @@ export class AidService {
switch (payload.status) {
case TaskStatus.COMPLETED:
// Task completed successfully - trigger any follow-up actions
console.log(`[AI Webhook] Task ${payload.taskId} completed successfully`);
console.log(
`[AI Webhook] Task ${payload.taskId} completed successfully`,
);
if (payload.result) {
console.log(`[AI Webhook] Result:`, payload.result);
}
break;
case TaskStatus.FAILED:
// Task failed - log error and potentially trigger alerts
console.error(`[AI Webhook] Task ${payload.taskId} failed:`, payload.error);
console.error(
`[AI Webhook] Task ${payload.taskId} failed:`,
payload.error,
);
break;
case TaskStatus.PROCESSING:
console.log(`[AI Webhook] Task ${payload.taskId} is still processing`);
break;
default:
console.log(`[AI Webhook] Task ${payload.taskId} status: ${payload.status}`);
console.log(
`[AI Webhook] Task ${payload.taskId} status: ${payload.status}`,
);
}

return {
Expand Down
2 changes: 1 addition & 1 deletion app/backend/src/aid/dto/ai-task-webhook.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ export class AiTaskWebhookDto {
@IsOptional()
@IsString()
completedAt?: string;
}
}
17 changes: 14 additions & 3 deletions app/backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
GlobalStatsQuery,
MapDataDto,
MapDataQuery,
GeoJsonFeatureCollection,
} from './dto';

@Controller('analytics')
Expand All @@ -69,7 +70,6 @@ export class AnalyticsController {

constructor(private readonly analyticsService: AnalyticsService) {}


@Get('global-stats')
@HttpCode(HttpStatus.OK)
async getGlobalStats(
Expand All @@ -83,7 +83,6 @@ export class AnalyticsController {
return this.analyticsService.getGlobalStats(query);
}


@Get('map-data')
@HttpCode(HttpStatus.OK)
async getMapData(
Expand All @@ -95,4 +94,16 @@ export class AnalyticsController {
this.logger.log(`GET /analytics/map-data ${JSON.stringify(query)}`);
return this.analyticsService.getMapData(query);
}
}

@Get('map-anonymized')
@HttpCode(HttpStatus.OK)
async getMapAnonymizedData(
@Query('region') region?: string,
@Query('token') token?: string,
@Query('status') status?: string,
): Promise<GeoJsonFeatureCollection> {
const query: MapDataQuery = { region, token, status };
this.logger.log(`GET /analytics/map-anonymized ${JSON.stringify(query)}`);
return this.analyticsService.getMapAnonymizedData(query);
}
}
4 changes: 3 additions & 1 deletion app/backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { PrismaModule } from '../prisma/prisma.module';
import { RedisService } from '../../cache/redis.service';
import { PrivacyService } from './privacy.service';

@Module({
imports: [PrismaModule],
controllers: [AnalyticsController],
providers: [
AnalyticsService,
PrivacyService,
/**
* RedisService manages its own ioredis client and is provided here as a
* plain class — no CacheModule or external adapter needed.
Expand All @@ -18,4 +20,4 @@ import { RedisService } from '../../cache/redis.service';
],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
export class AnalyticsModule {}
68 changes: 48 additions & 20 deletions app/backend/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
MapDataQuery,
BreakdownEntry,
TimeframeBucket,
GeoJsonFeatureCollection,
} from './dto';
import { RedisService } from '../../cache/redis.service';
import { PrivacyService } from './privacy.service';

// export type MapDataPoint = {
// id: string;
Expand Down Expand Up @@ -71,8 +73,6 @@ import { RedisService } from '../../cache/redis.service';

// }



const CACHE_TTL_SECONDS = 300; // 5 minutes

const DEFAULT_LOOKBACK_DAYS = 30;
Expand All @@ -83,7 +83,6 @@ const FALLBACK_TOKEN = 'UNKNOWN';
const FALLBACK_LAT = 0;
const FALLBACK_LNG = 0;


interface CampaignMetadata {
region?: string;
token?: string;
Expand All @@ -98,9 +97,9 @@ export class AnalyticsService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly privacyService: PrivacyService,
) {}


/**
* Return aggregated totals for the global dashboard.
*
Expand All @@ -112,7 +111,10 @@ export class AnalyticsService {
* GET /analytics/global-stats?from=2024-01-01&to=2024-03-31&token=USDC
*/
async getGlobalStats(query: GlobalStatsQuery = {}): Promise<GlobalStatsDto> {
const cacheKey = this.buildCacheKey('global-stats', query as Record<string, unknown>);
const cacheKey = this.buildCacheKey(
'global-stats',
query as Record<string, unknown>,
);

const cached = await this.redis.get<GlobalStatsDto>(cacheKey);
if (cached) {
Expand All @@ -138,7 +140,10 @@ export class AnalyticsService {
* GET /analytics/map-data?region=West+Africa&token=USDC
*/
async getMapData(query: MapDataQuery = {}): Promise<MapDataDto> {
const cacheKey = this.buildCacheKey('map-data', query as Record<string, unknown>);
const cacheKey = this.buildCacheKey(
'map-data',
query as Record<string, unknown>,
);

const cached = await this.redis.get<MapDataDto>(cacheKey);
if (cached) {
Expand All @@ -153,6 +158,33 @@ export class AnalyticsService {
return result;
}

/**
* Return anonymized geo-coordinates formatted as GeoJSON.
*/
async getMapAnonymizedData(
query: MapDataQuery = {},
): Promise<GeoJsonFeatureCollection> {
const rawData = await this.getMapData(query);

const features = rawData.points.map(p => {
const { lat, lng } = this.privacyService.fuzzCoordinates(p.lat, p.lng);
const { lat: _lat, lng: _lng, ...properties } = p;
return {
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [lng, lat] as [number, number],
},
properties,
};
});

return {
type: 'FeatureCollection',
features,
computedAt: rawData.computedAt,
};
}

private async computeGlobalStats(
query: GlobalStatsQuery,
Expand All @@ -167,9 +199,7 @@ export class AnalyticsService {
status: ClaimStatus.disbursed,
createdAt: { gte: startDate, lte: endDate },
campaign: {
...(region || token
? this.buildMetadataFilter(region, token)
: {}),
...(region || token ? this.buildMetadataFilter(region, token) : {}),
},
},
select: {
Expand All @@ -192,7 +222,7 @@ export class AnalyticsService {
},
});

// Aggregate in JS (avoids complex Prisma JSON path queries)
// Aggregate in JS (avoids complex Prisma JSON path queries)

let totalAidDisbursed = 0;
const uniqueRecipients = new Set<string>();
Expand Down Expand Up @@ -265,7 +295,7 @@ export class AnalyticsService {
};
}

// Private — map data computation
// Private — map data computation

private async computeMapData(query: MapDataQuery): Promise<MapDataDto> {
const { region, token, status } = query;
Expand All @@ -280,9 +310,7 @@ export class AnalyticsService {
where: {
status: claimStatus,
campaign: {
...(region || token
? this.buildMetadataFilter(region, token)
: {}),
...(region || token ? this.buildMetadataFilter(region, token) : {}),
},
},
select: {
Expand All @@ -295,7 +323,7 @@ export class AnalyticsService {
},
});

const points: MapDataPoint[] = claims.map((claim) => {
const points: MapDataPoint[] = claims.map(claim => {
const meta = (claim.campaign.metadata ?? {}) as CampaignMetadata;

return {
Expand All @@ -314,7 +342,6 @@ export class AnalyticsService {
return { points, computedAt: new Date().toISOString() };
}


private buildMetadataFilter(
region?: string,
token?: string,
Expand Down Expand Up @@ -342,7 +369,6 @@ export class AnalyticsService {
return conditions.length === 1 ? conditions[0] : { AND: conditions };
}


private resolveDateRange(
from?: string,
to?: string,
Expand All @@ -363,7 +389,10 @@ export class AnalyticsService {
*
* Example: "analytics:global-stats:from=2024-01-01:token=USDC"
*/
private buildCacheKey(endpoint: string, query: Record<string, unknown>): string {
private buildCacheKey(
endpoint: string,
query: Record<string, unknown>,
): string {
const sorted = Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.sort(([a], [b]) => a.localeCompare(b))
Expand All @@ -373,12 +402,11 @@ export class AnalyticsService {
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;
}
}
}
19 changes: 16 additions & 3 deletions app/backend/src/analytics/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

export interface BreakdownEntry {
label: string;
totalAmount: number;
Expand All @@ -21,7 +20,6 @@ export interface GlobalStatsDto {
computedAt: string;
}


export interface MapDataPoint {
id: string;
lat: number;
Expand All @@ -37,6 +35,21 @@ export interface MapDataDto {
computedAt: string;
}

export interface GeoJsonFeature {
type: 'Feature';
geometry: {
type: 'Point';
coordinates: [number, number]; // [lng, lat]
};
properties: Omit<MapDataPoint, 'lat' | 'lng'>;
}

export interface GeoJsonFeatureCollection {
type: 'FeatureCollection';
features: GeoJsonFeature[];
computedAt: string;
}

export interface GlobalStatsQuery {
from?: string;
to?: string;
Expand All @@ -48,4 +61,4 @@ export interface MapDataQuery {
region?: string;
token?: string;
status?: string;
}
}
Loading
Loading