feat(admin): consultation des messages de contact (#80)
All checks were successful
All checks were successful
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>
This commit is contained in:
parent
a252640fe6
commit
712772dc02
15 changed files with 667 additions and 4 deletions
23
apps/api/src/modules/admin/admin-contact.routes.ts
Normal file
23
apps/api/src/modules/admin/admin-contact.routes.ts
Normal 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)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
62
apps/api/src/modules/admin/admin-contact.service.ts
Normal file
62
apps/api/src/modules/admin/admin-contact.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||||
|
import { adminContactRouter } from "./admin-contact.routes.js";
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
import { adminToursRouter } from "./admin-tours.routes.js";
|
||||||
|
|
@ -26,3 +27,4 @@ adminRouter.use("/metrics", adminMetricsRouter);
|
||||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
adminRouter.use("/tours", adminToursRouter);
|
adminRouter.use("/tours", adminToursRouter);
|
||||||
|
adminRouter.use("/contact", adminContactRouter);
|
||||||
|
|
|
||||||
180
apps/api/test/admin-contact.test.ts
Normal file
180
apps/api/test/admin-contact.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
apps/web/cypress/e2e/admin-messages.cy.ts
Normal file
74
apps/web/cypress/e2e/admin-messages.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPa
|
||||||
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
|
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
|
||||||
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
|
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
|
||||||
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
|
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 { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
||||||
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
|
|
@ -199,6 +200,7 @@ export function App() {
|
||||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
||||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
<Route path="catalogue" element={<AdminCatalogPage />} />
|
||||||
<Route path="didacticiels" element={<AdminToursPage />} />
|
<Route path="didacticiels" element={<AdminToursPage />} />
|
||||||
|
<Route path="messages" element={<AdminMessagesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type AdminUserView,
|
type AdminUserView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type CatalogPlaceholderGroupView,
|
type CatalogPlaceholderGroupView,
|
||||||
|
type ContactMessageAdminView,
|
||||||
type CorrectionAdminView,
|
type CorrectionAdminView,
|
||||||
type CreateTourScenarioInput,
|
type CreateTourScenarioInput,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -219,6 +220,15 @@ export class AdminApiClient {
|
||||||
public deleteTourScenario(id: number): Promise<void> {
|
public deleteTourScenario(id: number): Promise<void> {
|
||||||
return this._request(`/tours/${id}`, { method: "DELETE" });
|
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`. */
|
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||||
|
|
|
||||||
|
|
@ -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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
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/corrections", key: "corrections", Icon: ListChecks, end: false },
|
||||||
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
||||||
{ to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false },
|
{ to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false },
|
||||||
|
{ to: "/admin/messages", key: "messages", Icon: Inbox, end: false },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1223,7 +1223,8 @@
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"corrections": "Corrections",
|
"corrections": "Corrections",
|
||||||
"catalog": "Catalogue",
|
"catalog": "Catalogue",
|
||||||
"tours": "Didacticiels"
|
"tours": "Didacticiels",
|
||||||
|
"messages": "Messages"
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"logout": "Se déconnecter"
|
"logout": "Se déconnecter"
|
||||||
|
|
@ -1328,6 +1329,29 @@
|
||||||
"consumed": "Consommée"
|
"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": {
|
"catalog": {
|
||||||
"title": "Ingrédients hors-catalogue",
|
"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.",
|
"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.",
|
||||||
|
|
|
||||||
118
apps/web/src/pages/admin/messages/MessagesPage.tsx
Normal file
118
apps/web/src/pages/admin/messages/MessagesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/web/src/pages/admin/messages/messages-page.scss
Normal file
110
apps/web/src/pages/admin/messages/messages-page.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ export * from "./schemas/sources.js";
|
||||||
export * from "./schemas/tech-step-worker.js";
|
export * from "./schemas/tech-step-worker.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/admin.js";
|
export * from "./types/admin.js";
|
||||||
|
export * from "./types/contact.js";
|
||||||
export * from "./types/cooking-session.js";
|
export * from "./types/cooking-session.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,12 @@ export const submitContactMessageSchema = z.object({
|
||||||
appVersion: z.string().max(50).optional(),
|
appVersion: z.string().max(50).optional(),
|
||||||
});
|
});
|
||||||
export type SubmitContactMessageInput = z.infer<typeof submitContactMessageSchema>;
|
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>;
|
||||||
|
|
|
||||||
25
packages/shared/src/types/contact.ts
Normal file
25
packages/shared/src/types/contact.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -439,8 +439,8 @@ pur/testable sans base.
|
||||||
`warn` et avalé (jamais de latence ni d'échec sur la requête appelante). `type`
|
`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"`,
|
est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`,
|
||||||
`"planning.item_added"`, `"tech_step.correction_submitted"`,
|
`"planning.item_added"`, `"tech_step.correction_submitted"`,
|
||||||
`"shopping_list.viewed"` aujourd'hui) — ajouter un évènement = un appel d'une
|
`"shopping_list.viewed"`, `"contact.submitted"` aujourd'hui) — ajouter un
|
||||||
ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (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).
|
évènement est un fait historique qui survit au compte qu'il décrit).
|
||||||
|
|
||||||
**Monitoring** (`admin-monitoring.service.ts`'s `getMonitoring()`) — sonde
|
**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
|
`reference-seed-data.ts` + locales) reste **100 % manuelle** — pas de fusion
|
||||||
ni de génération de snippet depuis l'UI.
|
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)
|
## `reference` — catalogues publics (pas de session requise)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue