-
Notifications
You must be signed in to change notification settings - Fork 11
feat(admin): referent management (CRUD, import/export, public API) #3198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5573590
5419e42
f159e73
6479b0c
874ad90
f4592c3
ec6a115
c4cbad5
4da9364
ab21b82
4ad5f36
1e714ae
d8890a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 :)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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"); |
| 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('""'); | ||
| }); | ||
| }); |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pourquoi on met pas ça dans un router trpc ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Le CSV bloque : tRPC renvoie du JSON enveloppé, pas moyen de setter |
| 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); | ||
| } |
| 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(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah oui bien vu 👍