From 0c8cda7f3e009ffba0b77d5e686db255f44e07c1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 8 Sep 2026 08:34:36 +0200 Subject: [PATCH] feat(admin): consultation des messages de contact (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2/2 de l'issue #80 — la vue admin en lecture seule des messages remontés via le formulaire de contact (PR 1 = #123). - Contrat partagé : `listContactMessagesQuerySchema` (filtre `type` optionnel) + `types/contact.ts` `ContactMessageAdminView` (`author` = `{ id, firstName, lastName, email } | null`, `null` = compte supprimé). - API : `admin-contact.{routes,service}.ts` — `GET /admin/api/contact?type=` (`requireAdmin`), tri `createdAt desc`, plafond `CONTACT_MESSAGES_PAGE_SIZE = 200` (même esprit que `CORRECTIONS_PAGE_SIZE`). Branché dans `admin.routes.ts` (`adminRouter.use(/contact, …)`). Aucune route d'écriture. - Front : page `/admin/messages` (`MessagesPage` + `.scss`) — tableau lecture seule (date, expéditeur ou « Compte supprimé », badge de type, message, page, user-agent tronqué + titre au survol), un filtre par type. Entrée de nav « Messages » (icône `Inbox`) dans `AdminLayout` + `adminApiClient.getContactMessages` + i18n `admin.nav.messages` / `admin.messages.*`. - Tests : `apps/api/test/admin-contact.test.ts` (Mocha — 401 sans session admin, tri récent d'abord + `author` résolu, filtre `type`, type inconnu → 400, plafond 200, `author: null` après suppression du compte), `apps/web/cypress/e2e/admin-messages.cy.ts` (rendu lecture seule, filtre par type via le query param). - Docs : `backend-architecture.md` (section `admin` + event `contact.submitted` dans la liste). Closes #80. Co-Authored-By: Claude Sonnet 5 --- .../src/modules/admin/admin-contact.routes.ts | 23 +++ .../modules/admin/admin-contact.service.ts | 62 ++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/test/admin-contact.test.ts | 180 ++++++++++++++++++ apps/web/cypress/e2e/admin-messages.cy.ts | 74 +++++++ apps/web/src/App.tsx | 2 + apps/web/src/api/admin-client.ts | 10 + apps/web/src/layouts/AdminLayout.tsx | 10 +- apps/web/src/locales/fr/translation.json | 26 ++- .../src/pages/admin/messages/MessagesPage.tsx | 118 ++++++++++++ .../pages/admin/messages/messages-page.scss | 110 +++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/contact.ts | 9 + packages/shared/src/types/contact.ts | 25 +++ specs/backend-architecture.md | 19 +- 15 files changed, 667 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/admin/admin-contact.routes.ts create mode 100644 apps/api/src/modules/admin/admin-contact.service.ts create mode 100644 apps/api/test/admin-contact.test.ts create mode 100644 apps/web/cypress/e2e/admin-messages.cy.ts create mode 100644 apps/web/src/pages/admin/messages/MessagesPage.tsx create mode 100644 apps/web/src/pages/admin/messages/messages-page.scss create mode 100644 packages/shared/src/types/contact.ts diff --git a/apps/api/src/modules/admin/admin-contact.routes.ts b/apps/api/src/modules/admin/admin-contact.routes.ts new file mode 100644 index 0000000..05ae2a6 --- /dev/null +++ b/apps/api/src/modules/admin/admin-contact.routes.ts @@ -0,0 +1,23 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { listContactMessagesQuerySchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { requireAdmin } from "../../middlewares/require-admin.js"; +import { listContactMessages } from "./admin-contact.service.js"; + +/** + * Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind + * {@link requireAdmin}. Backs the `/admin/messages` page (issue #80): a + * read-only list of the contact-form messages users have sent. No write + * routes — the first version has no triage workflow. + */ +export const adminContactRouter = Router(); + +adminContactRouter.get( + "/", + requireAdmin, + wrapAsyncHandler(async (req, res) => { + res + .status(200) + .json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query))); + }), +); diff --git a/apps/api/src/modules/admin/admin-contact.service.ts b/apps/api/src/modules/admin/admin-contact.service.ts new file mode 100644 index 0000000..14cc449 --- /dev/null +++ b/apps/api/src/modules/admin/admin-contact.service.ts @@ -0,0 +1,62 @@ +import type { + ContactMessageAdminView, + ContactMessageType, + ListContactMessagesQuery, +} from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */ +const CONTACT_MESSAGES_PAGE_SIZE = 200; + +const contactMessageInclude = { + author: { select: { id: true, firstName: true, lastName: true, email: true } }, +} as const; + +type ContactMessageRow = Awaited< + ReturnType< + typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }> + > +>; + +/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */ +function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView { + return { + id: row.id, + // Safe cast: `POST /contact` validates `type` against + // `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds + // one of those values. + type: row.type as ContactMessageType, + message: row.message, + pageUrl: row.pageUrl, + userAgent: row.userAgent, + appVersion: row.appVersion, + createdAt: row.createdAt.toISOString(), + // `null` when the sender's account was deleted after sending + // (`authorId` is `onDelete: SetNull`). + author: row.author, + }; +} + +/** + * Every `ContactMessage` for the admin read view (`/admin/messages`, issue + * #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an + * optional single-`type` filter. Read-only: this first version has no + * triage workflow (status / assignment / notes), so there's no update + * counterpart. + */ +export async function listContactMessages( + query: ListContactMessagesQuery, +): Promise { + try { + const rows = await prisma.contactMessage.findMany({ + where: query.type ? { type: query.type } : {}, + orderBy: { createdAt: "desc" }, + take: CONTACT_MESSAGES_PAGE_SIZE, + include: contactMessageInclude, + }); + return rows.map(toContactMessageAdminView); + } catch (err) { + // Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch. + throw err; + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index e85d096..adaea93 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminCatalogRouter } from "./admin-catalog.routes.js"; +import { adminContactRouter } from "./admin-contact.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminToursRouter } from "./admin-tours.routes.js"; @@ -26,3 +27,4 @@ adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/tours", adminToursRouter); +adminRouter.use("/contact", adminContactRouter); diff --git a/apps/api/test/admin-contact.test.ts b/apps/api/test/admin-contact.test.ts new file mode 100644 index 0000000..b3ec8ab --- /dev/null +++ b/apps/api/test/admin-contact.test.ts @@ -0,0 +1,180 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +async function seedAdmin(): Promise<{ email: string; password: string }> { + const email = faker.internet.email().toLowerCase(); + const password = faker.internet.password({ length: 16 }); + await prisma.adminUser.create({ + data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, + }); + return { email, password }; +} + +/** Creates a `UserProfile` to attribute contact messages to. */ +async function seedUser() { + return prisma.userProfile.create({ + data: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: `${faker.string.uuid()}@example.test`, + passwordHash: "x", + }, + }); +} + +/** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */ +async function seedMessage(overrides: { + authorId: number | null; + type?: string; + message?: string; + createdAt?: Date; +}) { + return prisma.contactMessage.create({ + data: { + authorId: overrides.authorId, + type: overrides.type ?? "bug", + message: overrides.message ?? faker.lorem.sentence(), + pageUrl: "/", + userAgent: "test-agent/1.0", + appVersion: "0.2.1", + ...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}), + }, + }); +} + +const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; + +describe("Admin contact messages", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + async function adminAgent() { + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/api/auth/login").send({ email, password }); + return agent; + } + + it("rejects the route without an admin session", async () => { + const res = await request(app).get("/admin/api/contact"); + expect(res.status).to.equal(401); + }); + + it("returns messages newest first, with the author resolved", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + await seedMessage({ + authorId: user.id, + message: "le plus ancien", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + await seedMessage({ + authorId: user.id, + message: "le plus récent", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + }); + + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact"); + + expect(res.status).to.equal(200); + expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([ + "le plus récent", + "le plus ancien", + ]); + expect(res.body[0].author).to.deep.equal({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }); + }); + + it("filters by type", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + await seedMessage({ authorId: user.id, type: "bug" }); + await seedMessage({ authorId: user.id, type: "suggestion" }); + await seedMessage({ authorId: user.id, type: "suggestion" }); + + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact").query({ type: "suggestion" }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.lengthOf(2); + expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true); + }); + + it("rejects an unknown type filter with 400", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact").query({ type: "complaint" }); + expect(res.status).to.equal(400); + }); + + it("caps the page at 200 rows", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + await prisma.contactMessage.createMany({ + data: Array.from({ length: 205 }, () => ({ + authorId: user.id, + type: "bug", + message: faker.lorem.sentence(), + })), + }); + + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.lengthOf(200); + }); + + it("reports author: null once the sender's account is deleted", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" }); + await prisma.userProfile.delete({ where: { id: user.id } }); + + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.lengthOf(1); + expect(res.body[0].message).to.equal("signalé puis compte supprimé"); + expect(res.body[0].author).to.equal(null); + }); +}); diff --git a/apps/web/cypress/e2e/admin-messages.cy.ts b/apps/web/cypress/e2e/admin-messages.cy.ts new file mode 100644 index 0000000..c411264 --- /dev/null +++ b/apps/web/cypress/e2e/admin-messages.cy.ts @@ -0,0 +1,74 @@ +// Mocks the admin API via cy.intercept — no live backend (see +// .github/workflows/ci.yml); apps/api's Mocha suite covers +// `GET /admin/api/contact` against a real database. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function messages() { + return [ + { + id: 2, + type: "bug", + message: "Le bouton Envoyer ne réagit pas sur la page planning.", + pageUrl: "/", + userAgent: "Mozilla/5.0 (Linux; Android 14) BatchCookingApp/0.2.1", + appVersion: "0.2.1", + createdAt: "2026-09-07T10:00:00.000Z", + author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" }, + }, + { + id: 1, + type: "suggestion", + message: "Ajoutez un mode hors-ligne pour la liste de courses.", + pageUrl: "/liste-de-courses", + userAgent: null, + appVersion: null, + createdAt: "2026-09-06T08:30:00.000Z", + author: null, + }, + ]; +} + +describe("Admin contact messages (/admin/messages)", () => { + beforeEach(() => { + cy.viewport(1400, 1000); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); + }); + + it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => { + cy.intercept("GET", "**/admin/api/contact*", { statusCode: 200, body: messages() }).as( + "getMessages", + ); + + cy.visit("/admin/messages"); + cy.wait("@getMessages"); + + cy.get(".messages-table tbody tr").should("have.length", 2); + cy.contains(".messages-table tr", "page planning").should("contain.text", "alice@example.com"); + cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé"); + // No triage controls on the page. + cy.get(".messages-table button").should("not.exist"); + }); + + it("filters by type through the query param", () => { + cy.intercept("GET", "**/admin/api/contact", { statusCode: 200, body: messages() }).as("all"); + cy.intercept("GET", "**/admin/api/contact?type=suggestion", { + statusCode: 200, + body: [messages()[1]], + }).as("filtered"); + + cy.visit("/admin/messages"); + cy.wait("@all"); + + cy.get(".messages-filters select").select("Suggestion"); + cy.wait("@filtered"); + cy.get(".messages-table tbody tr").should("have.length", 1); + cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 83f5586..0390df8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,7 @@ import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPa import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage"; import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage"; import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; +import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage"; import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage"; import { LoginPage } from "./pages/auth/LoginPage"; @@ -199,6 +200,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index 1bd24ff..e031bff 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -4,6 +4,7 @@ import { type AdminUserView, type ApiErrorResponse, type CatalogPlaceholderGroupView, + type ContactMessageAdminView, type CorrectionAdminView, type CreateTourScenarioInput, ErrorCode, @@ -219,6 +220,15 @@ export class AdminApiClient { public deleteTourScenario(id: number): Promise { return this._request(`/tours/${id}`, { method: "DELETE" }); } + + /** + * Contact-form messages users have sent (issue #80), newest first, capped + * server-side at 200. `type` is an optional single-value filter. Read-only + * — this first version has no triage workflow. + */ + public getContactMessages(filters: { type?: string }): Promise { + return this._request(`/contact${query(filters)}`); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/web/src/layouts/AdminLayout.tsx b/apps/web/src/layouts/AdminLayout.tsx index 4602e90..9b1547a 100644 --- a/apps/web/src/layouts/AdminLayout.tsx +++ b/apps/web/src/layouts/AdminLayout.tsx @@ -1,4 +1,11 @@ -import { Activity, GraduationCap, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; +import { + Activity, + GraduationCap, + Inbox, + LayoutDashboard, + ListChecks, + PackageSearch, +} from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -17,6 +24,7 @@ const NAV_ITEMS = [ { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false }, + { to: "/admin/messages", key: "messages", Icon: Inbox, end: false }, ] as const; /** diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 9146130..118c460 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1223,7 +1223,8 @@ "monitoring": "Monitoring", "corrections": "Corrections", "catalog": "Catalogue", - "tours": "Didacticiels" + "tours": "Didacticiels", + "messages": "Messages" }, "layout": { "logout": "Se déconnecter" @@ -1328,6 +1329,29 @@ "consumed": "Consommée" } }, + "messages": { + "title": "Messages de contact", + "lead": "Bugs et suggestions remontés par les utilisateurs via le formulaire de contact. Lecture seule.", + "empty": "Aucun message pour ces filtres.", + "deletedAuthor": "Compte supprimé", + "filter": { + "type": "Type", + "any": "Tous" + }, + "type": { + "bug": "Bug", + "suggestion": "Suggestion", + "other": "Autre" + }, + "col": { + "date": "Date", + "sender": "Expéditeur", + "type": "Type", + "message": "Message", + "page": "Page", + "userAgent": "Client" + } + }, "catalog": { "title": "Ingrédients hors-catalogue", "lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.", diff --git a/apps/web/src/pages/admin/messages/MessagesPage.tsx b/apps/web/src/pages/admin/messages/MessagesPage.tsx new file mode 100644 index 0000000..dfa0408 --- /dev/null +++ b/apps/web/src/pages/admin/messages/MessagesPage.tsx @@ -0,0 +1,118 @@ +import { CONTACT_MESSAGE_TYPES, type ContactMessageAdminView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { adminApiClient } from "../../../api/admin-client"; +import "../admin-page.scss"; +import "./messages-page.scss"; + +type MessagesState = + | { status: "loading" } + | { status: "loaded"; rows: ContactMessageAdminView[] } + | { status: "error" }; + +/** + * Contact-form messages (issue #80) — a read-only list of the bug reports / + * suggestions users have sent from `/parametres/contact`, newest first, + * capped server-side at 200. One optional filter (by `type`); no actions, + * no triage workflow in this first version. + */ +export function MessagesPage() { + const { t } = useTranslation(); + const [type, setType] = useState(""); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + adminApiClient + .getContactMessages({ type: type || undefined }) + .then((rows) => { + if (!cancelled) setState({ status: "loaded", rows }); + }) + .catch(() => { + if (!cancelled) setState({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [type]); + + return ( +
+

{t("admin.messages.title")}

+

{t("admin.messages.lead")}

+ +
+ +
+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && state.rows.length === 0 && ( +

{t("admin.messages.empty")}

+ )} + {state.status === "loaded" && state.rows.length > 0 && ( +
+ + + + + + + + + + + + + {state.rows.map((row) => ( + + + + + + + + + ))} + +
{t("admin.messages.col.date")}{t("admin.messages.col.sender")}{t("admin.messages.col.type")}{t("admin.messages.col.message")}{t("admin.messages.col.page")}{t("admin.messages.col.userAgent")}
+ {new Date(row.createdAt).toLocaleString("fr-FR")} + + {row.author ? ( + <> + {row.author.firstName} {row.author.lastName} +
+ {row.author.email} + + ) : ( + + {t("admin.messages.deletedAuthor")} + + )} +
+ + {t(`admin.messages.type.${row.type}`)} + + {row.message}{row.pageUrl ?? "—"} + {row.userAgent ?? "—"} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/pages/admin/messages/messages-page.scss b/apps/web/src/pages/admin/messages/messages-page.scss new file mode 100644 index 0000000..5795986 --- /dev/null +++ b/apps/web/src/pages/admin/messages/messages-page.scss @@ -0,0 +1,110 @@ +// ============================================================================= +// MessagesPage (issue #80) — one type filter and a read-only table of +// contact-form messages. Shares the visual language of CorrectionsPage's +// filter row + table (corrections-page.scss); kept as its own file since +// nothing outside this page uses these classes. +// ============================================================================= + +.messages-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); + margin-bottom: var(--space-md); + + label { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + select { + padding: var(--space-xs) var(--space-sm); + font-family: var(--font-body); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + } +} + +.messages-table-wrap { + overflow-x: auto; +} + +.messages-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + th, + td { + padding: var(--space-xs) var(--space-sm); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; + } + + th { + color: var(--color-text-muted); + font-weight: 600; + } + + &__date { + white-space: nowrap; + } + + &__email { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + + &__deleted { + color: var(--color-text-muted); + font-style: italic; + } + + // The message is the point of the row — give it room, let it wrap. + &__message { + max-width: 32rem; + white-space: pre-wrap; + } + + &__page { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + } + + // User-agent strings are long and low-value at a glance — clamp to one + // line, full value on hover (see the cell's `title`). + &__ua { + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } +} + +.messages-badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: var(--radius-base); + font-size: var(--font-size-xs); + font-weight: 600; + background: var(--color-surface-alt); + color: var(--color-text); + border-left: 3px solid var(--color-border); + + &--bug { + border-left-color: var(--color-error); + } + &--suggestion { + border-left-color: var(--color-accent); + } + &--other { + border-left-color: var(--color-border); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index af7334f..d2bc57d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ export * from "./schemas/sources.js"; export * from "./schemas/tech-step-worker.js"; export * from "./tools/assert-is-never.js"; export * from "./types/admin.js"; +export * from "./types/contact.js"; export * from "./types/cooking-session.js"; export * from "./types/household.js"; export * from "./types/planning.js"; diff --git a/packages/shared/src/schemas/contact.ts b/packages/shared/src/schemas/contact.ts index 0904591..e4d588f 100644 --- a/packages/shared/src/schemas/contact.ts +++ b/packages/shared/src/schemas/contact.ts @@ -33,3 +33,12 @@ export const submitContactMessageSchema = z.object({ appVersion: z.string().max(50).optional(), }); export type SubmitContactMessageInput = z.infer; + +/** + * Query params for `GET /admin/api/contact` (issue #80, admin read view). + * `type` is an optional single-value filter — omitted means "all types". + */ +export const listContactMessagesQuerySchema = z.object({ + type: z.enum(CONTACT_MESSAGE_TYPES).optional(), +}); +export type ListContactMessagesQuery = z.infer; diff --git a/packages/shared/src/types/contact.ts b/packages/shared/src/types/contact.ts new file mode 100644 index 0000000..3939931 --- /dev/null +++ b/packages/shared/src/types/contact.ts @@ -0,0 +1,25 @@ +import type { ContactMessageType } from "../schemas/contact.js"; + +/** + * One `ContactMessage` row as returned by `GET /admin/api/contact` (issue + * #80, admin read view). Hand-written rather than derived from + * `@prisma/client` (same reason as {@link SafeUserProfile}: `apps/web` must + * not depend on Prisma). `createdAt` is an ISO 8601 string (JSON has no + * date type). `author` is `null` when the sender's account was deleted + * after sending (`ContactMessage.authorId` is `onDelete: SetNull`). + */ +export interface ContactMessageAdminView { + id: number; + type: ContactMessageType; + message: string; + pageUrl: string | null; + userAgent: string | null; + appVersion: string | null; + createdAt: string; + author: { + id: number; + firstName: string; + lastName: string; + email: string; + } | null; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index dbb6c90..a10bfdc 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -439,8 +439,8 @@ pur/testable sans base. `warn` et avalé (jamais de latence ni d'échec sur la requête appelante). `type` est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`, `"planning.item_added"`, `"tech_step.correction_submitted"`, -`"shopping_list.viewed"` aujourd'hui) — ajouter un évènement = un appel d'une -ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un +`"shopping_list.viewed"`, `"contact.submitted"` aujourd'hui) — ajouter un +évènement = un appel d'une ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un évènement est un fait historique qui survit au compte qu'il décrit). **Monitoring** (`admin-monitoring.service.ts`'s `getMonitoring()`) — sonde @@ -521,6 +521,21 @@ La promotion d'un placeholder en vraie entrée catalogue (édition de `reference-seed-data.ts` + locales) reste **100 % manuelle** — pas de fusion ni de génération de snippet depuis l'UI. +**Messages de contact** (`admin-contact.service.ts`, route +`/admin/api/contact`, `requireAdmin`, issue #80) — la contrepartie +consultation du formulaire `/parametres/contact` (section `contact` +ci-dessus) : + +- `GET /admin/api/contact?type=` — `ContactMessage` triés `createdAt desc`, + plafonnés à `CONTACT_MESSAGES_PAGE_SIZE = 200` (même esprit que + `CORRECTIONS_PAGE_SIZE` — c'est une vue de triage, pas un export), filtre + `type` optionnel (`bug|suggestion|other`). `ContactMessageAdminView[]` : + champs du message + `author: { id, firstName, lastName, email } | null` + (`null` = compte supprimé depuis l'envoi, `authorId` étant + `onDelete: SetNull`). +- **Lecture seule** : pas de route d'écriture, pas de workflow de traitement + (statut / assignation / note) dans cette première version. + --- ## `reference` — catalogues publics (pas de session requise)