Skip to content

Commit 3dc83f1

Browse files
committed
Merge remote-tracking branch 'origin/alpha' into feat/issue-3177-maildev-setup
2 parents 1a54b72 + 0a889e8 commit 3dc83f1

42 files changed

Lines changed: 3256 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ S3_BUCKET_NAME="egapro-dev-app"
2323
CLAMAV_HOST="localhost"
2424
CLAMAV_PORT="3310"
2525
ADMIN_EMAILS="test@fia1.fr"
26+
EGAPRO_AUDIT_CLEANUP_TOKEN="change-me-to-a-32-chars-minimum-token"
2627
MAIL_ENABLED="true"
2728
SMTP_HOST="localhost"
2829
SMTP_PORT="1025"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Create referent type enum and referents table for admin referent management (issue #3182)
2+
DO $$ BEGIN
3+
CREATE TYPE "public"."referent_type" AS ENUM('email', 'url');
4+
EXCEPTION
5+
WHEN duplicate_object THEN null;
6+
END $$;
7+
8+
CREATE TABLE IF NOT EXISTS "app_referent" (
9+
"id" varchar(255) PRIMARY KEY NOT NULL,
10+
"region" varchar(3) NOT NULL,
11+
"county" varchar(3),
12+
"name" varchar(255) NOT NULL,
13+
"type" "referent_type" NOT NULL,
14+
"value" varchar(500) NOT NULL,
15+
"principal" boolean DEFAULT false NOT NULL,
16+
"substitute_name" varchar(255),
17+
"substitute_email" varchar(255),
18+
"created_at" timestamp with time zone,
19+
"updated_at" timestamp with time zone
20+
);
21+
22+
CREATE INDEX IF NOT EXISTS "referent_region_idx" ON "app_referent" USING btree ("region");

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@
183183
"when": 1775700000000,
184184
"tag": "0025_add_user_is_admin",
185185
"breakpoints": true
186+
},
187+
{
188+
"idx": 26,
189+
"version": "7",
190+
"when": 1775800000000,
191+
"tag": "0026_add_referents_table",
192+
"breakpoints": true
186193
}
187194
]
188195
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { AdminReferentsPage } from "~/modules/admin";
2+
3+
export default function Page() {
4+
return <AdminReferentsPage />;
5+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
dbSelect: vi.fn(),
5+
}));
6+
7+
vi.mock("~/server/db", () => ({
8+
db: { select: mocks.dbSelect },
9+
}));
10+
11+
vi.mock("~/server/db/schema", () => ({
12+
referents: {
13+
region: "region",
14+
county: "county",
15+
name: "name",
16+
type: "type",
17+
value: "value",
18+
principal: "principal",
19+
substituteName: "substituteName",
20+
substituteEmail: "substituteEmail",
21+
},
22+
}));
23+
24+
vi.mock("drizzle-orm", () => ({
25+
asc: (col: unknown) => ({ asc: col }),
26+
}));
27+
28+
function setRows(rows: unknown[]) {
29+
mocks.dbSelect.mockReturnValue({
30+
from: () => ({
31+
orderBy: () => Promise.resolve(rows),
32+
}),
33+
});
34+
}
35+
36+
describe("/api/public/referents-egalite-professionnelle", () => {
37+
beforeEach(() => {
38+
vi.resetAllMocks();
39+
});
40+
41+
it("returns JSON by default", async () => {
42+
setRows([
43+
{
44+
region: "11",
45+
county: "75",
46+
name: "Jean",
47+
type: "email",
48+
value: "j@gouv.fr",
49+
principal: true,
50+
substituteName: null,
51+
substituteEmail: null,
52+
},
53+
]);
54+
55+
const { GET } = await import("../route");
56+
const response = await GET(
57+
new Request(
58+
"http://localhost/api/public/referents-egalite-professionnelle",
59+
),
60+
);
61+
62+
expect(response.headers.get("Content-Type")).toMatch(/application\/json/);
63+
const body = await response.json();
64+
expect(body).toHaveLength(1);
65+
expect(body[0]).toMatchObject({ name: "Jean", region: "11" });
66+
});
67+
68+
it("returns CSV with headers and region/county labels when format=csv", async () => {
69+
setRows([
70+
{
71+
region: "11",
72+
county: "75",
73+
name: "Jean",
74+
type: "email",
75+
value: 'j"@gouv.fr',
76+
principal: true,
77+
substituteName: "Marie",
78+
substituteEmail: "m@gouv.fr",
79+
},
80+
{
81+
region: "11",
82+
county: null,
83+
name: "Sans département",
84+
type: "url",
85+
value: "https://gouv.fr",
86+
principal: false,
87+
substituteName: null,
88+
substituteEmail: null,
89+
},
90+
]);
91+
92+
const { GET } = await import("../route");
93+
const response = await GET(
94+
new Request(
95+
"http://localhost/api/public/referents-egalite-professionnelle?format=csv",
96+
),
97+
);
98+
99+
expect(response.headers.get("Content-Type")).toContain("text/csv");
100+
expect(response.headers.get("Content-Disposition")).toContain(
101+
"referents_egalite_professionnelle.csv",
102+
);
103+
104+
const csv = await response.text();
105+
const lines = csv.split("\n");
106+
expect(lines[0]).toBe(
107+
"Région;Département;Nom;Type;Valeur;Principal;Nom suppléant;Email suppléant",
108+
);
109+
expect(lines[1]).toContain('"Jean"');
110+
expect(lines[1]).toContain('"j""@gouv.fr"');
111+
expect(lines[1]).toContain('"Oui"');
112+
expect(lines[2]).toContain('"Sans département"');
113+
expect(lines[2]).toContain('"Non"');
114+
expect(lines[2]).toContain('""');
115+
});
116+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { asc } from "drizzle-orm";
2+
import { NextResponse } from "next/server";
3+
import type { CountyCode, RegionCode } from "~/modules/domain";
4+
import { COUNTIES, REGIONS } from "~/modules/domain";
5+
import { db } from "~/server/db";
6+
import { referents } from "~/server/db/schema";
7+
8+
async function getAllReferents() {
9+
return db
10+
.select({
11+
region: referents.region,
12+
county: referents.county,
13+
name: referents.name,
14+
type: referents.type,
15+
value: referents.value,
16+
principal: referents.principal,
17+
substituteName: referents.substituteName,
18+
substituteEmail: referents.substituteEmail,
19+
})
20+
.from(referents)
21+
.orderBy(asc(referents.region), asc(referents.county));
22+
}
23+
24+
function formatCsv(rows: Awaited<ReturnType<typeof getAllReferents>>): string {
25+
const headers = [
26+
"Région",
27+
"Département",
28+
"Nom",
29+
"Type",
30+
"Valeur",
31+
"Principal",
32+
"Nom suppléant",
33+
"Email suppléant",
34+
];
35+
36+
const csvRows = [
37+
headers.join(";"),
38+
...rows.map((r) =>
39+
[
40+
REGIONS[r.region as RegionCode] ?? r.region,
41+
r.county ? (COUNTIES[r.county as CountyCode] ?? r.county) : "",
42+
r.name,
43+
r.type,
44+
r.value,
45+
r.principal ? "Oui" : "Non",
46+
r.substituteName ?? "",
47+
r.substituteEmail ?? "",
48+
]
49+
.map((val) => `"${String(val).replace(/"/g, '""')}"`)
50+
.join(";"),
51+
),
52+
];
53+
54+
return csvRows.join("\n");
55+
}
56+
57+
export async function GET(request: Request) {
58+
const { searchParams } = new URL(request.url);
59+
const format = searchParams.get("format") ?? "json";
60+
61+
const rows = await getAllReferents();
62+
63+
if (format === "csv") {
64+
const csv = formatCsv(rows);
65+
return new NextResponse(csv, {
66+
headers: {
67+
"Content-Type": "text/csv; charset=utf-8",
68+
"Content-Disposition":
69+
'attachment; filename="referents_egalite_professionnelle.csv"',
70+
},
71+
});
72+
}
73+
74+
return NextResponse.json(rows);
75+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("admin user can access /admin/liste-referents", async ({ page }) => {
4+
await page.goto("/admin/liste-referents");
5+
await expect(
6+
page.getByRole("heading", { name: "Liste des référents Egapro", level: 1 }),
7+
).toBeVisible();
8+
});
9+
10+
test("unauthenticated user visiting /admin/liste-referents is redirected to /login", async ({
11+
browser,
12+
}) => {
13+
const anonCtx = await browser.newContext({ storageState: undefined });
14+
try {
15+
const page = await anonCtx.newPage();
16+
await page.goto("/admin/liste-referents");
17+
await page.waitForURL("**/login**");
18+
expect(page.url()).toContain("/login");
19+
} finally {
20+
await anonCtx.close();
21+
}
22+
});

packages/app/src/modules/admin/AdminNavigation.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
66
const adminLinks = [
77
{ href: "/admin", label: "Accueil" },
88
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
9+
{ href: "/admin/liste-referents", label: "Référents" },
910
] as const;
1011

1112
export function AdminNavigation() {

packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe("AdminNavigation", () => {
2222
expect(
2323
screen.getByRole("link", { name: "Mimoquer un Siren" }),
2424
).toBeInTheDocument();
25+
expect(screen.getByRole("link", { name: "Référents" })).toBeInTheDocument();
2526
});
2627

2728
it("marks /admin as active when on /admin", () => {
@@ -48,4 +49,16 @@ describe("AdminNavigation", () => {
4849
"aria-current",
4950
);
5051
});
52+
53+
it("marks /admin/liste-referents as active when on that page", () => {
54+
(usePathname as Mock).mockReturnValue("/admin/liste-referents");
55+
render(<AdminNavigation />);
56+
expect(screen.getByRole("link", { name: "Référents" })).toHaveAttribute(
57+
"aria-current",
58+
"page",
59+
);
60+
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
61+
"aria-current",
62+
);
63+
});
5164
});

packages/app/src/modules/admin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
AdminDeclarationsPage,
66
} from "./declarations";
77
export { ImpersonatePage } from "./impersonate/ImpersonatePage";
8+
export { AdminReferentsPage } from "./referents";
89
export {
910
type ImpersonateSearchInput,
1011
impersonateSearchSchema,

0 commit comments

Comments
 (0)