diff --git a/packages/app/.env.example b/packages/app/.env.example index b9b4796f6..59389ce90 100644 --- a/packages/app/.env.example +++ b/packages/app/.env.example @@ -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" diff --git a/packages/app/drizzle/0026_add_referents_table.sql b/packages/app/drizzle/0026_add_referents_table.sql new file mode 100644 index 000000000..779a9f0ac --- /dev/null +++ b/packages/app/drizzle/0026_add_referents_table.sql @@ -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"); diff --git a/packages/app/drizzle/meta/_journal.json b/packages/app/drizzle/meta/_journal.json index e54c6ec70..9770dadd0 100644 --- a/packages/app/drizzle/meta/_journal.json +++ b/packages/app/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/packages/app/src/app/admin/liste-referents/page.tsx b/packages/app/src/app/admin/liste-referents/page.tsx new file mode 100644 index 000000000..b221c17bc --- /dev/null +++ b/packages/app/src/app/admin/liste-referents/page.tsx @@ -0,0 +1,5 @@ +import { AdminReferentsPage } from "~/modules/admin"; + +export default function Page() { + return ; +} diff --git a/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts b/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts new file mode 100644 index 000000000..2c67922bb --- /dev/null +++ b/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts @@ -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('""'); + }); +}); diff --git a/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts b/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts new file mode 100644 index 000000000..f9f8da7e7 --- /dev/null +++ b/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts @@ -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>): 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); +} diff --git a/packages/app/src/e2e/admin-referents.e2e.ts b/packages/app/src/e2e/admin-referents.e2e.ts new file mode 100644 index 000000000..c6a158c0d --- /dev/null +++ b/packages/app/src/e2e/admin-referents.e2e.ts @@ -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(); + } +}); diff --git a/packages/app/src/modules/admin/AdminNavigation.tsx b/packages/app/src/modules/admin/AdminNavigation.tsx index 88fb03bc5..64008c22e 100644 --- a/packages/app/src/modules/admin/AdminNavigation.tsx +++ b/packages/app/src/modules/admin/AdminNavigation.tsx @@ -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() { diff --git a/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx b/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx index 9a953d1dc..50f28e2fe 100644 --- a/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx +++ b/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx @@ -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", () => { @@ -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(); + expect(screen.getByRole("link", { name: "Référents" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute( + "aria-current", + ); + }); }); diff --git a/packages/app/src/modules/admin/index.ts b/packages/app/src/modules/admin/index.ts index 5f1abad1b..4da810051 100644 --- a/packages/app/src/modules/admin/index.ts +++ b/packages/app/src/modules/admin/index.ts @@ -5,6 +5,7 @@ export { AdminDeclarationsPage, } from "./declarations"; export { ImpersonatePage } from "./impersonate/ImpersonatePage"; +export { AdminReferentsPage } from "./referents"; export { type ImpersonateSearchInput, impersonateSearchSchema, diff --git a/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx b/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx new file mode 100644 index 000000000..21dee5c5d --- /dev/null +++ b/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useState } from "react"; + +import type { CountyCode, RegionCode } from "~/modules/domain"; +import { api } from "~/trpc/react"; +import { DeleteModal, useDeleteModal } from "./DeleteConfirmationModal"; +import { ImportReferentsModal, useImportModal } from "./ImportReferentsModal"; +import { ReferentFormModal, useReferentFormModal } from "./ReferentFormModal"; +import { ReferentTable } from "./ReferentTable"; +import { SearchForm } from "./SearchForm"; +import type { ReferentFormValues, SortColumn } from "./schemas"; +import { DEFAULT_PAGE_SIZE } from "./schemas"; +import type { ReferentSearchRow } from "./types"; + +function ExportButton() { + const { data, refetch, isFetching } = api.adminReferents.exportAll.useQuery( + undefined, + { enabled: false }, + ); + + const handleExport = useCallback( + async (format: "json" | "csv") => { + const result = data ?? (await refetch()).data; + if (!result) return; + + let content: string; + let mimeType: string; + let extension: string; + + if (format === "csv") { + const headers = [ + "region", + "county", + "name", + "type", + "value", + "principal", + "substituteName", + "substituteEmail", + ]; + const csvRows = [ + headers.join(";"), + ...result.map((r) => + headers + .map((h) => { + const val = r[h as keyof typeof r] ?? ""; + return `"${String(val).replace(/"/g, '""')}"`; + }) + .join(";"), + ), + ]; + content = csvRows.join("\n"); + mimeType = "text/csv;charset=utf-8"; + extension = "csv"; + } else { + content = JSON.stringify(result, null, 2); + mimeType = "application/json"; + extension = "json"; + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `referents.${extension}`; + link.click(); + URL.revokeObjectURL(url); + }, + [data, refetch], + ); + + return ( +
+ + +
+ ); +} + +function ReferentsContent() { + const searchParams = useSearchParams(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [deleteError, setDeleteError] = useState(null); + const [editingReferent, setEditingReferent] = + useState(null); + const { + modalRef: deleteModalRef, + open: openDeleteModal, + close: closeDeleteModal, + } = useDeleteModal(); + const { createRef, editRef, openCreate, closeCreate, openEdit, closeEdit } = + useReferentFormModal(); + const { + modalRef: importModalRef, + open: openImport, + close: closeImport, + } = useImportModal(); + + const input = { + query: searchParams.get("query") ?? undefined, + region: (searchParams.get("region") as RegionCode) || undefined, + county: (searchParams.get("county") as CountyCode) || undefined, + page: Number(searchParams.get("page") ?? "1"), + pageSize: Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)), + sortBy: (searchParams.get("sortBy") as SortColumn) ?? "region", + sortOrder: (searchParams.get("sortOrder") as "asc" | "desc") ?? "asc", + }; + + const { data, isLoading, refetch } = + api.adminReferents.search.useQuery(input); + const deleteMutation = api.adminReferents.delete.useMutation({ + onSuccess: () => { + setSelectedIds(new Set()); + setDeleteError(null); + refetch(); + }, + onError: () => { + setDeleteError("La suppression a échoué. Veuillez réessayer."); + }, + }); + const createMutation = api.adminReferents.create.useMutation({ + onSuccess: () => refetch(), + }); + const updateMutation = api.adminReferents.update.useMutation({ + onSuccess: () => refetch(), + }); + + const handleDelete = useCallback(() => { + if (selectedIds.size === 0) return; + deleteMutation.mutate({ ids: [...selectedIds] }); + }, [selectedIds, deleteMutation]); + + const handleEdit = useCallback( + (row: ReferentSearchRow) => { + setEditingReferent(row); + openEdit(); + }, + [openEdit], + ); + + const handleCreateSubmit = useCallback( + (data: ReferentFormValues & { id?: string }) => { + createMutation.mutate( + data as Parameters[0], + ); + }, + [createMutation], + ); + + const handleEditSubmit = useCallback( + (data: ReferentFormValues & { id?: string }) => { + if (!data.id) return; + updateMutation.mutate( + data as Parameters[0], + ); + }, + [updateMutation], + ); + + if (isLoading) { + return

Chargement...

; + } + + return ( + <> + +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+ +
+
+ {deleteError && ( +
+

{deleteError}

+
+ )} + {selectedIds.size > 0 && ( +
+ +
+ )} + {data && ( + + )} + + + + refetch()} + /> + + ); +} + +export function AdminReferentsPage() { + return ( + <> +

Liste des référents Egapro

+ Chargement...

}> + +
+ + ); +} diff --git a/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx b/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx new file mode 100644 index 000000000..8fea7e7d3 --- /dev/null +++ b/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useCallback } from "react"; + +import { useDsfrModal } from "~/modules/shared"; + +const MODAL_ID = "admin-delete-referents-modal"; + +export function useDeleteModal() { + return useDsfrModal(); +} + +type ModalProps = { + count: number; + onConfirm: () => void; + modalRef: React.RefObject; + onClose: () => void; +}; + +export function DeleteModal({ + count, + onConfirm, + modalRef, + onClose, +}: ModalProps) { + const handleConfirm = useCallback(() => { + onClose(); + onConfirm(); + }, [onClose, onConfirm]); + + return ( + +
+
+
+
+
+ +
+
+

+ Confirmer la suppression +

+

+ Vous êtes sur le point de supprimer{" "} + + {count} référent{count > 1 ? "s" : ""} + + . Cette action est irréversible. +

+
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ ); +} diff --git a/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx b/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx new file mode 100644 index 000000000..3e41aafda --- /dev/null +++ b/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { useDsfrModal } from "~/modules/shared"; +import { api } from "~/trpc/react"; + +import { importReferentsSchema } from "./schemas"; + +const MODAL_ID = "admin-import-referents-modal"; + +export function useImportModal() { + return useDsfrModal(); +} + +type Props = { + modalRef: React.RefObject; + onClose: () => void; + onSuccess: () => void; +}; + +export function ImportReferentsModal({ modalRef, onClose, onSuccess }: Props) { + const [file, setFile] = useState(null); + const [error, setError] = useState(""); + const importMutation = api.adminReferents.import.useMutation({ + onSuccess: () => { + setFile(null); + setError(""); + onClose(); + onSuccess(); + }, + onError: (err) => { + setError(err.message); + }, + }); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + setFile(e.target.files?.[0] ?? null); + setError(""); + }, + [], + ); + + const handleImport = useCallback(async () => { + if (!file) return; + setError(""); + + try { + const text = await file.text(); + const json: unknown = JSON.parse(text); + const parsed = importReferentsSchema.safeParse(json); + + if (!parsed.success) { + setError( + `Format invalide : ${parsed.error.issues.map((i) => i.message).join(", ")}`, + ); + return; + } + + importMutation.mutate(parsed.data); + } catch { + setError("Le fichier n'est pas un JSON valide."); + } + }, [file, importMutation]); + + return ( + +
+
+
+
+
+ +
+
+

+ Importer des référents +

+

Importer depuis un fichier JSON une liste de référents.

+
+

+ Attention, cette opération remplacera toutes les données + existantes ! +

+
+
+ + +
+ {error && ( +
+

{error}

+
+ )} + {importMutation.isPending && ( +
+

Import en cours...

+
+ )} +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ ); +} diff --git a/packages/app/src/modules/admin/referents/ReferentFormFields.tsx b/packages/app/src/modules/admin/referents/ReferentFormFields.tsx new file mode 100644 index 000000000..e4cf295fc --- /dev/null +++ b/packages/app/src/modules/admin/referents/ReferentFormFields.tsx @@ -0,0 +1,191 @@ +"use client"; + +import type { + FieldErrors, + UseFormRegister, + UseFormWatch, +} from "react-hook-form"; +import type { RegionCode } from "~/modules/domain"; +import { COUNTIES, REGIONS, REGIONS_TO_COUNTIES } from "~/modules/domain"; + +import type { ReferentFormValues } from "./schemas"; + +type Props = { + modalId: string; + register: UseFormRegister; + watch: UseFormWatch; + errors: FieldErrors; +}; + +const sortedRegions = (Object.entries(REGIONS) as [RegionCode, string][]).sort( + (a, b) => a[1].localeCompare(b[1], "fr"), +); + +export function ReferentFormFields({ + modalId, + register, + watch, + errors, +}: Props) { + const selectedRegion = watch("region") as RegionCode | ""; + const selectedType = watch("type"); + const countyOptions = selectedRegion + ? REGIONS_TO_COUNTIES[selectedRegion as RegionCode] + : []; + + return ( + <> +
+ + +
+ +
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + + {errors.region && ( +

{errors.region.message}

+ )} +
+ +
+ + +
+ +
+ Type de la valeur +
+
+ + +
+
+ + +
+
+
+ +
+ + + {errors.value && ( +

{errors.value.message}

+ )} +
+ +
+ + Suppléant + Non obligatoire + +
+
+ + +
+
+ + + {errors.substituteEmail && ( +

{errors.substituteEmail.message}

+ )} +
+
+
+ + ); +} diff --git a/packages/app/src/modules/admin/referents/ReferentFormModal.tsx b/packages/app/src/modules/admin/referents/ReferentFormModal.tsx new file mode 100644 index 000000000..379eda1d6 --- /dev/null +++ b/packages/app/src/modules/admin/referents/ReferentFormModal.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import type { CountyCode, RegionCode } from "~/modules/domain"; +import { useDsfrModal, useZodForm } from "~/modules/shared"; + +import { ReferentFormFields } from "./ReferentFormFields"; +import type { ReferentFormValues } from "./schemas"; +import { referentFormSchema } from "./schemas"; +import type { ReferentSearchRow } from "./types"; + +type Props = { + mode: "create" | "edit"; + referent?: ReferentSearchRow | null; + onSubmit: (data: ReferentFormValues & { id?: string }) => void; +}; + +const CREATE_MODAL_ID = "admin-create-referent-modal"; +const EDIT_MODAL_ID = "admin-edit-referent-modal"; + +export function useReferentFormModal() { + const create = useDsfrModal(); + const edit = useDsfrModal(); + return { + createRef: create.modalRef, + editRef: edit.modalRef, + openCreate: create.open, + closeCreate: create.close, + openEdit: edit.open, + closeEdit: edit.close, + }; +} + +export function ReferentFormModal({ + mode, + referent, + onSubmit, + modalRef, + onClose, +}: Props & { + modalRef: React.RefObject; + onClose: () => void; +}) { + const modalId = mode === "create" ? CREATE_MODAL_ID : EDIT_MODAL_ID; + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isValid }, + } = useZodForm(referentFormSchema, { + defaultValues: { + region: "" as RegionCode, + county: "", + name: "", + type: "email" as const, + value: "", + principal: false, + substituteName: "", + substituteEmail: "", + }, + mode: "onChange", + }); + + useEffect(() => { + if (mode === "edit" && referent) { + reset({ + region: referent.region as RegionCode, + county: (referent.county as CountyCode) ?? "", + name: referent.name, + type: referent.type, + value: referent.value, + principal: referent.principal, + substituteName: referent.substituteName ?? "", + substituteEmail: referent.substituteEmail ?? "", + }); + } else if (mode === "create") { + reset({ + region: "" as RegionCode, + county: "", + name: "", + type: "email", + value: "", + principal: false, + substituteName: "", + substituteEmail: "", + }); + } + }, [mode, referent, reset]); + + const doSubmit = useCallback( + (data: ReferentFormValues) => { + onSubmit( + mode === "edit" && referent ? { ...data, id: referent.id } : data, + ); + onClose(); + reset(); + }, + [mode, referent, onSubmit, onClose, reset], + ); + + return ( + +
+
+
+
+
+ +
+
+

+ {mode === "create" + ? "Ajouter un référent" + : "Modifier le référent"} +

+
+ + +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ ); +} diff --git a/packages/app/src/modules/admin/referents/ReferentTable.tsx b/packages/app/src/modules/admin/referents/ReferentTable.tsx new file mode 100644 index 000000000..7aded2339 --- /dev/null +++ b/packages/app/src/modules/admin/referents/ReferentTable.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; +import type { CountyCode, RegionCode } from "~/modules/domain"; +import { COUNTIES, REGIONS } from "~/modules/domain"; +import { DsfrTable } from "~/modules/shared/DsfrTable"; + +import type { SortColumn } from "./schemas"; +import { SORT_COLUMNS } from "./schemas"; +import { COLUMN_LABELS } from "./shared/constants"; +import type { ReferentSearchRow } from "./types"; + +type Props = { + rows: ReferentSearchRow[]; + total: number; + page: number; + totalPages: number; + sortBy: string; + sortOrder: string; + selectedIds: Set; + onSelectionChange: (ids: Set) => void; + onEdit: (row: ReferentSearchRow) => void; +}; + +export function ReferentTable({ + rows, + total, + page, + totalPages, + sortBy, + sortOrder, + selectedIds, + onSelectionChange, + onEdit, +}: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const toggleOne = useCallback( + (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onSelectionChange(next); + }, + [selectedIds, onSelectionChange], + ); + + const handleSort = useCallback( + (column: SortColumn) => { + const params = new URLSearchParams(searchParams.toString()); + if (sortBy === column) { + params.set("sortOrder", sortOrder === "asc" ? "desc" : "asc"); + } else { + params.set("sortBy", column); + params.set("sortOrder", "asc"); + } + params.set("page", "1"); + router.push(`/admin/liste-referents?${params.toString()}`); + }, + [searchParams, sortBy, sortOrder, router], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("page", String(newPage)); + router.push(`/admin/liste-referents?${params.toString()}`); + }, + [searchParams, router], + ); + + const ariaSort = (column: SortColumn) => { + if (sortBy !== column) return undefined; + return sortOrder === "asc" + ? ("ascending" as const) + : ("descending" as const); + }; + + const sortIcon = (column: SortColumn) => { + if (sortBy !== column) return null; + return ; + }; + + return ( + <> +

+ {total} résultat{total > 1 ? "s" : ""} +

+ + + + + Sélectionner + + {SORT_COLUMNS.map((col) => ( + + + + ))} + Actions + + + + {rows.map((row) => ( + + +
+ toggleOne(row.id)} + type="checkbox" + /> + +
+ + + {REGIONS[row.region as RegionCode] ?? row.region} ({row.region}) + + + {row.county + ? `${COUNTIES[row.county as CountyCode] ?? row.county} (${row.county})` + : "—"} + + + {row.name} + {row.substituteName && ( + <> +
+ + {row.substituteName} + + + )} + + + + + {row.substituteEmail && ( + <> +
+ + {row.substituteEmail} + + + )} + + +
+ {totalPages > 1 && ( +

+ Page {page} sur {totalPages} —{" "} + {page > 1 && ( + + )} + {page < totalPages && ( + + )} +

+ )} + + ); +} diff --git a/packages/app/src/modules/admin/referents/SearchForm.tsx b/packages/app/src/modules/admin/referents/SearchForm.tsx new file mode 100644 index 000000000..72c9485cc --- /dev/null +++ b/packages/app/src/modules/admin/referents/SearchForm.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; +import type { RegionCode } from "~/modules/domain"; +import { COUNTIES, REGIONS, REGIONS_TO_COUNTIES } from "~/modules/domain"; +import { useZodForm } from "~/modules/shared"; + +import type { SearchReferentsFormValues } from "./schemas"; +import { searchReferentsFormSchema } from "./schemas"; + +const sortedRegions = (Object.entries(REGIONS) as [RegionCode, string][]).sort( + (a, b) => a[1].localeCompare(b[1], "fr"), +); + +export function SearchForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const { register, handleSubmit, reset, watch } = useZodForm( + searchReferentsFormSchema, + { + defaultValues: { + query: searchParams.get("query") ?? "", + region: searchParams.get("region") ?? "", + county: searchParams.get("county") ?? "", + }, + }, + ); + + const selectedRegion = watch("region") as RegionCode | "" | undefined; + const countyOptions = selectedRegion + ? REGIONS_TO_COUNTIES[selectedRegion as RegionCode] + : []; + + const onSubmit = useCallback( + (data: SearchReferentsFormValues) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + params.set("page", "1"); + router.push(`/admin/liste-referents?${params.toString()}`); + }, + [router], + ); + + const handleReset = useCallback(() => { + reset({ query: "", region: "", county: "" }); + router.push("/admin/liste-referents"); + }, [reset, router]); + + return ( +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+ ); +} diff --git a/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx b/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx new file mode 100644 index 000000000..79e4eed25 --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", async () => { + const actual = + await vi.importActual("next/navigation"); + return { + ...actual, + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + refresh: vi.fn(), + }), + useSearchParams: vi.fn().mockReturnValue(new URLSearchParams()), + }; +}); + +vi.mock("~/trpc/react", () => ({ + api: { + adminReferents: { + search: { + useQuery: vi.fn().mockReturnValue({ + data: { + rows: [ + { + id: "ref-1", + region: "11", + county: "75", + name: "Jean DUPONT", + type: "email", + value: "jean@gouv.fr", + principal: true, + substituteName: "Marie MARTIN", + substituteEmail: "marie@gouv.fr", + createdAt: new Date("2024-06-15T10:00:00Z"), + }, + ], + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }, + isLoading: false, + refetch: vi.fn(), + }), + }, + delete: { + useMutation: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), + }, + create: { + useMutation: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), + }, + update: { + useMutation: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), + }, + import: { + useMutation: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), + }, + exportAll: { + useQuery: vi.fn().mockReturnValue({ + data: null, + refetch: vi.fn(), + isFetching: false, + }), + }, + }, + }, +})); + +import { AdminReferentsPage } from "../AdminReferentsPage"; + +describe("AdminReferentsPage", () => { + it("renders page title", () => { + render(); + expect( + screen.getByRole("heading", { + level: 1, + name: "Liste des référents Egapro", + }), + ).toBeInTheDocument(); + }); + + it("renders search form", () => { + render(); + expect( + screen.getByRole("button", { name: "Rechercher" }), + ).toBeInTheDocument(); + }); + + it("renders table with data", () => { + render(); + expect(screen.getByText("Jean DUPONT")).toBeInTheDocument(); + expect(screen.getByText("jean@gouv.fr")).toBeInTheDocument(); + }); + + it("shows result count", () => { + render(); + expect(screen.getByText("1 résultat")).toBeInTheDocument(); + }); + + it("renders action buttons", () => { + render(); + expect(screen.getByRole("button", { name: "Ajouter" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Importer" }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx b/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx new file mode 100644 index 000000000..7bef9909a --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DeleteModal } from "../DeleteConfirmationModal"; + +function renderModal(count = 3) { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + const modalRef = createRef(); + render( + , + ); + return { onClose, onConfirm }; +} + +describe("DeleteModal", () => { + it("renders the confirmation title", () => { + renderModal(); + expect( + screen.getByRole("heading", { + level: 2, + name: "Confirmer la suppression", + hidden: true, + }), + ).toBeInTheDocument(); + }); + + it("pluralizes the count when greater than 1", () => { + renderModal(3); + expect(screen.getByText("3 référents")).toBeInTheDocument(); + }); + + it("uses the singular form when count is 1", () => { + renderModal(1); + expect(screen.getByText("1 référent")).toBeInTheDocument(); + }); + + it("calls onClose then onConfirm when confirming", () => { + const { onClose, onConfirm } = renderModal(2); + fireEvent.click( + screen.getByRole("button", { name: "Supprimer", hidden: true }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("calls onClose on Annuler", () => { + const { onClose, onConfirm } = renderModal(2); + fireEvent.click( + screen.getByRole("button", { name: "Annuler", hidden: true }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("calls onClose on the Fermer button", () => { + const { onClose } = renderModal(2); + fireEvent.click( + screen.getByRole("button", { name: "Fermer", hidden: true }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx b/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx new file mode 100644 index 000000000..b0c953661 --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx @@ -0,0 +1,145 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { createRef } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const importMutate = vi.fn(); +let mutationOnError: ((err: { message: string }) => void) | undefined; +let mutationOnSuccess: (() => void) | undefined; + +vi.mock("~/trpc/react", () => ({ + api: { + adminReferents: { + import: { + useMutation: (opts: { + onSuccess?: () => void; + onError?: (err: { message: string }) => void; + }) => { + mutationOnSuccess = opts?.onSuccess; + mutationOnError = opts?.onError; + return { mutate: importMutate, isPending: false }; + }, + }, + }, + }, +})); + +import { ImportReferentsModal } from "../ImportReferentsModal"; + +function renderModal() { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const modalRef = createRef(); + render( + , + ); + return { onClose, onSuccess }; +} + +function selectFile(name: string, content: string) { + const input = screen.getByLabelText(/Fichier JSON/, { + selector: "input", + }) as HTMLInputElement; + const file = new File([content], name, { type: "application/json" }); + fireEvent.change(input, { target: { files: [file] } }); + return file; +} + +describe("ImportReferentsModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mutationOnError = undefined; + mutationOnSuccess = undefined; + }); + + it("renders warning and title", () => { + renderModal(); + expect( + screen.getByText(/cette opération remplacera toutes les données/), + ).toBeInTheDocument(); + }); + + it("disables Importer until a file is selected", () => { + renderModal(); + const importBtn = screen.getByRole("button", { + name: "Importer", + hidden: true, + }); + expect(importBtn).toBeDisabled(); + }); + + it("shows an error when the file is not valid JSON", async () => { + renderModal(); + selectFile("bad.json", "not-json"); + fireEvent.click( + screen.getByRole("button", { name: "Importer", hidden: true }), + ); + await waitFor(() => { + expect( + screen.getByText("Le fichier n'est pas un JSON valide."), + ).toBeInTheDocument(); + }); + expect(importMutate).not.toHaveBeenCalled(); + }); + + it("shows an error when the JSON fails schema validation", async () => { + renderModal(); + selectFile("wrong.json", JSON.stringify([{ region: "bad" }])); + fireEvent.click( + screen.getByRole("button", { name: "Importer", hidden: true }), + ); + await waitFor(() => { + expect(screen.getByText(/Format invalide/)).toBeInTheDocument(); + }); + expect(importMutate).not.toHaveBeenCalled(); + }); + + it("calls the mutation when the payload is valid", async () => { + renderModal(); + const valid = [ + { + region: "11", + county: "75", + name: "Jean", + type: "email", + value: "jean@gouv.fr", + principal: true, + substituteName: "", + substituteEmail: "", + }, + ]; + selectFile("valid.json", JSON.stringify(valid)); + fireEvent.click( + screen.getByRole("button", { name: "Importer", hidden: true }), + ); + await waitFor(() => { + expect(importMutate).toHaveBeenCalledTimes(1); + }); + }); + + it("surfaces a server error through the mutation onError hook", () => { + renderModal(); + act(() => { + mutationOnError?.({ message: "boom" }); + }); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("closes and calls onSuccess when the mutation resolves", () => { + const { onClose, onSuccess } = renderModal(); + act(() => { + mutationOnSuccess?.(); + }); + expect(onClose).toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx b/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx new file mode 100644 index 000000000..98ab12fa9 --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx @@ -0,0 +1,144 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const routerPush = vi.fn(); +let currentSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", async () => { + const actual = + await vi.importActual("next/navigation"); + return { + ...actual, + useRouter: () => ({ push: routerPush }), + useSearchParams: () => currentSearchParams, + }; +}); + +import { ReferentTable } from "../ReferentTable"; +import type { ReferentSearchRow } from "../types"; + +const baseRow: ReferentSearchRow = { + id: "ref-1", + region: "11", + county: "75", + name: "Jean DUPONT", + type: "email", + value: "jean@gouv.fr", + principal: true, + substituteName: "Marie MARTIN", + substituteEmail: "marie@gouv.fr", + createdAt: new Date("2024-06-15T10:00:00Z"), +}; + +function renderTable( + overrides: Partial[0]> = {}, +) { + const onEdit = vi.fn(); + const onSelectionChange = vi.fn(); + render( + , + ); + return { onEdit, onSelectionChange }; +} + +describe("ReferentTable", () => { + beforeEach(() => { + routerPush.mockClear(); + currentSearchParams = new URLSearchParams(); + }); + + it("renders rows with region, county and value", () => { + renderTable(); + expect(screen.getByText("Jean DUPONT")).toBeInTheDocument(); + expect(screen.getByText("jean@gouv.fr")).toBeInTheDocument(); + expect(screen.getByText("Marie MARTIN")).toBeInTheDocument(); + }); + + it("renders an empty state when there are no rows", () => { + renderTable({ rows: [], total: 0 }); + expect(screen.getByText("Aucun référent trouvé.")).toBeInTheDocument(); + }); + + it("toggles a row selection when its checkbox is clicked", () => { + const { onSelectionChange } = renderTable(); + fireEvent.click(screen.getByLabelText(/Sélectionner Jean DUPONT/)); + const selected = onSelectionChange.mock.calls[0]?.[0] as Set; + expect(selected.has("ref-1")).toBe(true); + }); + + it("unselects when clicking an already-selected row", () => { + const { onSelectionChange } = renderTable({ + selectedIds: new Set(["ref-1"]), + }); + fireEvent.click(screen.getByLabelText(/Sélectionner Jean DUPONT/)); + const selected = onSelectionChange.mock.calls[0]?.[0] as Set; + expect(selected.has("ref-1")).toBe(false); + }); + + it("flips sort order when clicking the active sort column", () => { + renderTable({ sortBy: "region", sortOrder: "asc" }); + fireEvent.click(screen.getByRole("button", { name: /Région/ })); + expect(routerPush).toHaveBeenCalledWith( + expect.stringMatching(/sortOrder=desc/), + ); + }); + + it("switches sort column on inactive column click", () => { + renderTable({ sortBy: "region", sortOrder: "asc" }); + fireEvent.click(screen.getByRole("button", { name: "Nom" })); + expect(routerPush).toHaveBeenCalledWith( + expect.stringMatching(/sortBy=name/), + ); + expect(routerPush).toHaveBeenCalledWith( + expect.stringMatching(/sortOrder=asc/), + ); + }); + + it("shows pagination when totalPages > 1 and navigates", () => { + renderTable({ page: 2, totalPages: 3 }); + fireEvent.click(screen.getByRole("button", { name: "Suivant" })); + expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=3/)); + fireEvent.click(screen.getByRole("button", { name: "Précédent" })); + expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=1/)); + }); + + it("calls onEdit when clicking Modifier", () => { + const { onEdit } = renderTable(); + fireEvent.click(screen.getByRole("button", { name: "Modifier" })); + expect(onEdit).toHaveBeenCalledWith(baseRow); + }); + + it("handles rows without a county", () => { + renderTable({ + rows: [{ ...baseRow, county: null }], + }); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("handles URL type referents", () => { + renderTable({ + rows: [ + { + ...baseRow, + type: "url", + value: "https://gouv.fr", + substituteName: null, + substituteEmail: null, + }, + ], + }); + const link = screen.getByRole("link", { name: /https:\/\/gouv\.fr/ }); + expect(link).toHaveAttribute("href", "https://gouv.fr"); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx b/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx new file mode 100644 index 000000000..20f2c7f2c --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const routerPush = vi.fn(); + +vi.mock("next/navigation", async () => { + const actual = + await vi.importActual("next/navigation"); + return { + ...actual, + useRouter: () => ({ push: routerPush }), + useSearchParams: () => new URLSearchParams(), + }; +}); + +import { SearchForm } from "../SearchForm"; + +describe("SearchForm", () => { + beforeEach(() => { + routerPush.mockClear(); + }); + + it("disables the county select until a region is chosen", () => { + render(); + const county = screen.getByLabelText("Département") as HTMLSelectElement; + expect(county).toBeDisabled(); + expect(county).toHaveDisplayValue("Choisir une région d'abord"); + }); + + it("enables the county select when a region is selected", () => { + render(); + const region = screen.getByLabelText("Région") as HTMLSelectElement; + fireEvent.change(region, { target: { value: "11" } }); + const county = screen.getByLabelText("Département") as HTMLSelectElement; + expect(county).not.toBeDisabled(); + }); + + it("pushes a URL with the submitted filters on search", async () => { + render(); + fireEvent.input(screen.getByLabelText("Nom du référent"), { + target: { value: "Jean" }, + }); + fireEvent.change(screen.getByLabelText("Région"), { + target: { value: "11" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Rechercher" })); + + await waitFor(() => { + expect(routerPush).toHaveBeenCalledWith( + expect.stringMatching(/\/admin\/liste-referents\?.*query=Jean/), + ); + }); + expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/region=11/)); + expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=1/)); + }); + + it("resets and pushes the base URL on Réinitialiser", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Réinitialiser" })); + expect(routerPush).toHaveBeenCalledWith("/admin/liste-referents"); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/constants.test.ts b/packages/app/src/modules/admin/referents/__tests__/constants.test.ts new file mode 100644 index 000000000..dbd9e408a --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/constants.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { COLUMN_LABELS, REFERENT_TYPE_LABELS } from "../shared/constants"; + +describe("REFERENT_TYPE_LABELS", () => { + it("has labels for email and url", () => { + expect(REFERENT_TYPE_LABELS.email).toBe("Email"); + expect(REFERENT_TYPE_LABELS.url).toBe("URL"); + }); +}); + +describe("COLUMN_LABELS", () => { + it("has labels for all columns", () => { + expect(COLUMN_LABELS.region).toBe("Région"); + expect(COLUMN_LABELS.county).toBe("Département"); + expect(COLUMN_LABELS.name).toBe("Nom"); + expect(COLUMN_LABELS.value).toBe("Valeur"); + expect(COLUMN_LABELS.principal).toBe("Principal"); + expect(COLUMN_LABELS.createdAt).toBe("Date de création"); + }); +}); diff --git a/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts b/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts new file mode 100644 index 000000000..3a0eb455d --- /dev/null +++ b/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { + createReferentSchema, + deleteReferentsSchema, + editReferentSchema, + importReferentsSchema, + referentFormSchema, + searchReferentsSchema, +} from "../schemas"; + +describe("searchReferentsSchema", () => { + it("accepts minimal input with defaults", () => { + const result = searchReferentsSchema.parse({}); + expect(result).toEqual({ + page: 1, + pageSize: 20, + sortBy: "region", + sortOrder: "asc", + }); + }); + + it("parses all fields", () => { + const result = searchReferentsSchema.parse({ + query: "Dupont", + region: "11", + county: "75", + page: "2", + pageSize: "50", + sortBy: "name", + sortOrder: "desc", + }); + expect(result).toEqual({ + query: "Dupont", + region: "11", + county: "75", + page: 2, + pageSize: 50, + sortBy: "name", + sortOrder: "desc", + }); + }); + + it("accepts empty region/county strings", () => { + const result = searchReferentsSchema.parse({ + region: "", + county: "", + }); + expect(result.region).toBe(""); + expect(result.county).toBe(""); + }); + + it("rejects invalid region", () => { + expect(() => searchReferentsSchema.parse({ region: "ZZ" })).toThrow(); + }); + + it("rejects invalid sort column", () => { + expect(() => + searchReferentsSchema.parse({ sortBy: "nonexistent" }), + ).toThrow(); + }); +}); + +describe("createReferentSchema", () => { + it("validates an email referent", () => { + const result = createReferentSchema.parse({ + region: "11", + name: "Jean DUPONT", + type: "email", + value: "jean@gouv.fr", + principal: true, + }); + expect(result.type).toBe("email"); + expect(result.value).toBe("jean@gouv.fr"); + }); + + it("validates a URL referent", () => { + const result = createReferentSchema.parse({ + region: "84", + county: "69", + name: "DREETS ARA", + type: "url", + value: "https://dreets.gouv.fr", + }); + expect(result.type).toBe("url"); + }); + + it("rejects invalid email", () => { + expect(() => + createReferentSchema.parse({ + region: "11", + name: "Test", + type: "email", + value: "not-an-email", + }), + ).toThrow(); + }); + + it("rejects invalid URL", () => { + expect(() => + createReferentSchema.parse({ + region: "11", + name: "Test", + type: "url", + value: "not-a-url", + }), + ).toThrow(); + }); + + it("rejects empty name", () => { + expect(() => + createReferentSchema.parse({ + region: "11", + name: "", + type: "email", + value: "test@gouv.fr", + }), + ).toThrow(); + }); +}); + +describe("editReferentSchema", () => { + it("requires an id", () => { + const result = editReferentSchema.parse({ + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + region: "11", + name: "Jean DUPONT", + type: "email", + value: "jean@gouv.fr", + }); + expect(result.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + }); + + it("rejects missing id", () => { + expect(() => + editReferentSchema.parse({ + region: "11", + name: "Test", + type: "email", + value: "test@gouv.fr", + }), + ).toThrow(); + }); +}); + +describe("deleteReferentsSchema", () => { + it("accepts an array of UUIDs", () => { + const ids = [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "b2c3d4e5-f6a7-8901-bcde-f12345678901", + ]; + const result = deleteReferentsSchema.parse({ ids }); + expect(result.ids).toEqual(ids); + }); + + it("rejects empty array", () => { + expect(() => deleteReferentsSchema.parse({ ids: [] })).toThrow(); + }); +}); + +describe("importReferentsSchema", () => { + it("validates an array of referents", () => { + const result = importReferentsSchema.parse([ + { + region: "11", + name: "Jean DUPONT", + type: "email", + value: "jean@gouv.fr", + }, + { + region: "84", + name: "DREETS ARA", + type: "url", + value: "https://dreets.gouv.fr", + }, + ]); + expect(result).toHaveLength(2); + }); + + it("rejects empty array", () => { + expect(() => importReferentsSchema.parse([])).toThrow(); + }); +}); + +describe("referentFormSchema", () => { + it("validates form data", () => { + const result = referentFormSchema.parse({ + region: "11", + name: "Test", + type: "email", + value: "test@gouv.fr", + principal: false, + }); + expect(result.principal).toBe(false); + }); +}); diff --git a/packages/app/src/modules/admin/referents/index.ts b/packages/app/src/modules/admin/referents/index.ts new file mode 100644 index 000000000..438797cc1 --- /dev/null +++ b/packages/app/src/modules/admin/referents/index.ts @@ -0,0 +1,15 @@ +export { AdminReferentsPage } from "./AdminReferentsPage"; +export type { + CreateReferentInput, + EditReferentInput, + ImportReferentsInput, + SearchReferentsInput, + SearchReferentsOutput, +} from "./schemas"; +export { + createReferentSchema, + deleteReferentsSchema, + editReferentSchema, + importReferentsSchema, + searchReferentsSchema, +} from "./schemas"; diff --git a/packages/app/src/modules/admin/referents/schemas.ts b/packages/app/src/modules/admin/referents/schemas.ts new file mode 100644 index 000000000..607dd7ac0 --- /dev/null +++ b/packages/app/src/modules/admin/referents/schemas.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +import { COUNTY_CODES, REGION_CODES } from "~/modules/domain"; + +export const SORT_COLUMNS = [ + "region", + "county", + "name", + "value", + "principal", + "createdAt", +] as const; + +export type SortColumn = (typeof SORT_COLUMNS)[number]; + +export const DEFAULT_PAGE_SIZE = 20; + +export const searchReferentsSchema = z.object({ + query: z.string().optional(), + region: z.enum(REGION_CODES).optional().or(z.literal("")), + county: z.enum(COUNTY_CODES).optional().or(z.literal("")), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(10).max(100).default(DEFAULT_PAGE_SIZE), + sortBy: z.enum(SORT_COLUMNS).default("region"), + sortOrder: z.enum(["asc", "desc"]).default("asc"), +}); + +export type SearchReferentsInput = z.input; +export type SearchReferentsOutput = z.output; + +export const searchReferentsFormSchema = z.object({ + query: z.string().optional(), + region: z.string().optional(), + county: z.string().optional(), +}); + +export type SearchReferentsFormValues = z.infer< + typeof searchReferentsFormSchema +>; + +const baseReferentFields = { + region: z.enum(REGION_CODES), + county: z.enum(COUNTY_CODES).optional().or(z.literal("")), + name: z.string().min(1, "Le nom est obligatoire"), + principal: z.boolean().default(false), + substituteName: z.string().optional().or(z.literal("")), + substituteEmail: z + .string() + .email("L'email du suppléant est invalide") + .optional() + .or(z.literal("")), +}; + +export const createReferentSchema = z.discriminatedUnion("type", [ + z.object({ + ...baseReferentFields, + type: z.literal("email"), + value: z.string().email("L'email est invalide"), + }), + z.object({ + ...baseReferentFields, + type: z.literal("url"), + value: z.string().url("L'URL est invalide"), + }), +]); + +export type CreateReferentInput = z.infer; + +export const editReferentSchema = z.discriminatedUnion("type", [ + z.object({ + id: z.string().uuid(), + ...baseReferentFields, + type: z.literal("email"), + value: z.string().email("L'email est invalide"), + }), + z.object({ + id: z.string().uuid(), + ...baseReferentFields, + type: z.literal("url"), + value: z.string().url("L'URL est invalide"), + }), +]); + +export type EditReferentInput = z.infer; + +export const deleteReferentsSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(100), +}); + +export const importReferentsSchema = z.array(createReferentSchema).min(1); + +export type ImportReferentsInput = z.infer; + +export const referentFormSchema = z.object({ + region: z.enum(REGION_CODES), + county: z.enum(COUNTY_CODES).optional().or(z.literal("")), + name: z.string().min(1, "Le nom est obligatoire"), + principal: z.boolean(), + substituteName: z.string().optional().or(z.literal("")), + substituteEmail: z + .string() + .email("L'email du suppléant est invalide") + .optional() + .or(z.literal("")), + type: z.enum(["email", "url"]), + value: z.string().min(1, "La valeur est obligatoire"), +}); + +export type ReferentFormValues = z.infer; diff --git a/packages/app/src/modules/admin/referents/shared/constants.ts b/packages/app/src/modules/admin/referents/shared/constants.ts new file mode 100644 index 000000000..4aa709efc --- /dev/null +++ b/packages/app/src/modules/admin/referents/shared/constants.ts @@ -0,0 +1,13 @@ +export const REFERENT_TYPE_LABELS: Record = { + email: "Email", + url: "URL", +}; + +export const COLUMN_LABELS: Record = { + region: "Région", + county: "Département", + name: "Nom", + value: "Valeur", + principal: "Principal", + createdAt: "Date de création", +}; diff --git a/packages/app/src/modules/admin/referents/types.ts b/packages/app/src/modules/admin/referents/types.ts new file mode 100644 index 000000000..569f871e9 --- /dev/null +++ b/packages/app/src/modules/admin/referents/types.ts @@ -0,0 +1,4 @@ +import type { RouterOutputs } from "~/trpc/react"; + +export type ReferentSearchRow = + RouterOutputs["adminReferents"]["search"]["rows"][number]; diff --git a/packages/app/src/modules/domain/__tests__/regions.test.ts b/packages/app/src/modules/domain/__tests__/regions.test.ts new file mode 100644 index 000000000..8a0c1f50f --- /dev/null +++ b/packages/app/src/modules/domain/__tests__/regions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + COUNTIES, + COUNTY_CODES, + REGION_CODES, + REGIONS, + REGIONS_TO_COUNTIES, +} from "../shared/regions"; + +describe("REGIONS", () => { + it("has 18 regions", () => { + expect(Object.keys(REGIONS)).toHaveLength(18); + }); + + it("REGION_CODES matches REGIONS keys", () => { + expect(REGION_CODES).toEqual(Object.keys(REGIONS)); + }); + + it("includes Île-de-France", () => { + expect(REGIONS["11"]).toBe("Île-de-France"); + }); +}); + +describe("COUNTIES", () => { + it("has 101 counties", () => { + expect(Object.keys(COUNTIES)).toHaveLength(101); + }); + + it("COUNTY_CODES matches COUNTIES keys", () => { + expect(COUNTY_CODES).toEqual(Object.keys(COUNTIES)); + }); + + it("includes Paris", () => { + expect(COUNTIES["75"]).toBe("Paris"); + }); + + it("includes overseas departments", () => { + expect(COUNTIES["971"]).toBe("Guadeloupe"); + expect(COUNTIES["976"]).toBe("Mayotte"); + }); +}); + +describe("REGIONS_TO_COUNTIES", () => { + it("maps all 18 regions", () => { + expect(Object.keys(REGIONS_TO_COUNTIES)).toHaveLength(18); + }); + + it("Île-de-France contains Paris", () => { + expect(REGIONS_TO_COUNTIES["11"]).toContain("75"); + }); + + it("every county in the mapping exists in COUNTIES", () => { + for (const counties of Object.values(REGIONS_TO_COUNTIES)) { + for (const code of counties) { + expect(COUNTIES).toHaveProperty(code); + } + } + }); +}); diff --git a/packages/app/src/modules/domain/index.ts b/packages/app/src/modules/domain/index.ts index 1eaa2f566..e658b832f 100644 --- a/packages/app/src/modules/domain/index.ts +++ b/packages/app/src/modules/domain/index.ts @@ -53,6 +53,15 @@ export { normalizeDecimalInput, parseNumber, } from "./shared/number"; +export type { CountyCode, RegionCode } from "./shared/regions"; +// Regions & counties +export { + COUNTIES, + COUNTY_CODES, + REGION_CODES, + REGIONS, + REGIONS_TO_COUNTIES, +} from "./shared/regions"; // SIREN utilities export { extractSiren, formatSiren, parseSiren } from "./shared/siren"; export type { diff --git a/packages/app/src/modules/domain/shared/regions.ts b/packages/app/src/modules/domain/shared/regions.ts new file mode 100644 index 000000000..2a88ebcc4 --- /dev/null +++ b/packages/app/src/modules/domain/shared/regions.ts @@ -0,0 +1,199 @@ +export const REGIONS = { + "01": "Guadeloupe", + "02": "Martinique", + "03": "Guyane", + "04": "La Réunion", + "06": "Mayotte", + "11": "Île-de-France", + "24": "Centre-Val de Loire", + "27": "Bourgogne-Franche-Comté", + "28": "Normandie", + "32": "Hauts-de-France", + "44": "Grand-Est", + "52": "Pays de la Loire", + "53": "Bretagne", + "75": "Nouvelle-Aquitaine", + "76": "Occitanie", + "84": "Auvergne-Rhône-Alpes", + "93": "Provence-Alpes-Côte d'Azur", + "94": "Corse", +} as const; + +export type RegionCode = keyof typeof REGIONS; + +export const REGION_CODES = Object.keys(REGIONS) as [ + RegionCode, + ...RegionCode[], +]; + +export const COUNTIES = { + "01": "Ain", + "02": "Aisne", + "03": "Allier", + "04": "Alpes-de-Haute-Provence", + "05": "Hautes-Alpes", + "06": "Alpes-Maritimes", + "07": "Ardèche", + "08": "Ardennes", + "09": "Ariège", + "10": "Aube", + "11": "Aude", + "12": "Aveyron", + "13": "Bouches-du-Rhône", + "14": "Calvados", + "15": "Cantal", + "16": "Charente", + "17": "Charente-Maritime", + "18": "Cher", + "19": "Corrèze", + "2A": "Corse-du-Sud", + "2B": "Haute-Corse", + "21": "Côte-d'Or", + "22": "Côtes-d'Armor", + "23": "Creuse", + "24": "Dordogne", + "25": "Doubs", + "26": "Drôme", + "27": "Eure", + "28": "Eure-et-Loir", + "29": "Finistère", + "30": "Gard", + "31": "Haute-Garonne", + "32": "Gers", + "33": "Gironde", + "34": "Hérault", + "35": "Ille-et-Vilaine", + "36": "Indre", + "37": "Indre-et-Loire", + "38": "Isère", + "39": "Jura", + "40": "Landes", + "41": "Loir-et-Cher", + "42": "Loire", + "43": "Haute-Loire", + "44": "Loire-Atlantique", + "45": "Loiret", + "46": "Lot", + "47": "Lot-et-Garonne", + "48": "Lozère", + "49": "Maine-et-Loire", + "50": "Manche", + "51": "Marne", + "52": "Haute-Marne", + "53": "Mayenne", + "54": "Meurthe-et-Moselle", + "55": "Meuse", + "56": "Morbihan", + "57": "Moselle", + "58": "Nièvre", + "59": "Nord", + "60": "Oise", + "61": "Orne", + "62": "Pas-de-Calais", + "63": "Puy-de-Dôme", + "64": "Pyrénées-Atlantiques", + "65": "Hautes-Pyrénées", + "66": "Pyrénées-Orientales", + "67": "Bas-Rhin", + "68": "Haut-Rhin", + "69": "Rhône", + "70": "Haute-Saône", + "71": "Saône-et-Loire", + "72": "Sarthe", + "73": "Savoie", + "74": "Haute-Savoie", + "75": "Paris", + "76": "Seine-Maritime", + "77": "Seine-et-Marne", + "78": "Yvelines", + "79": "Deux-Sèvres", + "80": "Somme", + "81": "Tarn", + "82": "Tarn-et-Garonne", + "83": "Var", + "84": "Vaucluse", + "85": "Vendée", + "86": "Vienne", + "87": "Haute-Vienne", + "88": "Vosges", + "89": "Yonne", + "90": "Territoire de Belfort", + "91": "Essonne", + "92": "Hauts-de-Seine", + "93": "Seine-Saint-Denis", + "94": "Val-de-Marne", + "95": "Val-d'Oise", + "971": "Guadeloupe", + "972": "Martinique", + "973": "Guyane", + "974": "La Réunion", + "976": "Mayotte", +} as const; + +export type CountyCode = keyof typeof COUNTIES; + +export const COUNTY_CODES = Object.keys(COUNTIES) as [ + CountyCode, + ...CountyCode[], +]; + +export const REGIONS_TO_COUNTIES: Record = { + "84": [ + "01", + "03", + "07", + "15", + "26", + "38", + "42", + "43", + "63", + "69", + "73", + "74", + ], + "27": ["21", "25", "39", "58", "70", "71", "89", "90"], + "53": ["35", "22", "56", "29"], + "24": ["18", "28", "36", "37", "41", "45"], + "94": ["2A", "2B"], + "44": ["08", "10", "51", "52", "54", "55", "57", "67", "68", "88"], + "01": ["971"], + "03": ["973"], + "32": ["02", "59", "60", "62", "80"], + "11": ["75", "77", "78", "91", "92", "93", "94", "95"], + "04": ["974"], + "06": ["976"], + "02": ["972"], + "28": ["14", "27", "50", "61", "76"], + "75": [ + "16", + "17", + "19", + "23", + "24", + "33", + "40", + "47", + "64", + "79", + "86", + "87", + ], + "52": ["44", "49", "53", "72", "85"], + "93": ["04", "05", "06", "13", "83", "84"], + "76": [ + "09", + "11", + "12", + "30", + "31", + "32", + "34", + "46", + "48", + "65", + "66", + "81", + "82", + ], +}; diff --git a/packages/app/src/modules/my-space/DeclarationsSection.tsx b/packages/app/src/modules/my-space/DeclarationsSection.tsx index d038950b0..a693196aa 100644 --- a/packages/app/src/modules/my-space/DeclarationsSection.tsx +++ b/packages/app/src/modules/my-space/DeclarationsSection.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { useState } from "react"; -import { formatShortDate, getCurrentYear } from "~/modules/domain"; +import { getCurrentYear } from "~/modules/domain"; import { Pagination } from "~/modules/shared/Pagination"; diff --git a/packages/app/src/modules/shared/DsfrTable.tsx b/packages/app/src/modules/shared/DsfrTable.tsx new file mode 100644 index 000000000..02e246b07 --- /dev/null +++ b/packages/app/src/modules/shared/DsfrTable.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +type Props = { + caption: string; + children: ReactNode; + className?: string; +}; + +export function DsfrTable({ + caption, + children, + className = "fr-mb-4w", +}: Props) { + return ( +
+
+
+
+ + + {children} +
{caption}
+
+
+
+
+ ); +} diff --git a/packages/app/src/modules/shared/index.ts b/packages/app/src/modules/shared/index.ts index 34fb5c6d6..9bed90cd4 100644 --- a/packages/app/src/modules/shared/index.ts +++ b/packages/app/src/modules/shared/index.ts @@ -10,4 +10,6 @@ export { SCAN_TIMEOUT_MS, } from "./uploadConfig"; export { uploadFile } from "./uploadFile"; +export { useDsfrModal } from "./useDsfrModal"; export { useFileUploadForm } from "./useFileUploadForm"; +export { useZodForm } from "./useZodForm"; diff --git a/packages/app/src/modules/shared/useDsfrModal.ts b/packages/app/src/modules/shared/useDsfrModal.ts new file mode 100644 index 000000000..1850c9231 --- /dev/null +++ b/packages/app/src/modules/shared/useDsfrModal.ts @@ -0,0 +1,21 @@ +import { useCallback, useRef } from "react"; + +import { getDsfrModal } from "./getDsfrModal"; + +export function useDsfrModal() { + const modalRef = useRef(null); + + const open = useCallback(() => { + if (modalRef.current) { + getDsfrModal(modalRef.current)?.disclose(); + } + }, []); + + const close = useCallback(() => { + if (modalRef.current) { + getDsfrModal(modalRef.current)?.conceal(); + } + }, []); + + return { modalRef, open, close }; +} diff --git a/packages/app/src/server/api/root.ts b/packages/app/src/server/api/root.ts index ca0a885d0..69e1edb15 100644 --- a/packages/app/src/server/api/root.ts +++ b/packages/app/src/server/api/root.ts @@ -1,5 +1,6 @@ import { adminRouter } from "~/server/api/routers/admin"; import { adminDeclarationsRouter } from "~/server/api/routers/adminDeclarations"; +import { adminReferentsRouter } from "~/server/api/routers/adminReferents"; import { companyRouter } from "~/server/api/routers/company"; import { cseOpinionRouter } from "~/server/api/routers/cseOpinion"; import { declarationRouter } from "~/server/api/routers/declaration"; @@ -16,6 +17,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ admin: adminRouter, adminDeclarations: adminDeclarationsRouter, + adminReferents: adminReferentsRouter, company: companyRouter, cseOpinion: cseOpinionRouter, declaration: declarationRouter, diff --git a/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts b/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts new file mode 100644 index 000000000..062ebad02 --- /dev/null +++ b/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/server/auth", () => ({ auth: vi.fn() })); +vi.mock("~/server/db", () => ({ db: {} })); +vi.mock("~/server/db/schema", () => ({ + referents: { + id: "id", + region: "region", + county: "county", + name: "name", + type: "type", + value: "value", + principal: "principal", + substituteName: "substituteName", + substituteEmail: "substituteEmail", + createdAt: "createdAt", + }, +})); + +const mockInsertValues = vi.fn(); +const mockUpdateSet = vi.fn(); +const mockDeleteCalled = vi.fn(); +const mockTransaction = vi.fn(); + +type SelectQueue = unknown[]; +let selectQueue: SelectQueue = []; + +const CHAIN_METHODS = new Set(["from", "where", "orderBy", "limit", "offset"]); + +function wrapChain(results: unknown): unknown { + const promise = Promise.resolve(results); + const chain = new Proxy( + {}, + { + get(_target, prop) { + if (prop === "then" || prop === "catch" || prop === "finally") { + return (promise[prop] as (...args: unknown[]) => unknown).bind( + promise, + ); + } + if (typeof prop === "string" && CHAIN_METHODS.has(prop)) { + return () => chain; + } + return undefined; + }, + }, + ); + return chain; +} + +function makeChain(): unknown { + return wrapChain(selectQueue.shift() ?? []); +} + +function createMockDb() { + const db = { + select: vi.fn(() => makeChain()), + insert: vi.fn(() => ({ + values: (v: unknown) => { + mockInsertValues(v); + return { + returning: () => Promise.resolve([{ id: "new-id" }]), + }; + }, + })), + update: vi.fn(() => ({ + set: (v: unknown) => { + mockUpdateSet(v); + return { where: () => Promise.resolve(undefined) }; + }, + })), + delete: vi.fn(() => { + mockDeleteCalled(); + return { where: () => Promise.resolve(undefined) }; + }), + transaction: mockTransaction.mockImplementation(async (fn) => fn(db)), + }; + return db; +} + +async function createCaller(mockDb: unknown, isAdmin = true) { + const { adminReferentsRouter } = await import("../adminReferents"); + return adminReferentsRouter.createCaller({ + db: mockDb, + session: { + user: { id: "admin-1", email: "admin@gov.fr", isAdmin }, + expires: "", + }, + headers: new Headers(), + } as never); +} + +describe("adminReferentsRouter", () => { + beforeEach(() => { + vi.resetAllMocks(); + selectQueue = []; + }); + + describe("authorization", () => { + it("rejects non-admin users", async () => { + const caller = await createCaller(createMockDb(), false); + await expect( + caller.search({ + page: 1, + pageSize: 20, + sortBy: "region", + sortOrder: "asc", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + }); + + describe("search", () => { + it("returns rows and pagination metadata", async () => { + const rows = [ + { + id: "r-1", + region: "11", + county: "75", + name: "Jean", + type: "email", + value: "j@gouv.fr", + principal: true, + substituteName: null, + substituteEmail: null, + createdAt: new Date(), + }, + ]; + selectQueue = [rows, [{ total: 1 }]]; + const caller = await createCaller(createMockDb()); + + const result = await caller.search({ + page: 1, + pageSize: 20, + sortBy: "region", + sortOrder: "asc", + }); + + expect(result).toMatchObject({ + rows, + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }); + }); + + it("applies filters on query, region and county", async () => { + selectQueue = [[], [{ total: 0 }]]; + const caller = await createCaller(createMockDb()); + + const result = await caller.search({ + query: "Jean", + region: "11", + county: "75", + page: 2, + pageSize: 10, + sortBy: "name", + sortOrder: "desc", + }); + + expect(result.rows).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(1); + }); + }); + + describe("create", () => { + it("inserts a referent and strips empty optional strings", async () => { + const caller = await createCaller(createMockDb()); + + const result = await caller.create({ + region: "11", + county: "75", + name: "Jean", + type: "email", + value: "jean@gouv.fr", + principal: true, + substituteName: " ", + substituteEmail: "", + }); + + expect(result).toEqual({ id: "new-id" }); + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + region: "11", + county: "75", + name: "Jean", + substituteName: undefined, + substituteEmail: undefined, + }), + ); + }); + }); + + describe("update", () => { + it("updates a referent and returns its id", async () => { + const caller = await createCaller(createMockDb()); + + const result = await caller.update({ + id: "11111111-1111-4111-8111-111111111111", + region: "11", + county: "", + name: "Jean", + type: "url", + value: "https://gouv.fr/contact", + principal: false, + substituteName: "", + substituteEmail: "", + }); + + expect(result).toEqual({ id: "11111111-1111-4111-8111-111111111111" }); + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ county: undefined }), + ); + }); + }); + + describe("delete", () => { + it("deletes the given ids", async () => { + const caller = await createCaller(createMockDb()); + + const result = await caller.delete({ + ids: [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ], + }); + + expect(result).toEqual({ deleted: 2 }); + expect(mockDeleteCalled).toHaveBeenCalled(); + }); + }); + + describe("import", () => { + it("clears and re-inserts all referents in a transaction", async () => { + const caller = await createCaller(createMockDb()); + + const result = await caller.import([ + { + region: "11", + county: "75", + name: "Jean", + type: "email", + value: "jean@gouv.fr", + principal: true, + substituteName: "", + substituteEmail: "", + }, + { + region: "11", + county: "", + name: "Marie", + type: "url", + value: "https://gouv.fr", + principal: false, + substituteName: "", + substituteEmail: "", + }, + ]); + + expect(result).toEqual({ imported: 2 }); + expect(mockTransaction).toHaveBeenCalled(); + expect(mockDeleteCalled).toHaveBeenCalled(); + expect(mockInsertValues).toHaveBeenCalledTimes(2); + }); + }); + + describe("exportAll", () => { + it("returns all referents ordered by region then county", async () => { + const rows = [ + { + region: "11", + county: "75", + name: "Jean", + type: "email" as const, + value: "j@gouv.fr", + principal: true, + substituteName: null, + substituteEmail: null, + }, + ]; + selectQueue = [rows]; + const caller = await createCaller(createMockDb()); + + const result = await caller.exportAll(); + + expect(result).toEqual(rows); + }); + }); +}); diff --git a/packages/app/src/server/api/routers/adminReferents.ts b/packages/app/src/server/api/routers/adminReferents.ts new file mode 100644 index 000000000..ad9b85ff4 --- /dev/null +++ b/packages/app/src/server/api/routers/adminReferents.ts @@ -0,0 +1,180 @@ +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + type SQL, +} from "drizzle-orm"; + +import { + createReferentSchema, + deleteReferentsSchema, + editReferentSchema, + importReferentsSchema, + searchReferentsSchema, +} from "~/modules/admin/referents/schemas"; +import { adminProcedure, createTRPCRouter } from "~/server/api/trpc"; +import { referents } from "~/server/db/schema"; + +const sortColumnMap = { + region: referents.region, + county: referents.county, + name: referents.name, + value: referents.value, + principal: referents.principal, + createdAt: referents.createdAt, +} as const; + +function cleanOptionalString(val: string | undefined): string | undefined { + return val && val.trim() !== "" ? val.trim() : undefined; +} + +export const adminReferentsRouter = createTRPCRouter({ + search: adminProcedure + .input(searchReferentsSchema) + .query(async ({ ctx, input }) => { + const filters: SQL[] = []; + + if (input.query) { + const term = `%${input.query}%`; + filters.push(ilike(referents.name, term)); + } + + if (input.region) { + filters.push(eq(referents.region, input.region)); + } + + if (input.county) { + filters.push(eq(referents.county, input.county)); + } + + const where = filters.length > 0 ? and(...filters) : undefined; + const orderDir = input.sortOrder === "asc" ? asc : desc; + const orderColumn = + sortColumnMap[input.sortBy as keyof typeof sortColumnMap]; + const offset = (input.page - 1) * input.pageSize; + + const [rows, totalResult] = await Promise.all([ + ctx.db + .select({ + id: referents.id, + region: referents.region, + county: referents.county, + name: referents.name, + type: referents.type, + value: referents.value, + principal: referents.principal, + substituteName: referents.substituteName, + substituteEmail: referents.substituteEmail, + createdAt: referents.createdAt, + }) + .from(referents) + .where(where) + .orderBy(orderDir(orderColumn)) + .limit(input.pageSize) + .offset(offset), + ctx.db.select({ total: count() }).from(referents).where(where), + ]); + + const total = totalResult[0]?.total ?? 0; + + return { + rows, + total, + page: input.page, + pageSize: input.pageSize, + totalPages: Math.max(1, Math.ceil(total / input.pageSize)), + }; + }), + + create: adminProcedure + .input(createReferentSchema) + .mutation(async ({ ctx, input }) => { + const [created] = await ctx.db + .insert(referents) + .values({ + region: input.region, + county: cleanOptionalString(input.county), + name: input.name, + type: input.type, + value: input.value, + principal: input.principal, + substituteName: cleanOptionalString(input.substituteName), + substituteEmail: cleanOptionalString(input.substituteEmail), + }) + .returning({ id: referents.id }); + + return { id: created?.id }; + }), + + update: adminProcedure + .input(editReferentSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(referents) + .set({ + region: input.region, + county: cleanOptionalString(input.county), + name: input.name, + type: input.type, + value: input.value, + principal: input.principal, + substituteName: cleanOptionalString(input.substituteName), + substituteEmail: cleanOptionalString(input.substituteEmail), + updatedAt: new Date(), + }) + .where(eq(referents.id, input.id)); + + return { id: input.id }; + }), + + delete: adminProcedure + .input(deleteReferentsSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(referents).where(inArray(referents.id, input.ids)); + + return { deleted: input.ids.length }; + }), + + import: adminProcedure + .input(importReferentsSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + await tx.delete(referents); + + for (const item of input) { + await tx.insert(referents).values({ + region: item.region, + county: cleanOptionalString(item.county), + name: item.name, + type: item.type, + value: item.value, + principal: item.principal, + substituteName: cleanOptionalString(item.substituteName), + substituteEmail: cleanOptionalString(item.substituteEmail), + }); + } + }); + + return { imported: input.length }; + }), + + exportAll: adminProcedure.query(async ({ ctx }) => { + return ctx.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)); + }), +}); diff --git a/packages/app/src/server/db/schema.ts b/packages/app/src/server/db/schema.ts index 915aded9c..6f87c430c 100644 --- a/packages/app/src/server/db/schema.ts +++ b/packages/app/src/server/db/schema.ts @@ -528,6 +528,32 @@ export const adminImpersonationEvents = createTable( ], ); +// ── Referent tables ─────────────────────────────────────────────── + +export const referentTypeEnum = pgEnum("referent_type", ["email", "url"]); + +export const referents = createTable( + "referent", + (d) => ({ + id: d + .varchar({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + region: d.varchar({ length: 3 }).notNull(), + county: d.varchar({ length: 3 }), + name: d.varchar({ length: 255 }).notNull(), + type: referentTypeEnum().notNull(), + value: d.varchar({ length: 500 }).notNull(), + principal: d.boolean().notNull().default(false), + substituteName: d.varchar({ length: 255 }), + substituteEmail: d.varchar({ length: 255 }), + createdAt: d.timestamp({ withTimezone: true }).$defaultFn(() => new Date()), + updatedAt: d.timestamp({ withTimezone: true }).$defaultFn(() => new Date()), + }), + (t) => [index("referent_region_idx").on(t.region)], +); + // ── Export tables ─────────────────────────────────────────────────── export const exports = createTable( diff --git a/packages/app/src/server/services/clamav.ts b/packages/app/src/server/services/clamav.ts index 7118857f8..9ef40b6b6 100644 --- a/packages/app/src/server/services/clamav.ts +++ b/packages/app/src/server/services/clamav.ts @@ -4,7 +4,7 @@ import net from "node:net"; import { env } from "~/env"; -import { SCAN_TIMEOUT_MS } from "~/modules/shared"; +import { SCAN_TIMEOUT_MS } from "~/modules/shared/uploadConfig"; export type ScanResult = { clean: true } | { clean: false; virus: string }; diff --git a/packages/app/src/server/services/fileUpload.ts b/packages/app/src/server/services/fileUpload.ts index 7ce721e3c..db653653c 100644 --- a/packages/app/src/server/services/fileUpload.ts +++ b/packages/app/src/server/services/fileUpload.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { env } from "~/env"; -import { FILE_TOO_LARGE_ERROR, MAX_FILE_SIZE } from "~/modules/shared"; +import { + FILE_TOO_LARGE_ERROR, + MAX_FILE_SIZE, +} from "~/modules/shared/uploadConfig"; import { createClamdStream, type ScanResult } from "./clamav"; import { MIN_SIGNATURE_BYTES, validateFileSignature } from "./fileValidation"; diff --git a/packages/app/src/server/services/s3.ts b/packages/app/src/server/services/s3.ts index 0b2abec7f..2591a7882 100644 --- a/packages/app/src/server/services/s3.ts +++ b/packages/app/src/server/services/s3.ts @@ -112,7 +112,7 @@ export async function deleteFile(key: string): Promise { ); } -import { S3_PART_MIN_SIZE } from "~/modules/shared"; +import { S3_PART_MIN_SIZE } from "~/modules/shared/uploadConfig"; /** * Creates an incremental multipart upload to S3.