feat(contact): modèle + POST /contact + formulaire utilisateur (#80) #123
23 changed files with 731 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
apps/api/src/modules/contact/contact.routes.ts
Normal file
34
apps/api/src/modules/contact/contact.routes.ts
Normal 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();
|
||||
}),
|
||||
);
|
||||
50
apps/api/src/modules/contact/contact.service.ts
Normal file
50
apps/api/src/modules/contact/contact.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
`);
|
||||
|
|
|
|||
143
apps/api/test/contact.test.ts
Normal file
143
apps/api/test/contact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/web/cypress/e2e/contact-page.cy.ts
Normal file
58
apps/web/cypress/e2e/contact-page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
25
apps/web/cypress/e2e/contact.feature
Normal file
25
apps/web/cypress/e2e/contact.feature
Normal 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
|
||||
31
apps/web/cypress/e2e/contact.ts
Normal file
31
apps/web/cypress/e2e/contact.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
131
apps/web/src/pages/settings/ContactPage.tsx
Normal file
131
apps/web/src/pages/settings/ContactPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/pages/settings/contact-page.scss
Normal file
36
apps/web/src/pages/settings/contact-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
apps/web/src/pages/settings/contact.ts
Normal file
25
apps/web/src/pages/settings/contact.ts
Normal 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__,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
35
packages/shared/src/schemas/contact.ts
Normal file
35
packages/shared/src/schemas/contact.ts
Normal 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>;
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue