feat(contact): modèle + POST /contact + formulaire utilisateur (#80)
Some checks failed
CI / build (push) Successful in 2m22s
CI / lint (push) Successful in 2m26s
CI / intent-service-test (push) Successful in 4m8s
CI / e2e (push) Successful in 12m40s
CI / test (push) Failing after 15m4s
CI / build-android (push) Has been skipped

Formulaire in-app de remontée de bug / suggestion pour les utilisateurs
connectés (PR 1/2 de l'issue #80).

- Modèle Prisma `ContactMessage` (+ relation inverse sur `UserProfile`) et
  migration `20260908120000_contact_message` — table neuve, aucun backfill.
  `authorId` nullable + `onDelete: SetNull` : un signalement survit à la
  suppression de son compte.
- Contrat partagé `schemas/contact.ts` : `CONTACT_MESSAGE_TYPES`,
  `submitContactMessageSchema` (type enum, message 10-5000 trim, pageUrl /
  appVersion optionnels — jamais le user-agent).
- Module API `contact` : `POST /contact` (`requireAuth`, monté `/contact`),
  `204` sans corps. Le user-agent est lu de l'en-tête de requête, jamais du
  corps. Émet `analytics.recordEvent(contact.submitted, …)`.
- Page `/parametres/contact` (`ContactPage` + `contact.ts` + `.scss`) :
  select de type, textarea, validation cliente via le schéma partagé, états
  succès / erreur (`ErrorMessageService`, jamais `err.message`). Entrée de
  nav « Nous contacter » dans `SETTINGS_ITEMS` + clé i18n + icône.
  `apiClient.submitContactMessage(input)` ajoute `pageUrl` / `appVersion`.
- Tests : `apps/api/test/contact.test.ts` (Mocha — insertion, en-tête UA,
  champs nullables, message trop court / trop long, type inconnu),
  `contact.feature` + steps (Cypress e2e — envoi + confirmation + contexte),
  `contact-page.cy.ts` (rendu + garde `RequireAuth` + label d'erreur).
- Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
  `frontend-architecture.md`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-08 08:26:47 +02:00
parent 206ab6fa0c
commit a252640fe6
23 changed files with 731 additions and 3 deletions

View file

@ -0,0 +1,23 @@
-- Contact-form messages (issue #80). New table only — nothing to backfill.
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
-- sender's account deletion (same as ingredients.created_by_id).
-- CreateTable
CREATE TABLE "contact_message" (
"id" SERIAL NOT NULL,
"author_id" INTEGER,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"page_url" TEXT,
"user_agent" TEXT,
"app_version" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
-- AddForeignKey
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -135,6 +135,8 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text /// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles") @@map("user_profiles")
} }
@ -1189,3 +1191,37 @@ model TourProgress {
@@id([userProfileId, scenarioId]) @@id([userProfileId, scenarioId])
@@map("tour_progress") @@map("tour_progress")
} }
// -----------------------------------------------------------------------------
// Contact
// In-app "report a bug / suggestion" form — user-submitted, read-only for
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
// into a Forgejo issue).
// -----------------------------------------------------------------------------
/// A message sent via the app's contact form (bug, suggestion, other).
/// Written once by a signed-in user, never edited afterwards — consulted
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
/// even if the account that sent it is deleted in the meantime.
model ContactMessage {
id Int @id @default(autoincrement())
authorId Int? @map("author_id")
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
/// `CONTACT_MESSAGE_TYPES` const (same approach as
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
/// value is one line, no Postgres enum migration.
type String
message String
/// Technical context captured automatically at submit time, for triage —
/// all nullable (an older client, or a direct API call, can omit them).
pageUrl String? @map("page_url")
userAgent String? @map("user_agent")
appVersion String? @map("app_version")
createdAt DateTime @default(now()) @map("created_at")
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("contact_message")
}

View file

@ -7,6 +7,7 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
@ -60,6 +61,10 @@ export function createServer(): ExpressServer {
// admin page path can ever collide with an admin API path again. // admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter); server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers // doc comment), never `requireAuth`. Mounted alongside the other routers

View file

@ -0,0 +1,34 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { submitContactMessageSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { submitContactMessage } from "./contact.service.js";
/**
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
* session: only signed-in users can send a message, so every report is
* attributable (see `ContactMessage.authorId`). The admin-side read of
* these rows lives entirely in the admin router, not here.
*/
export const contactRouter = Router();
contactRouter.post(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = submitContactMessageSchema.parse(req.body);
// Read the UA from the request, never from the body — the schema omits
// it on purpose so it can't be spoofed. Header can be an array or
// absent; normalize to `string | null`.
const rawUserAgent = req.headers["user-agent"];
const userAgent =
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
// 204, not 201-with-a-body: the form has nothing to do with the stored
// row, and the shared `ApiClient._request` treats only 204 as bodyless
// (a 201 with an empty body makes its `response.json()` throw). Same
// "mutation returns nothing" shape as `POST /auth/logout`.
res.status(204).end();
}),
);

View file

@ -0,0 +1,50 @@
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
/**
* Persists one contact-form submission (issue #80). Write-only from the
* user's side — there is no read/update/delete counterpart here: admins
* consult these rows through the admin app (`admin-contact.service.ts`, PR
* 2), never this module.
*
* `userAgent` comes straight from the request header, never the body — the
* shared `submitContactMessageSchema` deliberately omits it so the client
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
* default to `null` when the (older, or non-web) client didn't send them.
*
* Returns nothing: the form only needs to know the write succeeded (a
* `201`), not the stored row.
*/
export async function submitContactMessage(
authorId: number,
input: SubmitContactMessageInput,
userAgent: string | null,
): Promise<void> {
try {
await prisma.contactMessage.create({
data: {
authorId,
type: input.type,
message: input.message,
pageUrl: input.pageUrl ?? null,
userAgent,
appVersion: input.appVersion ?? null,
},
});
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
// the dashboard series with no extra code; `context.type` lets it be
// broken down by bug/suggestion/other.
analytics.recordEvent("contact.submitted", {
actorType: "user",
actorId: authorId,
context: { type: input.type },
});
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
// already logs it) handles it; this service layer just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}

View file

@ -49,6 +49,7 @@ export async function resetDatabase() {
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "user_profiles", "diet", "house",
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target", "tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state" "admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);

View file

@ -0,0 +1,143 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */
function buildContactPayload() {
return {
type: "bug" as const,
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
pageUrl: "/",
appVersion: "0.2.1",
};
}
describe("Contact", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /contact", () => {
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/contact").send(buildContactPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
const agent = request.agent(app);
const signup = buildSignupPayload();
await agent.post("/auth/signup").send(signup);
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
const res = await agent
.post("/contact")
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
.send(buildContactPayload());
expect(res.status).to.equal(204);
const rows = await prisma.contactMessage.findMany();
expect(rows).to.have.lengthOf(1);
const [row] = rows;
expect(row.authorId).to.equal(author.id);
expect(row.type).to.equal("bug");
expect(row.message).to.equal(buildContactPayload().message);
expect(row.pageUrl).to.equal("/");
expect(row.appVersion).to.equal("0.2.1");
// Read from the request header, never the body.
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
});
it("ignores any user-agent sent in the body — the header is the only source", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.set("User-Agent", "real-agent/1.0")
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.userAgent).to.equal("real-agent/1.0");
});
it("keeps pageUrl / appVersion null when the client omits them", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({
type: "suggestion",
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
});
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.type).to.equal("suggestion");
expect(row.pageUrl).to.equal(null);
expect(row.appVersion).to.equal(null);
});
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
// 5 chars — and the schema trims before counting, so leading/trailing
// whitespace can't pad it to the minimum.
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
});
});

View file

@ -0,0 +1,58 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
// `POST /contact` behavior against a real database. The Gherkin journey
// (fill + send + confirmation) lives in contact.feature — this file only
// covers the page's initial render and its guards.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
describe("Contact page (/parametres/contact)", () => {
it("redirects to /login when signed out (RequireAuth)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.visit("/parametres/contact");
cy.url().should("include", "/login");
});
describe("signed in", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("renders the form: a type select defaulting to Bug, a message box, a submit button", () => {
cy.visit("/parametres/contact");
cy.contains("h1", "Nous contacter").should("be.visible");
cy.get("#contactType option").should("have.length", 3);
cy.get("#contactType").should("have.value", "bug");
cy.get("#contactMessage").should("be.visible");
cy.contains("button", "Envoyer").should("not.be.disabled");
});
it("shows a translated error label, never the raw server message, when the submit fails", () => {
cy.intercept("POST", "**/contact", {
statusCode: 500,
body: { code: 5000, message: "raw internal boom" },
}).as("submitContact");
cy.visit("/parametres/contact");
cy.get("#contactMessage").type(
"Ceci est un message de test suffisamment long pour être valide.",
);
cy.contains("button", "Envoyer").click();
cy.wait("@submitContact");
cy.get(".field-error").should("be.visible");
cy.contains("raw internal boom").should("not.exist");
});
});
});

View file

@ -0,0 +1,25 @@
Feature: Contact form
As a signed-in user
I want to report a bug or send a suggestion from within the app
So that the team hears about it with the technical context attached
Background:
Given I am signed in as "Alice" "Martin"
Scenario: Sending a bug report shows a confirmation and forwards the page context
Given submitting a contact message will succeed
When I visit "/parametres/contact"
And I select "Bug" from the "contactType" field
And I fill in the "contactMessage" field with "Le bouton Envoyer ne réagit pas sur la page planning."
And I click the button "Envoyer"
Then the contact request should have been sent with type "bug"
And the contact request body should carry the current page URL and app version
And I should see "Merci ! Votre message a bien été envoyé."
Scenario: A too-short message is rejected on the client, without a request
Given submitting a contact message will succeed
When I visit "/parametres/contact"
And I fill in the "contactMessage" field with "court"
And I click the button "Envoyer"
Then I should see "Le message doit faire au moins 10 caractères"
And no contact request should have been sent

View file

@ -0,0 +1,31 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
// Contact-form-specific steps (issue #80). Signing in and the generic
// select/type/click/see steps come from support/step_definitions/.
// Mocks the API via cy.intercept — this job runs no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers `POST /contact`
// against a real database.
Given("submitting a contact message will succeed", () => {
// 204, matching the real route — the client's `_request` treats only 204
// as a bodyless success (a 201 with no body makes its `.json()` throw).
cy.intercept("POST", "**/contact", { statusCode: 204 }).as("submitContact");
});
Then("the contact request should have been sent with type {string}", (type: string) => {
cy.wait("@submitContact").its("request.body.type").should("equal", type);
});
Then("the contact request body should carry the current page URL and app version", () => {
cy.get("@submitContact.all").should("have.length", 1);
cy.get("@submitContact")
.its("request.body")
.should((body: Record<string, unknown>) => {
expect(body.pageUrl).to.equal("/parametres/contact");
expect(body.appVersion).to.be.a("string").and.not.equal("");
});
});
Then("no contact request should have been sent", () => {
cy.get("@submitContact.all").should("have.length", 0);
});

View file

@ -27,6 +27,7 @@ import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
import { RecipesPage } from "./pages/recipes/RecipesPage"; import { RecipesPage } from "./pages/recipes/RecipesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { AppSettingsPage } from "./pages/settings/AppSettingsPage"; import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
import { ContactPage } from "./pages/settings/ContactPage";
import { CreditsPage } from "./pages/settings/CreditsPage"; import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage";
@ -124,6 +125,7 @@ export function App() {
) )
} }
/> />
<Route path="/parametres/contact" element={<ContactPage />} />
<Route path="/parametres/credits" element={<CreditsPage />} /> <Route path="/parametres/credits" element={<CreditsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} /> <Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route> </Route>

View file

@ -24,6 +24,7 @@ import {
type SignupInput, type SignupInput,
type SourceView, type SourceView,
type StepTechStepCorrectionView, type StepTechStepCorrectionView,
type SubmitContactMessageInput,
type SubmitTechStepCorrectionInput, type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult, type SubmitTechStepCorrectionResult,
type TechStepView, type TechStepView,
@ -540,6 +541,19 @@ export class ApiClient {
public getLatestMobileVersion(): Promise<{ version: string }> { public getLatestMobileVersion(): Promise<{ version: string }> {
return this._request("/mobile/latest-release"); return this._request("/mobile/latest-release");
} }
/**
* Sends a contact-form message (bug report / suggestion / other — issue
* #80). Session required; the message is stored server-side and read by
* admins at `/admin/messages`. Resolves with nothing on success (`204`),
* rejects with `VALIDATION_ERROR` if `message` is too short/long. The
* server reads the user-agent from the request header itself — the client
* only sends `type`, `message`, and the auto-captured `pageUrl` /
* `appVersion` context.
*/
public async submitContactMessage(input: SubmitContactMessageInput): Promise<void> {
await this._request("/contact", { method: "POST", body: JSON.stringify(input) });
}
} }
/** Single shared instance — this client is stateless, no need for one per caller. */ /** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -11,6 +11,7 @@ import {
AccountIcon, AccountIcon,
AppSettingsIcon, AppSettingsIcon,
ChevronLeftIcon, ChevronLeftIcon,
ContactIcon,
CreditsIcon, CreditsIcon,
DietPreferencesIcon, DietPreferencesIcon,
HouseholdIcon, HouseholdIcon,
@ -70,6 +71,7 @@ const SETTINGS_ITEMS: SettingsNavItem[] = [
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon, nativeOnly: true }, { to: "/parametres/application", key: "application", Icon: AppSettingsIcon, nativeOnly: true },
{ to: "/parametres/contact", key: "contact", Icon: ContactIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon }, { to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
]; ];

View file

@ -26,6 +26,10 @@ export {
// even in a bottom-tab position, unlike on desktop where it usually // even in a bottom-tab position, unlike on desktop where it usually
// means "open a side drawer". // means "open a side drawer".
Menu as MoreIcon, Menu as MoreIcon,
// "Contact" settings entry (issue #80) — the in-app bug/suggestion form.
// A speech bubble reads as "send us a message"; distinct from the Rss
// "Sources" feed glyph and the generic Settings cog.
MessageSquare as ContactIcon,
Palette as UserPreferencesIcon, Palette as UserPreferencesIcon,
Rss as SourcesIcon, Rss as SourcesIcon,
Settings as SettingsIcon, Settings as SettingsIcon,

View file

@ -123,6 +123,7 @@
"household": "Foyer", "household": "Foyer",
"userPreferences": "Préférences utilisateur", "userPreferences": "Préférences utilisateur",
"application": "Paramètres d'application", "application": "Paramètres d'application",
"contact": "Nous contacter",
"credits": "Crédits" "credits": "Crédits"
} }
}, },
@ -435,6 +436,23 @@
"SYSTEM": "Système" "SYSTEM": "Système"
} }
}, },
"contact": {
"title": "Nous contacter",
"intro": "Un bug, une idée d'amélioration ? Dites-nous tout. Votre message est transmis à l'équipe avec quelques informations techniques (page, version de l'app) pour nous aider à le comprendre.",
"typeLabel": "Type de message",
"type": {
"bug": "Bug",
"suggestion": "Suggestion",
"other": "Autre"
},
"messageLabel": "Votre message",
"messagePlaceholder": "Décrivez le problème ou votre suggestion le plus précisément possible…",
"charCount": "{{count}} / {{max}} caractères",
"submit": "Envoyer",
"submitting": "Envoi…",
"success": "Merci ! Votre message a bien été envoyé.",
"sendAnother": "Envoyer un autre message"
},
"credits": { "credits": {
"title": "Crédits", "title": "Crédits",
"icons": { "icons": {

View file

@ -0,0 +1,131 @@
import {
CONTACT_MESSAGE_TYPES,
type ContactMessageType,
ErrorCode,
submitContactMessageSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { errorMessageService } from "../../services/error-message.service";
import { buildContactMessageInput } from "./contact";
import "./settings-pages.scss";
import "./contact-page.scss";
/**
* `message`'s server-side ceiling (see `submitContactMessageSchema`) —
* mirrored here to cap the `<textarea>` natively so the count can't run
* away before the schema rejects it.
*/
const MAX_MESSAGE_LENGTH = 5000;
/** Where the submit currently stands — drives the button label and the success swap. */
type SubmitState = "idle" | "submitting" | "success" | "error";
/**
* In-app "signaler un bug / suggestion" form — routed at
* `/parametres/contact` (issue #80). Signed-in users only (the whole
* `/parametres/*` group is under `RequireAuth`); the message is stored
* server-side and read by admins at `/admin/messages`. The user picks a
* `type` and writes a message; `pageUrl` / `appVersion` ride along
* automatically (see {@link buildContactMessageInput}) and the API adds the
* user-agent from the request header.
*/
export function ContactPage() {
const { t } = useTranslation();
const [type, setType] = useState<ContactMessageType>("bug");
const [message, setMessage] = useState("");
const [submitState, setSubmitState] = useState<SubmitState>("idle");
// Client-side schema rejection (e.g. message too short) — the schema's
// own French messages, surfaced as-is (same choice as the auth forms).
const [fieldError, setFieldError] = useState<string | null>(null);
// A failed request — always an `ErrorMessageService` label, never `err.message`.
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setFieldError(null);
setSubmitError(null);
const parsed = submitContactMessageSchema.safeParse(buildContactMessageInput(type, message));
if (!parsed.success) {
setFieldError(parsed.error.issues[0]?.message ?? "");
return;
}
setSubmitState("submitting");
try {
await apiClient.submitContactMessage(parsed.data);
setSubmitState("success");
setMessage("");
setType("bug");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSubmitError(errorMessageService.getLabel(code));
setSubmitState("error");
}
}
if (submitState === "success") {
return (
<div className="settings-page">
<h1>{t("contact.title")}</h1>
<div className="settings-page__section">
<p className="settings-page__saved">{t("contact.success")}</p>
<div className="settings-page__actions">
<button type="button" onClick={() => setSubmitState("idle")}>
{t("contact.sendAnother")}
</button>
</div>
</div>
</div>
);
}
return (
<div className="settings-page">
<h1>{t("contact.title")}</h1>
<div className="settings-page__section">
<p className="settings-page__hint">{t("contact.intro")}</p>
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<label htmlFor="contactType">{t("contact.typeLabel")}</label>
<select
id="contactType"
value={type}
onChange={(event) => setType(event.target.value as ContactMessageType)}
>
{CONTACT_MESSAGE_TYPES.map((option) => (
<option key={option} value={option}>
{t(`contact.type.${option}`)}
</option>
))}
</select>
<label htmlFor="contactMessage">{t("contact.messageLabel")}</label>
<textarea
id="contactMessage"
value={message}
onChange={(event) => setMessage(event.target.value)}
rows={7}
maxLength={MAX_MESSAGE_LENGTH}
placeholder={t("contact.messagePlaceholder")}
/>
<p className="contact-form__char-count" aria-hidden="true">
{t("contact.charCount", { count: message.trim().length, max: MAX_MESSAGE_LENGTH })}
</p>
{fieldError && <p className="field-error">{fieldError}</p>}
{submitError && <p className="field-error">{submitError}</p>}
<div className="settings-page__actions">
<button type="submit" disabled={submitState === "submitting"}>
{submitState === "submitting" ? t("contact.submitting") : t("contact.submit")}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
// =============================================================================
// Contact form (issue #80) — routed at /parametres/contact. Only the bits
// the shared settings/field styles don't already cover: the <textarea>
// (profile-forms.scss styles input/select but never textarea) and the small
// character counter beneath it. Page/card layout comes from
// settings-pages.scss; label/select styling is global (profile-forms.scss).
// =============================================================================
.contact-form {
display: flex;
flex-direction: column;
// Mirrors profile-forms.scss's input/select rule (which excludes
// textarea) so the message box matches the type select above it, plus a
// sensible min height and vertical-only resize.
textarea {
width: 100%;
margin-top: var(--space-xs);
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
min-height: 8rem;
resize: vertical;
}
&__char-count {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-align: right;
}
}

View file

@ -0,0 +1,25 @@
import type { ContactMessageType, SubmitContactMessageInput } from "@batch-cooking/shared";
/**
* Builds the `POST /contact` payload (issue #80) from the two things the
* user picked in the form — the message `type` and its text — plus the two
* pieces of context captured automatically: the path they're on
* (`window.location.pathname`) and the running app version
* (`__APP_VERSION__`, injected by `vite.config.ts`). Kept out of
* `ContactPage` so those ambient reads sit in one unit-testable place.
*
* The user-agent is deliberately NOT set here — the API reads it straight
* from the request header, never trusting the client for it (see
* `submitContactMessageSchema`, which omits it).
*/
export function buildContactMessageInput(
type: ContactMessageType,
message: string,
): SubmitContactMessageInput {
return {
type,
message,
pageUrl: window.location.pathname,
appVersion: __APP_VERSION__,
};
}

View file

@ -10,6 +10,7 @@ export * from "./logic/planning-portions.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/admin.js"; export * from "./schemas/admin.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/contact.js";
export * from "./schemas/cooking-session.js"; export * from "./schemas/cooking-session.js";
export * from "./schemas/household.js"; export * from "./schemas/household.js";
export * from "./schemas/planning.js"; export * from "./schemas/planning.js";

View file

@ -0,0 +1,35 @@
import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/web (client-side validation for instant feedback on the contact
// form). Same rationale as schemas/auth.ts — one set of rules, French
// messages surfaced as-is in the form.
/**
* The kind of a contact-form message. A plain `String` in Postgres backed by
* this const (see `ContactMessage.type` in schema.prisma) rather than a
* Postgres enum — adding a value is one line here, no migration. `type`
* structures the admin-side triage; the user picks it from a small select.
*/
export const CONTACT_MESSAGE_TYPES = ["bug", "suggestion", "other"] as const;
export type ContactMessageType = (typeof CONTACT_MESSAGE_TYPES)[number];
/**
* Payload accepted by `POST /contact` (issue #80). `message` is the only
* thing the user actually types; `pageUrl` / `appVersion` are captured
* automatically by the client (`window.location.pathname`, `__APP_VERSION__`)
* and are optional so a direct API call or an older client can omit them.
* `userAgent` is NOT here — the server reads it from the request header,
* never trusting the client for it.
*/
export const submitContactMessageSchema = z.object({
type: z.enum(CONTACT_MESSAGE_TYPES),
message: z
.string()
.trim()
.min(10, "Le message doit faire au moins 10 caractères")
.max(5000, "Le message ne peut pas dépasser 5000 caractères"),
pageUrl: z.string().max(500).optional(),
appVersion: z.string().max(50).optional(),
});
export type SubmitContactMessageInput = z.infer<typeof submitContactMessageSchema>;

View file

@ -218,6 +218,27 @@ Géré depuis `PreferencesPage` côté web (`/parametres/preferences`).
--- ---
## `contact` — formulaire de contact (bug / suggestion)
**`contact`** (`contact.routes.ts`/`.service.ts`, monté `/contact`,
`requireAuth`) : `POST /contact` → `204` sans corps (comme `POST
/auth/logout` — `ApiClient._request` ne traite que `204` comme sans
corps). Le formulaire in-app
(issue #80) par lequel un utilisateur connecté remonte un bug, une
suggestion ou un message libre. Corps validé par
`submitContactMessageSchema` (`packages/shared`) : `type`
(`"bug"|"suggestion"|"other"`), `message` (10–5000 caractères, `trim`),
`pageUrl` / `appVersion` optionnels. Le **user-agent est lu de
`req.headers["user-agent"]`**, jamais du corps. `submitContactMessage`
insère la ligne `ContactMessage` (`authorId` = appelant) puis émet
`analytics.recordEvent("contact.submitted", { actorType: "user", actorId, context: { type } })`
(fire-and-forget) — `admin-metrics` agrège les `AnalyticsEvent` par `type`
génériquement, l'event apparaît dans les séries du dashboard sans code en
plus. La consultation admin (`GET /admin/api/contact`) est décrite dans la
section `admin`.
---
## `planning` — semaine, item, portions, import à la volée ## `planning` — semaine, item, portions, import à la volée
Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`. Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`.

View file

@ -410,6 +410,28 @@ jamais un ajout partiel.
--- ---
## Contact
### `contact_message` (`ContactMessage`)
Message envoyé via le formulaire de contact de l'app (issue #80) : un
utilisateur **connecté** signale un bug, propose une amélioration
(`type` = `"bug" | "suggestion" | "other"`, `String` adossé à la const
partagée `CONTACT_MESSAGE_TYPES`, pas d'enum Postgres) ou écrit un
message libre. Écrit une fois, jamais modifié ensuite ; consulté en
**lecture seule** côté admin (`GET /admin/api/contact`, voir
[backend-architecture.md](./backend-architecture.md)).
- `authorId` → `user_profiles`, **nullable** avec `onDelete: SetNull`
(même choix que `ingredients.createdById`) : un signalement garde son
intérêt même si le compte qui l'a envoyé est supprimé.
- `pageUrl` / `userAgent` / `appVersion` : contexte technique capturé à
l'envoi, tous nullables. `userAgent` est lu de l'en-tête de requête côté
API, jamais du corps.
- `@@index([createdAt])` — la vue admin trie par date décroissante.
---
## Relations ## Relations
### Many-to-one (clés étrangères) ### Many-to-one (clés étrangères)
@ -427,6 +449,7 @@ jamais un ajout partiel.
| `recipe` | `authorId` | `user_profiles` | | `recipe` | `authorId` | `user_profiles` |
| `recipe` | `authorHouseId` | `house` | | `recipe` | `authorHouseId` | `house` |
| `step` | `recipeId` | `recipe` | | `step` | `recipeId` | `recipe` |
| `contact_message` | `authorId` | `user_profiles` (nullable, `SET NULL`) |
### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel) ### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)

View file

@ -193,7 +193,8 @@ La sidebar a grandi avec l'app :
icône et gagne un `title` en tooltip. icône et gagne un `title` en tooltip.
- **`SettingsMenu`** — révèle Compte (`/parametres/compte`), Préférences - **`SettingsMenu`** — révèle Compte (`/parametres/compte`), Préférences
(`/parametres/preferences`), Foyer (`/parametres/foyer`), Préférences (`/parametres/preferences`), Foyer (`/parametres/foyer`), Préférences
utilisateur (`/parametres/preferences-utilisateur`), Crédits utilisateur (`/parametres/preferences-utilisateur`), Nous contacter
(`/parametres/contact` — formulaire bug/suggestion, issue #80), Crédits
(`/parametres/credits`). Ouvert par défaut si la route courante est déjà (`/parametres/credits`). Ouvert par défaut si la route courante est déjà
sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar. sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar.
- **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un - **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un
@ -235,7 +236,8 @@ flowchart LR
SETTINGSMENU --> S2["/parametres/preferences"] SETTINGSMENU --> S2["/parametres/preferences"]
SETTINGSMENU --> S3["/parametres/foyer"] SETTINGSMENU --> S3["/parametres/foyer"]
SETTINGSMENU --> S4["/parametres/preferences-utilisateur"] SETTINGSMENU --> S4["/parametres/preferences-utilisateur"]
SETTINGSMENU --> S5["/parametres/credits"] SETTINGSMENU --> S5["/parametres/contact"]
SETTINGSMENU --> S6["/parametres/credits"]
``` ```
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources → - **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
@ -328,7 +330,7 @@ plus bas (SCSS et theming) anticipait est maintenant réel.
## Pages de paramètres (`/parametres/*`) ## Pages de paramètres (`/parametres/*`)
L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées L'ancienne `HouseholdPage` combinée est éclatée en pages dédiées
(`pages/settings/*.tsx`, styles partagés `settings-pages.scss`) : (`pages/settings/*.tsx`, styles partagés `settings-pages.scss`) :
- **`AccountSettingsPage`** (`/compte`) — identité en **lecture seule** - **`AccountSettingsPage`** (`/compte`) — identité en **lecture seule**
@ -361,6 +363,14 @@ L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées
(voir la section précédente). Nom volontairement distinct de `/preferences` (voir la section précédente). Nom volontairement distinct de `/preferences`
malgré la collision terminologique : "comment l'app se présente" vs "les malgré la collision terminologique : "comment l'app se présente" vs "les
contraintes alimentaires du foyer". contraintes alimentaires du foyer".
- **`ContactPage`** (`/contact`, "Nous contacter", issue #80) — formulaire
in-app de remontée de bug / suggestion : un `<select>` de type
(`CONTACT_MESSAGE_TYPES`), un `<textarea>` message, validation cliente via
`submitContactMessageSchema` (messages du schéma affichés tels quels),
états succès / erreur (`ErrorMessageService`, jamais `err.message`).
`pageUrl` (`window.location.pathname`) et `appVersion` (`__APP_VERSION__`)
ajoutés automatiquement (`pages/settings/contact.ts`), l'API complète avec
le user-agent. `apiClient.submitContactMessage(input)` → `POST /contact`.
- **`CreditsPage`** (`/credits`) — page d'attribution pure, existe pour - **`CreditsPage`** (`/credits`) — page d'attribution pure, existe pour
satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients
(foodiconpack.com, voir plus bas). (foodiconpack.com, voir plus bas).