feat(contact): modèle + POST /contact + formulaire utilisateur (#80) #123

Merged
kyuno merged 2 commits from feat/contact-message-model-and-form into main 2026-09-08 09:17:20 +02:00
23 changed files with 731 additions and 3 deletions
Showing only changes of commit a252640fe6 - Show all commits

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
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles")
}
@ -1189,3 +1191,37 @@ model TourProgress {
@@id([userProfileId, scenarioId])
@@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 { adminRouter } from "./modules/admin/admin.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 { houseRouter } from "./modules/house/house.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.
server.mountRouter("/admin/api", adminRouter);
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
// `requireInternalWorker` on every route within (see that router's own
// 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",
"user_profiles", "diet", "house",
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
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 { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
import { ContactPage } from "./pages/settings/ContactPage";
import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
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="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route>

View file

@ -24,6 +24,7 @@ import {
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
type SubmitContactMessageInput,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
@ -540,6 +541,19 @@ export class ApiClient {
public getLatestMobileVersion(): Promise<{ version: string }> {
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. */

View file

@ -11,6 +11,7 @@ import {
AccountIcon,
AppSettingsIcon,
ChevronLeftIcon,
ContactIcon,
CreditsIcon,
DietPreferencesIcon,
HouseholdIcon,
@ -70,6 +71,7 @@ const SETTINGS_ITEMS: SettingsNavItem[] = [
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon, nativeOnly: true },
{ to: "/parametres/contact", key: "contact", Icon: ContactIcon },
{ 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
// means "open a side drawer".
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,
Rss as SourcesIcon,
Settings as SettingsIcon,

View file

@ -123,6 +123,7 @@
"household": "Foyer",
"userPreferences": "Préférences utilisateur",
"application": "Paramètres d'application",
"contact": "Nous contacter",
"credits": "Crédits"
}
},
@ -435,6 +436,23 @@
"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": {
"title": "Crédits",
"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/admin.js";
export * from "./schemas/auth.js";
export * from "./schemas/contact.js";
export * from "./schemas/cooking-session.js";
export * from "./schemas/household.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
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
### Many-to-one (clés étrangères)
@ -427,6 +449,7 @@ jamais un ajout partiel.
| `recipe` | `authorId` | `user_profiles` |
| `recipe` | `authorHouseId` | `house` |
| `step` | `recipeId` | `recipe` |
| `contact_message` | `authorId` | `user_profiles` (nullable, `SET NULL`) |
### 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.
- **`SettingsMenu`** — révèle Compte (`/parametres/compte`), 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à
sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar.
- **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un
@ -235,7 +236,8 @@ flowchart LR
SETTINGSMENU --> S2["/parametres/preferences"]
SETTINGSMENU --> S3["/parametres/foyer"]
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 →
@ -328,7 +330,7 @@ plus bas (SCSS et theming) anticipait est maintenant réel.
## 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`) :
- **`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`
malgré la collision terminologique : "comment l'app se présente" vs "les
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
satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients
(foodiconpack.com, voir plus bas).