Compare commits

...

1 commit

Author SHA1 Message Date
712772dc02 feat(admin): consultation des messages de contact (#80)
All checks were successful
CI / lint (push) Successful in 3m52s
CI / build (push) Successful in 4m5s
CI / intent-service-test (push) Successful in 5m11s
CI / e2e (push) Successful in 8m11s
CI / test (push) Successful in 21m19s
CI / build-android (push) Has been skipped
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 <noreply@anthropic.com>
2026-09-08 08:34:36 +02:00
15 changed files with 667 additions and 4 deletions

View file

@ -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)));
}),
);

View file

@ -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<ContactMessageAdminView[]> {
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;
}
}

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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");
});
});

View file

@ -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() {
<Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} />
<Route path="didacticiels" element={<AdminToursPage />} />
<Route path="messages" element={<AdminMessagesPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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<void> {
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<ContactMessageAdminView[]> {
return this._request(`/contact${query(filters)}`);
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -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;
/**

View file

@ -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.",

View file

@ -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<MessagesState>({ 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 (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.messages.title")}</h1>
<p className="admin-page__lead">{t("admin.messages.lead")}</p>
<div className="messages-filters">
<label>
{t("admin.messages.filter.type")}
<select value={type} onChange={(event) => setType(event.target.value)}>
<option value="">{t("admin.messages.filter.any")}</option>
{CONTACT_MESSAGE_TYPES.map((option) => (
<option key={option} value={option}>
{t(`admin.messages.type.${option}`)}
</option>
))}
</select>
</label>
</div>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && state.rows.length === 0 && (
<p className="admin-page__placeholder">{t("admin.messages.empty")}</p>
)}
{state.status === "loaded" && state.rows.length > 0 && (
<div className="messages-table-wrap">
<table className="messages-table">
<thead>
<tr>
<th>{t("admin.messages.col.date")}</th>
<th>{t("admin.messages.col.sender")}</th>
<th>{t("admin.messages.col.type")}</th>
<th>{t("admin.messages.col.message")}</th>
<th>{t("admin.messages.col.page")}</th>
<th>{t("admin.messages.col.userAgent")}</th>
</tr>
</thead>
<tbody>
{state.rows.map((row) => (
<tr key={row.id}>
<td className="messages-table__date">
{new Date(row.createdAt).toLocaleString("fr-FR")}
</td>
<td>
{row.author ? (
<>
{row.author.firstName} {row.author.lastName}
<br />
<span className="messages-table__email">{row.author.email}</span>
</>
) : (
<span className="messages-table__deleted">
{t("admin.messages.deletedAuthor")}
</span>
)}
</td>
<td>
<span className={`messages-badge messages-badge--${row.type}`}>
{t(`admin.messages.type.${row.type}`)}
</span>
</td>
<td className="messages-table__message">{row.message}</td>
<td className="messages-table__page">{row.pageUrl ?? "—"}</td>
<td className="messages-table__ua" title={row.userAgent ?? undefined}>
{row.userAgent ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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);
}
}

View file

@ -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";

View file

@ -33,3 +33,12 @@ export const submitContactMessageSchema = z.object({
appVersion: z.string().max(50).optional(),
});
export type SubmitContactMessageInput = z.infer<typeof submitContactMessageSchema>;
/**
* 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<typeof listContactMessagesQuerySchema>;

View file

@ -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;
}

View file

@ -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)