Compare commits
No commits in common. "9b6cee995083ef0b7e41f8e6842e31683ce1f872" and "206ab6fa0c22255e35f03ad4f93058993b946d19" have entirely different histories.
9b6cee9950
...
206ab6fa0c
23 changed files with 3 additions and 731 deletions
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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,8 +135,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -1191,37 +1189,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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";
|
||||||
|
|
@ -61,10 +60,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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,7 +49,6 @@ 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;
|
||||||
`);
|
`);
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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,7 +27,6 @@ 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";
|
||||||
|
|
@ -125,7 +124,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ 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,
|
||||||
|
|
@ -541,19 +540,6 @@ 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. */
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
AccountIcon,
|
AccountIcon,
|
||||||
AppSettingsIcon,
|
AppSettingsIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ContactIcon,
|
|
||||||
CreditsIcon,
|
CreditsIcon,
|
||||||
DietPreferencesIcon,
|
DietPreferencesIcon,
|
||||||
HouseholdIcon,
|
HouseholdIcon,
|
||||||
|
|
@ -71,7 +70,6 @@ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -436,23 +435,6 @@
|
||||||
"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": {
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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,7 +10,6 @@ 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";
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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,27 +218,6 @@ 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`.
|
||||||
|
|
|
||||||
|
|
@ -410,28 +410,6 @@ 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)
|
||||||
|
|
@ -449,7 +427,6 @@ message libre. Écrit une fois, jamais modifié ensuite ; consulté en
|
||||||
| `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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,7 @@ 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`), Nous contacter
|
utilisateur (`/parametres/preferences-utilisateur`), Crédits
|
||||||
(`/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
|
||||||
|
|
@ -236,8 +235,7 @@ 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/contact"]
|
SETTINGSMENU --> S5["/parametres/credits"]
|
||||||
SETTINGSMENU --> S6["/parametres/credits"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
|
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
|
||||||
|
|
@ -330,7 +328,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 pages dédiées
|
L'ancienne `HouseholdPage` combinée est éclatée en 5 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**
|
||||||
|
|
@ -363,14 +361,6 @@ L'ancienne `HouseholdPage` combinée est éclatée en 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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue