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
1 change: 1 addition & 0 deletions packages/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ S3_BUCKET_NAME="egapro-dev-app"
CLAMAV_HOST="localhost"
CLAMAV_PORT="3310"
ADMIN_EMAILS="test@fia1.fr"
EGAPRO_AUDIT_CLEANUP_TOKEN="change-me-to-a-32-chars-minimum-token"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah oui bien vu 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

à quoi ca sert concretementt ? et pouquoi on l'a pas register dans le env.js ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ou alors, c'est une variable qu'on avait oublié d'ajouter :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est juste bizarre d'avoir une variable clean_up comme ça, on pourra en discuter une next time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oui c'est plus un oubli, c'est un token pour sécuriser l'api de nettoyage des logs d'audit

22 changes: 22 additions & 0 deletions packages/app/drizzle/0026_add_referents_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Create referent type enum and referents table for admin referent management (issue #3182)
DO $$ BEGIN
CREATE TYPE "public"."referent_type" AS ENUM('email', 'url');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

CREATE TABLE IF NOT EXISTS "app_referent" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"region" varchar(3) NOT NULL,
"county" varchar(3),
"name" varchar(255) NOT NULL,
"type" "referent_type" NOT NULL,
"value" varchar(500) NOT NULL,
"principal" boolean DEFAULT false NOT NULL,
"substitute_name" varchar(255),
"substitute_email" varchar(255),
"created_at" timestamp with time zone,
"updated_at" timestamp with time zone
);

CREATE INDEX IF NOT EXISTS "referent_region_idx" ON "app_referent" USING btree ("region");
7 changes: 7 additions & 0 deletions packages/app/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@
"when": 1775700000000,
"tag": "0025_add_user_is_admin",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1775800000000,
"tag": "0026_add_referents_table",
"breakpoints": true
}
]
}
5 changes: 5 additions & 0 deletions packages/app/src/app/admin/liste-referents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminReferentsPage } from "~/modules/admin";

export default function Page() {
return <AdminReferentsPage />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
dbSelect: vi.fn(),
}));

vi.mock("~/server/db", () => ({
db: { select: mocks.dbSelect },
}));

vi.mock("~/server/db/schema", () => ({
referents: {
region: "region",
county: "county",
name: "name",
type: "type",
value: "value",
principal: "principal",
substituteName: "substituteName",
substituteEmail: "substituteEmail",
},
}));

vi.mock("drizzle-orm", () => ({
asc: (col: unknown) => ({ asc: col }),
}));

function setRows(rows: unknown[]) {
mocks.dbSelect.mockReturnValue({
from: () => ({
orderBy: () => Promise.resolve(rows),
}),
});
}

describe("/api/public/referents-egalite-professionnelle", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("returns JSON by default", async () => {
setRows([
{
region: "11",
county: "75",
name: "Jean",
type: "email",
value: "j@gouv.fr",
principal: true,
substituteName: null,
substituteEmail: null,
},
]);

const { GET } = await import("../route");
const response = await GET(
new Request(
"http://localhost/api/public/referents-egalite-professionnelle",
),
);

expect(response.headers.get("Content-Type")).toMatch(/application\/json/);
const body = await response.json();
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ name: "Jean", region: "11" });
});

it("returns CSV with headers and region/county labels when format=csv", async () => {
setRows([
{
region: "11",
county: "75",
name: "Jean",
type: "email",
value: 'j"@gouv.fr',
principal: true,
substituteName: "Marie",
substituteEmail: "m@gouv.fr",
},
{
region: "11",
county: null,
name: "Sans département",
type: "url",
value: "https://gouv.fr",
principal: false,
substituteName: null,
substituteEmail: null,
},
]);

const { GET } = await import("../route");
const response = await GET(
new Request(
"http://localhost/api/public/referents-egalite-professionnelle?format=csv",
),
);

expect(response.headers.get("Content-Type")).toContain("text/csv");
expect(response.headers.get("Content-Disposition")).toContain(
"referents_egalite_professionnelle.csv",
);

const csv = await response.text();
const lines = csv.split("\n");
expect(lines[0]).toBe(
"Région;Département;Nom;Type;Valeur;Principal;Nom suppléant;Email suppléant",
);
expect(lines[1]).toContain('"Jean"');
expect(lines[1]).toContain('"j""@gouv.fr"');
expect(lines[1]).toContain('"Oui"');
expect(lines[2]).toContain('"Sans département"');
expect(lines[2]).toContain('"Non"');
expect(lines[2]).toContain('""');
});
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pourquoi on met pas ça dans un router trpc ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Le CSV bloque : tRPC renvoie du JSON enveloppé, pas moyen de setter Content-Disposition pour le téléchargement. Et pour un endpoint /api/public/ consommé par des clients externes (curl, Python, data.gouv), l'enveloppe tRPC + URL couplée au nom de procédure c'est pas le bon contrat. tRPC serait pertinent pour un usage interne.

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { asc } from "drizzle-orm";
import { NextResponse } from "next/server";
import type { CountyCode, RegionCode } from "~/modules/domain";
import { COUNTIES, REGIONS } from "~/modules/domain";
import { db } from "~/server/db";
import { referents } from "~/server/db/schema";

async function getAllReferents() {
return db
.select({
region: referents.region,
county: referents.county,
name: referents.name,
type: referents.type,
value: referents.value,
principal: referents.principal,
substituteName: referents.substituteName,
substituteEmail: referents.substituteEmail,
})
.from(referents)
.orderBy(asc(referents.region), asc(referents.county));
}

function formatCsv(rows: Awaited<ReturnType<typeof getAllReferents>>): string {
const headers = [
"Région",
"Département",
"Nom",
"Type",
"Valeur",
"Principal",
"Nom suppléant",
"Email suppléant",
];

const csvRows = [
headers.join(";"),
...rows.map((r) =>
[
REGIONS[r.region as RegionCode] ?? r.region,
r.county ? (COUNTIES[r.county as CountyCode] ?? r.county) : "",
r.name,
r.type,
r.value,
r.principal ? "Oui" : "Non",
r.substituteName ?? "",
r.substituteEmail ?? "",
]
.map((val) => `"${String(val).replace(/"/g, '""')}"`)
.join(";"),
),
];

return csvRows.join("\n");
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json";

const rows = await getAllReferents();

if (format === "csv") {
const csv = formatCsv(rows);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition":
'attachment; filename="referents_egalite_professionnelle.csv"',
},
});
}

return NextResponse.json(rows);
}
22 changes: 22 additions & 0 deletions packages/app/src/e2e/admin-referents.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test";

test("admin user can access /admin/liste-referents", async ({ page }) => {
await page.goto("/admin/liste-referents");
await expect(
page.getByRole("heading", { name: "Liste des référents Egapro", level: 1 }),
).toBeVisible();
});

test("unauthenticated user visiting /admin/liste-referents is redirected to /login", async ({
browser,
}) => {
const anonCtx = await browser.newContext({ storageState: undefined });
try {
const page = await anonCtx.newPage();
await page.goto("/admin/liste-referents");
await page.waitForURL("**/login**");
expect(page.url()).toContain("/login");
} finally {
await anonCtx.close();
}
});
1 change: 1 addition & 0 deletions packages/app/src/modules/admin/AdminNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
const adminLinks = [
{ href: "/admin", label: "Accueil" },
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
{ href: "/admin/liste-referents", label: "Référents" },
] as const;

export function AdminNavigation() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("AdminNavigation", () => {
expect(
screen.getByRole("link", { name: "Mimoquer un Siren" }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Référents" })).toBeInTheDocument();
});

it("marks /admin as active when on /admin", () => {
Expand All @@ -48,4 +49,16 @@ describe("AdminNavigation", () => {
"aria-current",
);
});

it("marks /admin/liste-referents as active when on that page", () => {
(usePathname as Mock).mockReturnValue("/admin/liste-referents");
render(<AdminNavigation />);
expect(screen.getByRole("link", { name: "Référents" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
"aria-current",
);
});
});
1 change: 1 addition & 0 deletions packages/app/src/modules/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
AdminDeclarationsPage,
} from "./declarations";
export { ImpersonatePage } from "./impersonate/ImpersonatePage";
export { AdminReferentsPage } from "./referents";
export {
type ImpersonateSearchInput,
impersonateSearchSchema,
Expand Down
Loading
Loading