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
2 changes: 2 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppModule } from './app.module';
import { Environment } from './env.validation';
import { CustomLogger } from './modules/logger/custom-logger.service';
import { getAllowedOriginMatchers } from './utils/allowedOrigins';
import { configureQueryParser } from './utils/queryParser';

const bootstrap = async () => {
const logger = new CustomLogger();
Expand All @@ -15,6 +16,7 @@ const bootstrap = async () => {
app.enableCors({ credentials: true, origin: getAllowedOriginMatchers() });
}

configureQueryParser(app);
app.use(cookieParser());

// Setting up Swagger document
Expand Down
34 changes: 34 additions & 0 deletions apps/backend/src/modules/kobo/tests/kobo.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '@root/app.module';
import { configureQueryParser } from '@root/utils/queryParser';
import {
DisasterDtoType,
DROUGHT,
Expand Down Expand Up @@ -45,6 +46,7 @@ describe('KoboController', () => {
authService = testingModule.get(AuthService);
koboService = testingModule.get(KoboService);
app = testingModule.createNestApplication();
configureQueryParser(app);
await app.init();
});

Expand Down Expand Up @@ -182,6 +184,38 @@ describe('KoboController', () => {
});
});
});

it('should accept more than 20 province values in axios bracket query format', async () => {
const role = 'ncdm';
const disTyps = ['1'];
const startDate = '2026-08-01';
const endDate = '2026-09-01';
const province = Array.from({ length: 25 }, (_, index) => String(index + 1).padStart(2, '0'));

const getFormsSpy = jest.spyOn(koboService, 'getForms').mockImplementation(getFormsMock);

const user = await userFactory.createOne({ roles: [role] });
const accessToken = authService.createAccessToken(user, 10000);
const queryString = province.reduce(
(params, code) => `${params}&province[]=${code}`,
`disTyps[]=1&startDate=${startDate}&endDate=${endDate}`,
);

await request(app.getHttpServer())
.get(`/kobo/forms?${queryString}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect(() => {
expect(getFormsSpy).toHaveBeenNthCalledWith(1, {
disTyps,
startDate,
endDate,
province,
district: undefined,
commune: undefined,
});
});
});
});

describe('GET form', () => {
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/src/utils/queryParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { INestApplication } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import qs from 'qs';

// qs defaults arrayLimit to 20; admin-area filters can exceed that.
const QUERY_ARRAY_LIMIT = 2000;

export const configureQueryParser = (app: INestApplication): void => {
const expressApp = app as NestExpressApplication;

expressApp.set('query parser', (queryString: string) =>
qs.parse(queryString, { arrayLimit: QUERY_ARRAY_LIMIT }),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const AgricultureColumns: GridColDef[] = [
getColumnSetup({
field: FloodSpecific.NumFarmCroAff,
disaster: FLOOD,
width: colWidth + 8 * 4,
}),
getColumnSetup({
field: FloodSpecific.NumFarmPaddyAff,
disaster: FLOOD,
width: colWidth + 8 * 2,
}),
getColumnSetup({
Expand All @@ -33,7 +38,6 @@ const AgricultureColumns: GridColDef[] = [
}),
getColumnSetup({ field: FloodSpecific.SamNabAff, disaster: FLOOD }),
getColumnSetup({ field: FloodSpecific.SamNabDam, disaster: FLOOD }),
getColumnSetup({ field: FloodSpecific.NumFarmPaddyAff, disaster: FLOOD }),
getColumnSetup({ field: FloodSpecific.PaddyAff, disaster: FLOOD }),
getColumnSetup({ field: FloodSpecific.PaddyDam, disaster: FLOOD }),
getColumnSetup({ field: FloodSpecific.CowEva, disaster: FLOOD }),
Expand All @@ -53,6 +57,7 @@ const AgricultureColumnGroup: GridColumnGroupingModel = [
children: [
{ field: FloodSpecific.ToNamAgriAff },
{ field: FloodSpecific.NumFarmCroAff },
{ field: FloodSpecific.NumFarmPaddyAff },
],
},
{
Expand All @@ -65,7 +70,6 @@ const AgricultureColumnGroup: GridColumnGroupingModel = [
{
...getGroupSetup('seedling', FLOOD),
children: [
{ field: FloodSpecific.NumFarmPaddyAff },
{ field: FloodSpecific.SamNabAff },
{ field: FloodSpecific.SamNabDam },
],
Expand Down
12 changes: 10 additions & 2 deletions apps/frontend/services/api/kobo/useGetForms.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { DroughtDto, FloodDto, IncidentDto } from '@wfp-dmp/interfaces';
import {
DroughtDto,
FloodDto,
IncidentDto,
provinces,
} from '@wfp-dmp/interfaces';
import useSWR from 'swr';

import { SearchFormData } from 'components/Filters/SearchFilters';
Expand All @@ -13,7 +18,10 @@ export const useGetForms = ({
const inputStartDate = inputDateRange.startDate?.format('YYYY-MM-DD');
const inputEndDate = inputDateRange.endDate?.format('YYYY-MM-DD');
const inputProvince =
inputRegion.province.length === 0 ? undefined : inputRegion.province;
inputRegion.province.length === 0 ||
inputRegion.province.length === provinces.length
? undefined
: inputRegion.province;
const inputDistrict =
inputRegion.district.length === 0 ? undefined : inputRegion.district;
const inputCommune =
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,11 @@
"NumWareHDam": "Damaged",
"NumCraftAff": "Affected",
"NumCraftDam": "Damaged",
"ToNamAgriAff": "Affected",
"NumFarmCroAff": "Affected",
"ToNamAgriAff": "Farmland",
"NumFarmCroAff": "Mixed crops",
"FarmAff": "Affected",
"FarmDam": "Damaged",
"NumFarmPaddyAff": "Affected",
"NumFarmPaddyAff": "Seedlings",
"SamNabAff": "Affected",
"SamNabDam": "Damaged",
"PaddyAff": "Affected",
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/translations/km.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,11 @@
"NumWareHDam": "ខូចខាត",
"NumCraftAff": "ប៉ះពាល់",
"NumCraftDam": "ខូចខាត",
"ToNamAgriAff": "ប៉ះពាល់",
"NumFarmCroAff": "ប៉ះពាល់",
"ToNamAgriAff": "ដីស្រែ/ចម្ការ",
"NumFarmCroAff": "ដំណាំរួមផ្សំ",
"FarmAff": "ប៉ះពាល់",
"FarmDam": "ខូចខាត",
"NumFarmPaddyAff": "ប៉ះពាល់",
"NumFarmPaddyAff": "សំណាប/សន្ទូង",
"SamNabAff": "ប៉ះពាល់",
"SamNabDam": "ខូចខាត",
"PaddyAff": "ប៉ះពាល់",
Expand Down
78 changes: 59 additions & 19 deletions apps/frontend/utils/test/formatFormToRaw.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,73 @@
import { FLOOD, floodSpecificKeys, koboKeys } from '@wfp-dmp/interfaces';
import {
FLOOD,
floodSpecificKeys,
INCIDENT,
incidentSpecificKeys,
koboKeys,
} from '@wfp-dmp/interfaces';
import dayjs from 'dayjs';

import { FloodFormType } from 'components/FormValidation/FloodFormValidation/FloodFormType';
import { IncidentFormType } from 'components/FormValidation/IncidentFormValidation/IncidentFormType';
import { formatFormToRaw } from 'utils/formatFormToRaw';

describe('formatFormToRaw', () => {
const formValues = {
region: {
province: ['01'],
district: ['0102'],
commune: ['010203'],
village: 'village',
},
interviewer: 'tester',
disTyp: '1',
phone: '012',
reportDate: dayjs('2024-01-02'),
incidentDate: dayjs('2024-01-01'),
specific: {
NumFamAff: 50,
floodN: '3',
},
} as unknown as FloodFormType;

it('stringifies numeric DataGrid values and omits unmapped keys', () => {
const formValues = {
region: {
province: ['01'],
district: ['0102'],
commune: ['010203'],
village: 'village',
},
interviewer: 'tester',
disTyp: '1',
phone: '012',
reportDate: dayjs('2024-01-02'),
incidentDate: dayjs('2024-01-01'),
specific: {
NumFamAff: 50,
floodN: '3',
FarmAff: 31,
},
} as unknown as FloodFormType;

const raw = formatFormToRaw(formValues, koboKeys[FLOOD], floodSpecificKeys);

expect(raw['g3/g3_1/g3_2/NumFamAff']).toBe('50');
expect(raw['g2/flood_n']).toBe('3');
expect(raw['g11/Crop/FarmAff']).toBe('31');
expect(raw).not.toHaveProperty('g11/g11_1/FarmAff');
expect(raw).not.toHaveProperty('undefined');
});

it('writes incident factory fields to current Kobo xpaths', () => {
const incidentValues = {
region: {
province: ['05'],
district: ['0503'],
commune: ['050303'],
village: 'village',
},
interviewer: 'tester',
disTyp: '11',
phone: '012',
reportDate: dayjs('2024-01-02'),
incidentDate: dayjs('2024-01-01'),
specific: {
FactoryAff: 7,
FactoryDam: 4,
},
} as unknown as IncidentFormType;

const raw = formatFormToRaw(
incidentValues,
koboKeys[INCIDENT],
incidentSpecificKeys,
);

expect(raw['group_pk03i04/group_az7bv13/FactoryAff']).toBe('7');
expect(raw['group_pk03i04/group_az7bv13/FactoryDam']).toBe('4');
expect(raw).not.toHaveProperty('group_gh6ag70/group_az7bv13/FactoryAff');
});
});
95 changes: 95 additions & 0 deletions apps/frontend/utils/test/formatRawToForm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { FloodDto, IncidentDto } from '@wfp-dmp/interfaces';

import { formatFloodFields, formatIncidentFields } from 'utils/formatRawToForm';

const baseFlood = {
_id: 816716609,
'formhub/uuid': 'uid',
start: '2026-09-03T00:00:00.000Z',
end: '2026-09-03T00:00:00.000Z',
'g1/q_Enum': 'admin',
'g1/q_Funtion': 'flood_admin',
'g1/q_Phone': '12345678',
'g1/Date_report': '2026-09-03',
'g2/Province': '24',
'g2/District': '2401',
'g2/Commune': '240103',
'g2/Date_Dis': '2026-09-03',
'g2/DisTyp': '1',
__version__: 'version',
'meta/instanceID': 'uuid',
_xform_id_string: 'ids',
_uuid: 'uid',
_attachments: [],
_status: 'submitted_via_web',
_geolocation: [null, null],
_submission_time: '2026-09-03T08:00:00',
_tags: [],
_notes: [],
_validation_status: {},
_submitted_by: null,
} as FloodDto;

describe('formatFloodFields agriculture mapping', () => {
it('maps current Kobo agriculture xpaths into review fields', () => {
const formatted = formatFloodFields({
...baseFlood,
'g11/ToNamAgriAff': '33',
'g11/NumFarmCroAff': '32',
'g11/Crop/FarmAff': '31',
'g11/Crop/FarmDam': '30',
'g11/NumFarmPaddyAff': '29',
'g11/g11_2/SamNabAff': '28',
} as FloodDto);

expect(formatted.ToNamAgriAff).toBe('33');
expect(formatted.NumFarmCroAff).toBe('32');
expect(formatted.FarmAff).toBe('31');
expect(formatted.FarmDam).toBe('30');
expect(formatted.NumFarmPaddyAff).toBe('29');
expect(formatted.SamNabAff).toBe('28');
});
});

const baseIncident = {
_id: 3,
'formhub/uuid': 'uid',
start: '2026-09-03T00:00:00.000Z',
end: '2026-09-03T00:00:00.000Z',
'G1/q_Enum': 'admin',
'G1/q_Funtion': 'admin_incident',
'G1/q_Phone': '12345678',
'G1/Date_report': '2026-09-03',
'G2/Province': '05',
'G2/District': '0503',
'G2/Commune': '050303',
'G2/Date_Dis': '2026-09-03',
'G2/DisTyp': '11',
__version__: 'version',
'meta/instanceID': 'uuid',
_xform_id_string: 'ids',
_uuid: 'uid',
_attachments: [],
_status: 'submitted_via_web',
_geolocation: [null, null],
_submission_time: '2026-09-03T08:00:00',
_tags: [],
_notes: [],
_validation_status: {},
_submitted_by: null,
} as IncidentDto;

describe('formatIncidentFields factory mapping', () => {
it('maps current Kobo factory xpaths into review fields', () => {
const formatted = formatIncidentFields({
...baseIncident,
'group_pk03i04/group_az7bv13/FactoryAff': '7',
'group_pk03i04/group_az7bv13/FactoryDam': '4',
'group_gh6ag70/group_na4yi04/CropAff': '2',
} as IncidentDto);

expect(formatted.FactoryAff).toBe('7');
expect(formatted.FactoryDam).toBe('4');
expect(formatted.CropAff).toBe('2');
});
});
10 changes: 5 additions & 5 deletions packages/interfaces/src/kobo/FloodDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ export class FloodDto {
@IsOptional() @IsNumberString() readonly 'g10/g10_7/NumWareHDam'?: string;
@IsOptional() @IsNumberString() readonly 'g10/g10_8/NumCraftAff'?: string;
@IsOptional() @IsNumberString() readonly 'g10/g10_8/NumCraftDam'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_1/FarmAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_1/FarmDam'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_1/ToNamAgriAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_1/NumFarmCroAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_2/NumFarmPaddyAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/ToNamAgriAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/NumFarmCroAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/Crop/FarmAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/Crop/FarmDam'?: string;
@IsOptional() @IsNumberString() readonly 'g11/NumFarmPaddyAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_2/SamNabAff'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_2/SamNabDam'?: string;
@IsOptional() @IsNumberString() readonly 'g11/g11_3/PaddyAff'?: string;
Expand Down
4 changes: 2 additions & 2 deletions packages/interfaces/src/kobo/IncidentDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ export class IncidentDto {
@IsOptional() @IsNumberString() readonly 'group_pk03i04/group_lf9ou90/StorageDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_pk03i04/group_ub2hp14/CraftAff'?: string;
@IsOptional() @IsNumberString() readonly 'group_pk03i04/group_ub2hp14/CraftDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_pk03i04/group_az7bv13/FactoryAff'?: string;
@IsOptional() @IsNumberString() readonly 'group_pk03i04/group_az7bv13/FactoryDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_na4yi04/CropAff'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_na4yi04/CropDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_az7bv13/FactoryAff'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_az7bv13/FactoryDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_yh83p68/SamnabAff'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_yh83p68/SamnabDam'?: string;
@IsOptional() @IsNumberString() readonly 'group_gh6ag70/group_en38q31/PaddyAff'?: string;
Expand Down
Loading
Loading