Skip to content

Commit ac5b06c

Browse files
authored
feat(api): expose CSE files URLs in declarations export (#2905) (#3200)
1 parent badc16a commit ac5b06c

7 files changed

Lines changed: 234 additions & 7 deletions

File tree

packages/app/src/app/api/v1/export/declarations/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AUDIT_ACTIONS } from "~/modules/audit";
22
import {
33
assembleDeclaration,
4+
fetchCseFilesByDeclaration,
45
fetchCseOpinionsByDeclaration,
56
fetchIndicatorGByDeclaration,
67
fetchSubmittedDeclarations,
@@ -68,9 +69,10 @@ async function apiExportDeclarationsHandler(
6869
const sirenYearKeys = rows.map((r) => ({ siren: r.siren, year: r.year }));
6970
const declarationIds = rows.map((r) => r.declarationId);
7071

71-
const [indicatorGMap, cseMap] = await Promise.all([
72+
const [indicatorGMap, cseMap, cseFilesMap] = await Promise.all([
7273
fetchIndicatorGByDeclaration(declarationIds),
7374
fetchCseOpinionsByDeclaration(declarationIds),
75+
fetchCseFilesByDeclaration(sirenYearKeys),
7476
]);
7577

7678
const data = rows.map((row) => {
@@ -79,6 +81,7 @@ async function apiExportDeclarationsHandler(
7981
row,
8082
indicatorGMap.get(row.declarationId) ?? [],
8183
cseMap.get(row.declarationId) ?? [],
84+
cseFilesMap.get(key) ?? [],
8285
);
8386
});
8487

packages/app/src/modules/export/__tests__/exportApi.test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { describe, expect, it, vi } from "vitest";
33
const mockFetchSubmitted = vi.fn().mockResolvedValue([]);
44
const mockFetchIndicatorG = vi.fn().mockResolvedValue(new Map());
55
const mockFetchCse = vi.fn().mockResolvedValue(new Map());
6+
const mockFetchCseFiles = vi.fn().mockResolvedValue(new Map());
67

78
vi.mock("~/modules/export/queries", () => ({
89
fetchSubmittedDeclarations: (...args: unknown[]) =>
910
mockFetchSubmitted(...args),
1011
fetchIndicatorGByDeclaration: (...args: unknown[]) =>
1112
mockFetchIndicatorG(...args),
1213
fetchCseOpinionsByDeclaration: (...args: unknown[]) => mockFetchCse(...args),
13-
fetchCseFilesByDeclaration: vi.fn().mockResolvedValue(new Map()),
14+
fetchCseFilesByDeclaration: (...args: unknown[]) =>
15+
mockFetchCseFiles(...args),
1416
fetchJointEvaluationFilesByDeclaration: vi.fn().mockResolvedValue(new Map()),
1517
}));
1618

@@ -74,6 +76,7 @@ describe("GET /api/v1/export/declarations", () => {
7476
mockFetchSubmitted.mockResolvedValue([]);
7577
mockFetchIndicatorG.mockResolvedValue(new Map());
7678
mockFetchCse.mockResolvedValue(new Map());
79+
mockFetchCseFiles.mockResolvedValue(new Map());
7780
});
7881

7982
it("should return 401 when Authorization header is missing", async () => {
@@ -294,5 +297,145 @@ describe("GET /api/v1/export/declarations", () => {
294297
expect(decl.indicators.F.annual).toHaveLength(4);
295298
expect(decl.secondDeclaration.correction).toBeNull();
296299
expect(decl.cseOpinions).toEqual([]);
300+
expect(decl.cseFiles).toEqual([]);
301+
});
302+
303+
it("should expose CSE opinion declarationNumber alongside type", async () => {
304+
mockFetchSubmitted.mockResolvedValue([
305+
{
306+
declarationId: "decl-1",
307+
siren: "123456789",
308+
year: 2027,
309+
status: "submitted",
310+
compliancePath: null,
311+
totalWomen: 100,
312+
totalMen: 150,
313+
secondDeclarationStatus: null,
314+
secondDeclReferencePeriodStart: null,
315+
secondDeclReferencePeriodEnd: null,
316+
createdAt: new Date("2027-03-15T10:00:00Z"),
317+
updatedAt: new Date("2027-03-15T12:00:00Z"),
318+
companyName: "ACME Corp",
319+
workforce: 250,
320+
nafCode: "62.02",
321+
address: "1 rue test",
322+
hasCse: true,
323+
declarantFirstName: "Jean",
324+
declarantLastName: "Dupont",
325+
declarantEmail: "jean@acme.fr",
326+
declarantPhone: "0612345678",
327+
...nullIndicators,
328+
},
329+
]);
330+
mockFetchCse.mockResolvedValue(
331+
new Map([
332+
[
333+
"decl-1",
334+
[
335+
{
336+
declarationNumber: 1,
337+
type: "accuracy",
338+
opinion: "favorable",
339+
opinionDate: "2027-03-01",
340+
},
341+
{
342+
declarationNumber: 2,
343+
type: "gap",
344+
opinion: "unfavorable",
345+
opinionDate: "2027-06-01",
346+
},
347+
],
348+
],
349+
]),
350+
);
351+
352+
const { GET } = await import("~/app/api/v1/export/declarations/route");
353+
const request = authedRequest(
354+
"http://localhost/api/v1/export/declarations?date_begin=2027-03-15",
355+
);
356+
const response = await GET(request);
357+
358+
expect(response.status).toBe(200);
359+
const body = await response.json();
360+
expect(body.declarations[0].cseOpinions).toEqual([
361+
{
362+
declarationNumber: 1,
363+
type: "accuracy",
364+
opinion: "favorable",
365+
date: "2027-03-01",
366+
},
367+
{
368+
declarationNumber: 2,
369+
type: "gap",
370+
opinion: "unfavorable",
371+
date: "2027-06-01",
372+
},
373+
]);
374+
});
375+
376+
it("should include CSE file URLs in the declaration response", async () => {
377+
mockFetchSubmitted.mockResolvedValue([
378+
{
379+
declarationId: "decl-1",
380+
siren: "123456789",
381+
year: 2027,
382+
status: "submitted",
383+
compliancePath: null,
384+
totalWomen: 100,
385+
totalMen: 150,
386+
secondDeclarationStatus: null,
387+
secondDeclReferencePeriodStart: null,
388+
secondDeclReferencePeriodEnd: null,
389+
createdAt: new Date("2027-03-15T10:00:00Z"),
390+
updatedAt: new Date("2027-03-15T12:00:00Z"),
391+
companyName: "ACME Corp",
392+
workforce: 250,
393+
nafCode: "62.02",
394+
address: "1 rue test",
395+
hasCse: true,
396+
declarantFirstName: "Jean",
397+
declarantLastName: "Dupont",
398+
declarantEmail: "jean@acme.fr",
399+
declarantPhone: "0612345678",
400+
...nullIndicators,
401+
},
402+
]);
403+
mockFetchCseFiles.mockResolvedValue(
404+
new Map([
405+
[
406+
"123456789-2027",
407+
[
408+
{
409+
id: "file-abc",
410+
siren: "123456789",
411+
year: 2027,
412+
fileName: "avis-cse-2027.pdf",
413+
filePath: "/s3/path",
414+
uploadedAt: new Date("2027-03-10T08:30:00Z"),
415+
},
416+
],
417+
],
418+
]),
419+
);
420+
421+
const { GET } = await import("~/app/api/v1/export/declarations/route");
422+
const request = authedRequest(
423+
"http://localhost/api/v1/export/declarations?date_begin=2027-03-15",
424+
);
425+
const response = await GET(request);
426+
427+
expect(response.status).toBe(200);
428+
expect(mockFetchCseFiles).toHaveBeenCalledWith([
429+
{ siren: "123456789", year: 2027 },
430+
]);
431+
const body = await response.json();
432+
expect(body.declarations[0].cseFiles).toEqual([
433+
{
434+
id: "file-abc",
435+
fileName: "avis-cse-2027.pdf",
436+
uploadedAt: "2027-03-10T08:30:00.000Z",
437+
downloadUrl: "/api/v1/files/file-abc",
438+
},
439+
]);
297440
});
298441
});

packages/app/src/modules/export/__tests__/fetchDeclarations.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,19 +282,54 @@ describe("assembleDeclaration", () => {
282282

283283
it("should map CSE opinions", () => {
284284
const opinions: CseRow[] = [
285-
{ type: "initial", opinion: "favorable", opinionDate: "2027-01-15" },
285+
{
286+
declarationNumber: 1,
287+
type: "accuracy",
288+
opinion: "favorable",
289+
opinionDate: "2027-01-15",
290+
},
286291
];
287292

288293
const result = assembleDeclaration(baseRow, [], opinions);
289294

290295
expect(result.cseOpinions).toHaveLength(1);
291296
expect(result.cseOpinions[0]).toEqual({
292-
type: "initial",
297+
declarationNumber: 1,
298+
type: "accuracy",
293299
opinion: "favorable",
294300
date: "2027-01-15",
295301
});
296302
});
297303

304+
it("should map CSE files with download URLs", () => {
305+
const files = [
306+
{
307+
id: "file-xyz",
308+
siren: "123456789",
309+
year: 2027,
310+
fileName: "avis-cse.pdf",
311+
filePath: "/s3/path",
312+
uploadedAt: new Date("2027-02-10T08:30:00Z"),
313+
},
314+
];
315+
316+
const result = assembleDeclaration(baseRow, [], [], files);
317+
318+
expect(result.cseFiles).toEqual([
319+
{
320+
id: "file-xyz",
321+
fileName: "avis-cse.pdf",
322+
uploadedAt: "2027-02-10T08:30:00.000Z",
323+
downloadUrl: "/api/v1/files/file-xyz",
324+
},
325+
]);
326+
});
327+
328+
it("should default cseFiles to empty array when not provided", () => {
329+
const result = assembleDeclaration(baseRow, [], []);
330+
expect(result.cseFiles).toEqual([]);
331+
});
332+
298333
it("should handle null dates", () => {
299334
const rowWithNullDates = { ...baseRow, createdAt: null, updatedAt: null };
300335
const result = assembleDeclaration(rowWithNullDates, [], []);

packages/app/src/modules/export/__tests__/openapi.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe("openApiSpec", () => {
66
it("should be a valid OpenAPI 3.1 structure", () => {
77
expect(openApiSpec.openapi).toBe("3.1.0");
88
expect(openApiSpec.info.title).toBeDefined();
9-
expect(openApiSpec.info.version).toBe("1.2.0");
9+
expect(openApiSpec.info.version).toBe("1.3.0");
1010
expect(openApiSpec.paths).toBeDefined();
1111
});
1212

packages/app/src/modules/export/fetchDeclarations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type IndicatorGEntry = {
3434
};
3535

3636
export type CseRow = {
37+
declarationNumber: number;
3738
type: string;
3839
opinion: string | null;
3940
opinionDate: string | null;
@@ -153,6 +154,7 @@ export function assembleDeclaration(
153154
row: DeclarationRow,
154155
indicatorGEntries: IndicatorGEntry[],
155156
opinions: CseRow[],
157+
cseFiles: FileRow[] = [],
156158
) {
157159
const { initial, correction } = buildIndicatorG(indicatorGEntries);
158160

@@ -187,9 +189,16 @@ export function assembleDeclaration(
187189
phone: row.declarantPhone,
188190
},
189191
cseOpinions: opinions.map((o) => ({
192+
declarationNumber: o.declarationNumber,
190193
type: o.type,
191194
opinion: o.opinion,
192195
date: o.opinionDate,
193196
})),
197+
cseFiles: cseFiles.map((f) => ({
198+
id: f.id,
199+
fileName: f.fileName,
200+
uploadedAt: f.uploadedAt.toISOString(),
201+
downloadUrl: `/api/v1/files/${f.id}`,
202+
})),
194203
};
195204
}

packages/app/src/modules/export/openapi.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,18 @@ const indicatorGCategorySchema = {
3434
const cseOpinionSchema = {
3535
type: "object",
3636
properties: {
37-
type: { type: "string" },
37+
declarationNumber: {
38+
type: "integer",
39+
enum: [1, 2],
40+
description:
41+
"Declaration number the CSE opinion refers to: 1 = initial declaration, 2 = second declaration (required when pay gap >= 5%)",
42+
},
43+
type: {
44+
type: "string",
45+
enum: ["accuracy", "gap"],
46+
description:
47+
"CSE opinion type: 'accuracy' = opinion on data accuracy, 'gap' = opinion on pay gap correction measures",
48+
},
3849
opinion: { type: ["string", "null"] },
3950
date: { type: ["string", "null"], format: "date" },
4051
},
@@ -213,6 +224,25 @@ const declarationSchema = {
213224
description:
214225
"CSE opinions (PDF uploads, up to 4/year, companies >= 100 employees)",
215226
},
227+
cseFiles: {
228+
type: "array",
229+
items: {
230+
type: "object",
231+
properties: {
232+
id: { type: "string", description: "File unique identifier" },
233+
fileName: { type: "string", example: "avis-cse-2026.pdf" },
234+
uploadedAt: { type: "string", format: "date-time" },
235+
downloadUrl: {
236+
type: "string",
237+
description:
238+
"Relative URL to download the file via GET /api/v1/files/{fileId}",
239+
example: "/api/v1/files/abc-123",
240+
},
241+
},
242+
},
243+
description:
244+
"CSE opinion files (PDF) attached to the declaration, with a download URL pointing to /api/v1/files/{fileId}",
245+
},
216246
},
217247
} as const;
218248

@@ -227,6 +257,12 @@ const fileMetadataSchema = {
227257
},
228258
fileName: { type: "string", example: "avis-cse-2026.pdf" },
229259
uploadedAt: { type: "string", format: "date-time" },
260+
downloadUrl: {
261+
type: "string",
262+
description:
263+
"Relative URL to download the file via GET /api/v1/files/{fileId}",
264+
example: "/api/v1/files/abc-123",
265+
},
230266
},
231267
} as const;
232268

@@ -253,7 +289,7 @@ export const openApiSpec = {
253289
title: "EGAPRO — API d'export",
254290
description:
255291
"API REST sécurisée permettant de consulter les déclarations d'égalité professionnelle et les fichiers associés (avis CSE, évaluations conjointes). L'accès nécessite une signature de requête (RSA-SHA256) et une clé API (Bearer token).",
256-
version: "1.2.0",
292+
version: "1.3.0",
257293
contact: {
258294
name: "Équipe EGAPRO — DNUM",
259295
},

packages/app/src/modules/export/queries.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export async function fetchCseOpinionsByDeclaration(
174174
const rows = await database
175175
.select({
176176
declarationId: cseOpinions.declarationId,
177+
declarationNumber: cseOpinions.declarationNumber,
177178
type: cseOpinions.type,
178179
opinion: cseOpinions.opinion,
179180
opinionDate: cseOpinions.opinionDate,

0 commit comments

Comments
 (0)