Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/backend/.env.example.rc
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ export KOBO_TOKEN=
export FLOOD_ASSET_ID=
export DROUGHT_ASSET_ID=
export INCIDENT_ASSET_ID=
export KOBO_PUBLIC_API_TOKEN=
export PARTNER_API_KEYS=
2 changes: 2 additions & 0 deletions apps/backend/.env.test.rc
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ export KOBO_TOKEN=TO_FILL
export FLOOD_ASSET_ID=TO_FILL
export DROUGHT_ASSET_ID=TO_FILL
export INCIDENT_ASSET_ID=TO_FILL
export KOBO_PUBLIC_API_TOKEN=TO_FILL
export PARTNER_API_KEYS=testpartner:TO_FILL
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { QueryFailedFilter } from './exception/query-failed.filter';
import { KoboModule } from './modules/kobo/kobo.module';
import { LoggerMiddleware } from './modules/logger/logger.middleware';
import { LoggerModule } from './modules/logger/logger.module';
import { PartnersModule } from './modules/partners/partners.module';
import { User } from './modules/user/user.entity';
import { UserModule } from './modules/user/user.module';
import { WebhookModule } from './modules/webhook/webhook.module';
Expand Down Expand Up @@ -140,6 +141,7 @@ const ADMINJS_ADMIN = {
AuthModule,
LoggerModule,
KoboModule,
PartnersModule,
WebhookModule,
],
controllers: [AppController],
Expand Down
13 changes: 12 additions & 1 deletion apps/backend/src/env.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, IsString, validateSync } from 'class-validator';
import { IsEnum, IsNumber, IsOptional, IsString, validateSync } from 'class-validator';

export enum Environment {
Development = 'development',
Expand Down Expand Up @@ -80,6 +80,17 @@ class EnvironmentVariables {

@IsString()
TELEGRAM_NCDM_CHAT_ID!: string;

// Separate read-only Kobo credential for the partners API (see docs/adr/003-partner-api-approach.md).
// Optional until the partners module is deployed/enabled for a given environment.
@IsOptional()
@IsString()
KOBO_PUBLIC_API_TOKEN?: string;

// Comma-separated "partnerName:apiKey" pairs, e.g. "idpoor:abc123,someOtherPartner:def456".
@IsOptional()
@IsString()
PARTNER_API_KEYS?: string;
}

export const validate = (config: Record<string, unknown>): EnvironmentVariables => {
Expand Down
28 changes: 2 additions & 26 deletions apps/backend/src/modules/kobo/kobo.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import * as qs from 'qs';

import { KoboController } from './kobo.controller';
import { KoboService } from './kobo.service';
import { koboParamsSerializer } from './koboParamsSerializer';

const koboToken = process.env.KOBO_TOKEN;
if (koboToken === undefined) {
Expand All @@ -16,31 +16,7 @@ if (koboToken === undefined) {
baseURL: 'https://eu.kobotoolbox.org/api/v2/',
// Avoid HTTP(S)_PROXY (e.g. 127.0.0.1:7890) when proxy app is off — common local dev failure
proxy: false,
paramsSerializer: (params) => {
// Kobo API expects the 'query' parameter as a JSON-encoded string
// Handle 'query' specially: if it's an object, JSON stringify it; if it's already a string, use it as-is
const { query, ...otherParams } = params as {
query?: string | Record<string, unknown>;
[key: string]: unknown;
};

let queryString = '';
if (query !== undefined) {
// If query is an object, JSON stringify it; if it's already a string, use it directly
const queryValue = typeof query === 'string' ? query : JSON.stringify(query);
queryString = `query=${encodeURIComponent(queryValue)}`;
}

// Serialize other params normally
const otherParamsString = qs.stringify(otherParams, { arrayFormat: 'brackets' });

// Combine both parts
if (queryString !== '' && otherParamsString !== '') {
return `${queryString}&${otherParamsString}`;
}

return queryString !== '' ? queryString : otherParamsString;
},
paramsSerializer: koboParamsSerializer,
}),
],
controllers: [KoboController],
Expand Down
25 changes: 25 additions & 0 deletions apps/backend/src/modules/kobo/koboParamsSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as qs from 'qs';

// Kobo API expects the 'query' parameter as a JSON-encoded string. Shared between the internal
// KoboModule (KOBO_TOKEN) and the partners module (KOBO_PUBLIC_API_TOKEN, see
// docs/adr/003-partner-api-approach.md) since both talk to the same Kobo REST API shape.
export const koboParamsSerializer = (params: Record<string, unknown>): string => {
const { query, ...otherParams } = params as {
query?: string | Record<string, unknown>;
[key: string]: unknown;
};

let queryString = '';
if (query !== undefined) {
const queryValue = typeof query === 'string' ? query : JSON.stringify(query);
queryString = `query=${encodeURIComponent(queryValue)}`;
}

const otherParamsString = qs.stringify(otherParams, { arrayFormat: 'brackets' });

if (queryString !== '' && otherParamsString !== '') {
return `${queryString}&${otherParamsString}`;
}

return queryString !== '' ? queryString : otherParamsString;
};
46 changes: 46 additions & 0 deletions apps/backend/src/modules/partners/partnerApiKey.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';

// Comma-separated "partnerName:apiKey" pairs, e.g. "idpoor:abc123,someOtherPartner:def456".
// See docs/adr/003-partner-api-approach.md: a shared/per-partner API key plus request logging
// is the agreed baseline for the current partner scale.
const parsePartnerApiKeys = (raw: string | undefined): Map<string, string> => {
const entries = (raw ?? '')
.split(',')
.map((pair) => pair.trim())
.filter((pair) => pair !== '')
.map((pair) => pair.split(':').map((part) => part.trim()) as [string, string]);

return new Map(entries.map(([partnerName, apiKey]) => [apiKey, partnerName]));
};

export interface PartnerRequest {
headers: { 'x-api-key'?: string };
partnerName?: string;
}

@Injectable()
export class PartnerApiKeyGuard implements CanActivate {
private readonly apiKeysByKey = parsePartnerApiKeys(process.env.PARTNER_API_KEYS);

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<PartnerRequest>();
const apiKey = request.headers['x-api-key'];

const partnerName = apiKey === undefined ? undefined : this.apiKeysByKey.get(apiKey);

if (partnerName === undefined) {
throw new HttpException('Invalid or missing API key', HttpStatus.UNAUTHORIZED);
}

// Attach the resolved partner identity for downstream request logging.
request.partnerName = partnerName;

return true;
}
}
29 changes: 29 additions & 0 deletions apps/backend/src/modules/partners/partners.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Get } from '@decorators/httpDecorators';
import { Controller, Logger, Query, Req, UseGuards } from '@nestjs/common';
import { FloodAffectedVillageDto, GetFloodAffectedVillagesDto } from '@wfp-dmp/interfaces';

import { PartnerApiKeyGuard, PartnerRequest } from './partnerApiKey.guard';
import { PartnersService } from './partners.service';

@Controller('partners')
export class PartnersController {
private readonly logger = new Logger(PartnersController.name);

constructor(private readonly partnersService: PartnersService) {}

@Get('flood-affected-villages', { isPublic: true })
@UseGuards(PartnerApiKeyGuard)
async getFloodAffectedVillages(
@Req() request: PartnerRequest,
@Query() filters: GetFloodAffectedVillagesDto,
): Promise<FloodAffectedVillageDto[]> {
// Minimal audit trail per docs/adr/003-partner-api-approach.md: who queried what, when.
this.logger.log(
`partner=${request.partnerName ?? 'unknown'} startDate=${filters.startDate} endDate=${
filters.endDate
}`,
);

return this.partnersService.getFloodAffectedVillages(filters.startDate, filters.endDate);
}
}
29 changes: 29 additions & 0 deletions apps/backend/src/modules/partners/partners.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';

import { koboParamsSerializer } from '../kobo/koboParamsSerializer';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';

// Uses its own Kobo credential (KOBO_PUBLIC_API_TOKEN), deliberately separate from the
// validation-workflow token used by KoboModule (KOBO_TOKEN) — see
// docs/adr/003-partner-api-approach.md. Revoking/rotating this token must never touch the
// validation write path.
//
// Left optional (unlike KOBO_TOKEN) so environments that haven't provisioned the partners
// feature yet don't fail to boot; calls simply fail against Kobo (401) until it's set.
const koboPublicApiToken = process.env.KOBO_PUBLIC_API_TOKEN;

@Module({
imports: [
HttpModule.register({
headers: { authorization: `Token ${koboPublicApiToken ?? ''}` },
baseURL: 'https://eu.kobotoolbox.org/api/v2/',
proxy: false,
paramsSerializer: koboParamsSerializer,
}),
],
controllers: [PartnersController],
providers: [PartnersService],
})
export class PartnersModule {}
81 changes: 81 additions & 0 deletions apps/backend/src/modules/partners/partners.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import {
FLOOD,
FloodAffectedVillageDto,
FloodQueryResponseDto,
koboKeys,
parseAffectedVillageCodes,
villageToCommune,
} from '@wfp-dmp/interfaces';

import { AssetId } from '../kobo/constants';

const KOBO_PAGE_LIMIT = 1000;

@Injectable()
export class PartnersService {
constructor(private readonly httpService: HttpService) {}

private async getAllApprovedFloodPages(
startDate: string,
endDate: string,
): Promise<FloodQueryResponseDto> {
// Approved-only filter is enforced here, not by Kobo permissions — see
// docs/adr/003-partner-api-approach.md. The service account behind this HttpService
// (KOBO_PUBLIC_API_TOKEN) can technically see all submissions; every call from this
// service must include this filter.
//
// PARTNER_SKIP_APPROVAL_FILTER is a local-dev-only escape hatch (e.g. to test against
// Kobo data before anything has been approved yet). It must never be set outside local
// development — there is no guard here against enabling it in a deployed environment,
// so treat it as manual/trusted, not something to wire into any config UI.
const skipApprovalFilter = process.env.PARTNER_SKIP_APPROVAL_FILTER === 'true';

const query = {
...(skipApprovalFilter ? {} : { '_validation_status.uid': 'validation_status_approved' }),
[koboKeys[FLOOD].disasterDate]: { $gte: startDate, $lte: endDate },
};

const { data: firstPage } = await this.httpService.axiosRef.get<FloodQueryResponseDto>(
`assets/${AssetId[FLOOD]}/data.json`,
{ params: { query, limit: KOBO_PAGE_LIMIT } },
);

const results = [...firstPage.results];
let nextUrl = firstPage.next;

while (nextUrl !== null) {
const { data: nextPage } = await this.httpService.axiosRef.get<FloodQueryResponseDto>(
nextUrl,
);

results.push(...nextPage.results);
nextUrl = nextPage.next;
}

return { ...firstPage, next: null, results };
}

async getFloodAffectedVillages(
startDate: string,
endDate: string,
): Promise<FloodAffectedVillageDto[]> {
const { results } = await this.getAllApprovedFloodPages(startDate, endDate);

return results.flatMap((submission) => {
const villageCodes = parseAffectedVillageCodes(submission['g2/village']);

return villageCodes.map(
(villageCode): FloodAffectedVillageDto => ({
gazetteerCode: villageCode,
gazetteerLevel: 'village',
communeCode: villageToCommune[villageCode],
floodAffected: true,
disasterDate: submission['g2/Date_Dis'],
submissionId: submission._id,
}),
);
});
}
}
Loading
Loading