From 30f975d29a92d1ceb8c1b89f9879f259dff51d2f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 8 Sep 2026 20:10:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20cr=C3=A9e=20un=20ticket=20Forgej?= =?UTF-8?q?o=20depuis=20un=20message=20de=20contact=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depuis /admin/messages, un admin peut créer une issue Forgejo à partir d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie éditable → l'issue est créée via l'API Forgejo et le lien (#N) est mémorisé sur le message. Première intégration Forgejo d'apps/api. - Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage` (migration `20260908130000_contact_message_forgejo_link`, 2 colonnes nullables, aucun backfill). - Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes `.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) : bouton masqué + endpoint `503` tant que les trois ne sont pas là. Documentées dans `.env.example` ; valeurs de test dans `ci.yml`. - `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) : `isConfigured()` + `createIssue({ title, body })` → `POST .../issues`, en-tête `Authorization: token `, lit `{ number, html_url }` ; erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502 FORGEJO_REQUEST_FAILED`. - `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue` (`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo → écriture BDD ; un échec Forgejo ne touche pas la BDD) et `GET /admin/api/contact/forgejo/status`. - Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000), `ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux `ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002, `CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055) + labels i18n. - Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne « Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon), `CreateForgejoIssueModal` + helpers purs `messages.ts` (`buildIssueTitle` / `buildIssueBody`). - Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`), `admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 + ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie, lien après création, masquage si non configuré). - Docs : `backend-architecture.md`, `batch-cooking-modele.md`. Closes #81. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 7 + apps/api/.env.example | 9 + .../migration.sql | 8 + apps/api/prisma/schema.prisma | 10 ++ apps/api/src/config/env.ts | 17 ++ apps/api/src/lib/forgejo/forgejo.service.ts | 102 +++++++++++ .../src/modules/admin/admin-contact.routes.ts | 46 ++++- .../modules/admin/admin-contact.service.ts | 79 ++++++++- apps/api/test/admin-contact.test.ts | 166 ++++++++++++++++++ apps/api/test/forgejo.service.test.ts | 113 ++++++++++++ apps/web/cypress/e2e/admin-messages.cy.ts | 97 +++++++++- apps/web/src/api/admin-client.ts | 24 +++ apps/web/src/locales/fr/translation.json | 21 ++- .../messages/CreateForgejoIssueModal.tsx | 94 ++++++++++ .../src/pages/admin/messages/MessagesPage.tsx | 75 +++++++- .../pages/admin/messages/messages-page.scss | 102 +++++++++++ apps/web/src/pages/admin/messages/messages.ts | 51 ++++++ packages/shared/src/errors/error-codes.ts | 8 + packages/shared/src/schemas/contact.ts | 20 +++ packages/shared/src/types/contact.ts | 16 ++ specs/backend-architecture.md | 28 ++- specs/batch-cooking-modele.md | 5 + 22 files changed, 1077 insertions(+), 21 deletions(-) create mode 100644 apps/api/prisma/migrations/20260908130000_contact_message_forgejo_link/migration.sql create mode 100644 apps/api/src/lib/forgejo/forgejo.service.ts create mode 100644 apps/api/test/forgejo.service.test.ts create mode 100644 apps/web/src/pages/admin/messages/CreateForgejoIssueModal.tsx create mode 100644 apps/web/src/pages/admin/messages/messages.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f917566..7627c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,13 @@ env: # services/tech-step-intent-service/Dockerfile from this checkout. INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" + # Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts + # exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed + # in those tests, so no real request ever leaves CI; these values only need + # to parse (a valid URL and an `owner/repo`). + FORGEJO_BASE_URL: "https://forgejo.ci.invalid" + FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else" + FORGEJO_REPO: "ci/batchcooking" jobs: # Five independent jobs, no needs: between them — each starts in parallel diff --git a/apps/api/.env.example b/apps/api/.env.example index 9af71cb..1df662e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -35,3 +35,12 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # ADMIN_INITIAL_EMAIL=ops@example.com # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_NAME=Ops + +# Only needed to create a Forgejo issue from a contact message +# (apps/web's /admin/messages, issue #81). The button is hidden and the +# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a +# personal access token with the write:issue scope — generate it in Forgejo, +# never commit the real value. +# FORGEJO_BASE_URL=https://git.example.com +# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token +# FORGEJO_REPO=owner/repo diff --git a/apps/api/prisma/migrations/20260908130000_contact_message_forgejo_link/migration.sql b/apps/api/prisma/migrations/20260908130000_contact_message_forgejo_link/migration.sql new file mode 100644 index 0000000..075e797 --- /dev/null +++ b/apps/api/prisma/migrations/20260908130000_contact_message_forgejo_link/migration.sql @@ -0,0 +1,8 @@ +-- Link a contact message to the Forgejo issue an admin created from it +-- (issue #81). Two nullable columns, no backfill — existing rows have no +-- linked issue. + +-- AlterTable +ALTER TABLE "contact_message" + ADD COLUMN "forgejo_issue_number" INTEGER, + ADD COLUMN "forgejo_issue_url" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e9941ef..cf5026d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1220,6 +1220,16 @@ model ContactMessage { appVersion String? @map("app_version") createdAt DateTime @default(now()) @map("created_at") + /// Number of the Forgejo issue an admin created from this message via + /// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the + /// "Créer un ticket" button disappears (one message links to at most one + /// issue). + forgejoIssueNumber Int? @map("forgejo_issue_number") + /// The issue's html URL (`///issues/`) — stored + /// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays + /// correct even if that config changes later. + forgejoIssueUrl String? @map("forgejo_issue_url") + author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull) @@index([createdAt]) diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 3a717df..0c42d8e 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -143,6 +143,23 @@ const envSchema = z.object({ ADMIN_INITIAL_EMAIL: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(), ADMIN_INITIAL_NAME: z.string().optional(), + /** + * Forgejo instance + credentials for "create an issue from a contact + * message" (`/admin/messages`, issue #81). All three `.optional()` and + * checked together by `ForgejoService.isConfigured()` — the feature + * **fails closed** when any is missing: the endpoint answers `503` and + * the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` / + * `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the + * rest of the app never talks to Forgejo. + */ + FORGEJO_BASE_URL: z.string().url().optional(), + /** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */ + FORGEJO_TOKEN: z.string().optional(), + /** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */ + FORGEJO_REPO: z + .string() + .regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'") + .optional(), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/src/lib/forgejo/forgejo.service.ts b/apps/api/src/lib/forgejo/forgejo.service.ts new file mode 100644 index 0000000..bfa03ae --- /dev/null +++ b/apps/api/src/lib/forgejo/forgejo.service.ts @@ -0,0 +1,102 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode } from "@batch-cooking/shared"; +import { env } from "../../config/env.js"; +import { logger } from "../logger.service.js"; + +/** The slice of Forgejo's `POST .../issues` response this integration reads. */ +interface ForgejoIssueResponse { + number: number; + html_url: string; +} + +/** + * The one place `apps/api` talks to Forgejo — creating an issue from a + * contact message (`/admin/messages`, issue #81). A class + singleton + * (`forgejo` below), same convention as `logger` / `analytics`. + * + * **Fail-closed**: every method is a no-op / typed error unless all three + * `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check + * {@link isConfigured} first and answer `503` themselves; {@link createIssue} + * re-checks as a backstop. + */ +export class ForgejoService { + /** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */ + public isConfigured(): boolean { + return ( + env.FORGEJO_BASE_URL !== undefined && + env.FORGEJO_TOKEN !== undefined && + env.FORGEJO_REPO !== undefined + ); + } + + /** + * Creates an issue in `FORGEJO_REPO` with the given title/body (no labels + * — a deliberate #81 decision, the admin adds them in Forgejo). Returns + * the new issue's number and html URL. + * + * @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set, + * `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON + * response — the caller leaves the database untouched in that case. + */ + public async createIssue(input: { + title: string; + body: string; + }): Promise<{ number: number; htmlUrl: string }> { + if (!this.isConfigured()) { + throw new HttpError( + 503, + ErrorCode.FORGEJO_NOT_CONFIGURED, + "Forgejo integration is not configured", + ); + } + const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Forgejo/Gitea's PAT scheme — `token `, not `Bearer`. + Authorization: `token ${env.FORGEJO_TOKEN}`, + }, + body: JSON.stringify({ title: input.title, body: input.body }), + }); + } catch (err) { + logger.warn("Forgejo issue creation failed (network)", { + error: err instanceof Error ? err.message : String(err), + }); + throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed"); + } + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + logger.warn("Forgejo issue creation failed", { + status: response.status, + detail: detail.slice(0, 500), + }); + throw new HttpError( + 502, + ErrorCode.FORGEJO_REQUEST_FAILED, + `Forgejo issues API responded ${response.status}`, + ); + } + + try { + const data = (await response.json()) as ForgejoIssueResponse; + return { number: data.number, htmlUrl: data.html_url }; + } catch (err) { + logger.warn("Forgejo issue creation failed (bad response)", { + error: err instanceof Error ? err.message : String(err), + }); + throw new HttpError( + 502, + ErrorCode.FORGEJO_REQUEST_FAILED, + "Forgejo response was not valid JSON", + ); + } + } +} + +/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */ +export const forgejo = new ForgejoService(); diff --git a/apps/api/src/modules/admin/admin-contact.routes.ts b/apps/api/src/modules/admin/admin-contact.routes.ts index 05ae2a6..deaff38 100644 --- a/apps/api/src/modules/admin/admin-contact.routes.ts +++ b/apps/api/src/modules/admin/admin-contact.routes.ts @@ -1,14 +1,24 @@ +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { listContactMessagesQuerySchema } from "@batch-cooking/shared"; +import { + createForgejoIssueSchema, + ErrorCode, + listContactMessagesQuerySchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { requireAdmin } from "../../middlewares/require-admin.js"; -import { listContactMessages } from "./admin-contact.service.js"; +import { + createForgejoIssueFromMessage, + getForgejoStatus, + listContactMessages, +} from "./admin-contact.service.js"; /** * Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind - * {@link requireAdmin}. Backs the `/admin/messages` page (issue #80): a - * read-only list of the contact-form messages users have sent. No write - * routes — the first version has no triage workflow. + * {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list + * of contact-form messages (issue #80), plus "create a Forgejo issue from + * this message" (issue #81). Still no message-mutation routes — the message + * itself stays read-only. */ export const adminContactRouter = Router(); @@ -21,3 +31,29 @@ adminContactRouter.get( .json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query))); }), ); + +// Static path — declared before `/:id/...` so it's never parsed as an id. +adminContactRouter.get( + "/forgejo/status", + requireAdmin, + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(getForgejoStatus()); + }), +); + +adminContactRouter.post( + "/:id/forgejo-issue", + requireAdmin, + wrapAsyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + `Not a valid contact message id: ${req.params.id}`, + ); + } + const input = createForgejoIssueSchema.parse(req.body); + res.status(200).json(await createForgejoIssueFromMessage(id, input)); + }), +); diff --git a/apps/api/src/modules/admin/admin-contact.service.ts b/apps/api/src/modules/admin/admin-contact.service.ts index 14cc449..5748064 100644 --- a/apps/api/src/modules/admin/admin-contact.service.ts +++ b/apps/api/src/modules/admin/admin-contact.service.ts @@ -1,9 +1,14 @@ -import type { - ContactMessageAdminView, - ContactMessageType, - ListContactMessagesQuery, +import { HttpError } from "@batch-cooking/error-tools"; +import { + type ContactMessageAdminView, + type ContactMessageType, + type CreateForgejoIssueInput, + ErrorCode, + type ForgejoIntegrationStatus, + type ListContactMessagesQuery, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; +import { forgejo } from "../../lib/forgejo/forgejo.service.js"; /** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */ const CONTACT_MESSAGES_PAGE_SIZE = 200; @@ -34,6 +39,8 @@ function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminV // `null` when the sender's account was deleted after sending // (`authorId` is `onDelete: SetNull`). author: row.author, + forgejoIssueNumber: row.forgejoIssueNumber, + forgejoIssueUrl: row.forgejoIssueUrl, }; } @@ -60,3 +67,67 @@ export async function listContactMessages( throw err; } } + +/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */ +export function getForgejoStatus(): ForgejoIntegrationStatus { + return { enabled: forgejo.isConfigured() }; +} + +/** + * Creates a Forgejo issue from contact message `messageId` and records the + * link on the row (issue #81). The `title`/`body` come from the admin's + * pre-filled, edited modal. + * + * Order matters: every precondition is checked before Forgejo is called, + * and the row is only touched *after* Forgejo succeeds — a failed call + * leaves no partial state (no linked issue, button still shown). + * + * @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set), + * `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED` + * (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo + * call failed — DB untouched). + */ +export async function createForgejoIssueFromMessage( + messageId: number, + input: CreateForgejoIssueInput, +): Promise { + try { + if (!forgejo.isConfigured()) { + throw new HttpError( + 503, + ErrorCode.FORGEJO_NOT_CONFIGURED, + "Forgejo integration is not configured", + ); + } + + const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } }); + if (!existing) { + throw new HttpError( + 404, + ErrorCode.CONTACT_MESSAGE_NOT_FOUND, + `Contact message ${messageId} not found`, + ); + } + if (existing.forgejoIssueNumber !== null) { + throw new HttpError( + 409, + ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED, + `Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`, + ); + } + + // Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below, + // before the row is touched. + const issue = await forgejo.createIssue({ title: input.title, body: input.body }); + + const updated = await prisma.contactMessage.update({ + where: { id: messageId }, + data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl }, + include: contactMessageInclude, + }); + return toContactMessageAdminView(updated); + } catch (err) { + // Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch. + throw err; + } +} diff --git a/apps/api/test/admin-contact.test.ts b/apps/api/test/admin-contact.test.ts index b3ec8ab..c010dcd 100644 --- a/apps/api/test/admin-contact.test.ts +++ b/apps/api/test/admin-contact.test.ts @@ -1,3 +1,4 @@ +import { ErrorCode } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; import request from "supertest"; @@ -49,6 +50,10 @@ async function seedMessage(overrides: { } const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; +const forgejoConfigured = + env.FORGEJO_BASE_URL !== undefined && + env.FORGEJO_TOKEN !== undefined && + env.FORGEJO_REPO !== undefined; describe("Admin contact messages", () => { const app = createApp(); @@ -177,4 +182,165 @@ describe("Admin contact messages", () => { expect(res.body[0].message).to.equal("signalé puis compte supprimé"); expect(res.body[0].author).to.equal(null); }); + + describe("Forgejo issue creation (#81)", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + /** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */ + function stubForgejoFetch(body: unknown, status = 201) { + globalThis.fetch = (async () => + new Response(JSON.stringify(body), { status })) as typeof fetch; + } + + it("returns 401 without an admin session", async () => { + const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({ + title: "t", + body: "b", + }); + expect(res.status).to.equal(401); + }); + + it("exposes the integration status", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const agent = await adminAgent(); + const res = await agent.get("/admin/api/contact/forgejo/status"); + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ enabled: forgejoConfigured }); + }); + + it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () { + if (!adminSecretConfigured || forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + const message = await seedMessage({ authorId: user.id }); + const agent = await adminAgent(); + + const res = await agent + .post(`/admin/api/contact/${message.id}/forgejo-issue`) + .send({ title: "Titre", body: "Corps du ticket" }); + + expect(res.status).to.equal(503); + expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED); + }); + + it("creates the issue and records the link on the message", async function () { + if (!adminSecretConfigured || !forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" }); + const user = await seedUser(); + const message = await seedMessage({ authorId: user.id, message: "un bug précis" }); + const agent = await adminAgent(); + + const res = await agent + .post(`/admin/api/contact/${message.id}/forgejo-issue`) + .send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" }); + + expect(res.status).to.equal(200); + expect(res.body.forgejoIssueNumber).to.equal(77); + expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77"); + + const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } }); + expect(row.forgejoIssueNumber).to.equal(77); + expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77"); + }); + + it("responds 404 for an unknown message id", async function () { + if (!adminSecretConfigured || !forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubForgejoFetch({ number: 1, html_url: "x" }); + const agent = await adminAgent(); + + const res = await agent + .post("/admin/api/contact/999999/forgejo-issue") + .send({ title: "Titre", body: "Corps" }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND); + }); + + it("responds 409 when the message already links to an issue", async function () { + if (!adminSecretConfigured || !forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubForgejoFetch({ number: 2, html_url: "x" }); + const user = await seedUser(); + const message = await seedMessage({ authorId: user.id }); + await prisma.contactMessage.update({ + where: { id: message.id }, + data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" }, + }); + const agent = await adminAgent(); + + const res = await agent + .post(`/admin/api/contact/${message.id}/forgejo-issue`) + .send({ title: "Titre", body: "Corps" }); + + expect(res.status).to.equal(409); + expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED); + }); + + it("leaves the message untouched when the Forgejo call fails (502)", async function () { + if (!adminSecretConfigured || !forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubForgejoFetch("upstream boom", 500); + const user = await seedUser(); + const message = await seedMessage({ authorId: user.id }); + const agent = await adminAgent(); + + const res = await agent + .post(`/admin/api/contact/${message.id}/forgejo-issue`) + .send({ title: "Titre", body: "Corps" }); + + expect(res.status).to.equal(502); + expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED); + + const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } }); + expect(row.forgejoIssueNumber).to.equal(null); + expect(row.forgejoIssueUrl).to.equal(null); + }); + + it("rejects an empty title with 400", async function () { + if (!adminSecretConfigured || !forgejoConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const user = await seedUser(); + const message = await seedMessage({ authorId: user.id }); + const agent = await adminAgent(); + + const res = await agent + .post(`/admin/api/contact/${message.id}/forgejo-issue`) + .send({ title: " ", body: "Corps" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + }); }); diff --git a/apps/api/test/forgejo.service.test.ts b/apps/api/test/forgejo.service.test.ts new file mode 100644 index 0000000..17cd8f8 --- /dev/null +++ b/apps/api/test/forgejo.service.test.ts @@ -0,0 +1,113 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode } from "@batch-cooking/shared"; +import { expect } from "chai"; +import { env } from "../src/config/env.js"; +import { forgejo } from "../src/lib/forgejo/forgejo.service.js"; + +/** + * Stubs `globalThis.fetch` for one test — same approach as + * `recipe-sources/the-meal-db.test.ts` (no HTTP-mock library in this repo). + * `capture` collects the `(url, init)` each call was made with, so a test + * can assert the request shape. Restored by `afterEach`. + */ +function stubFetch( + responder: (url: string, init: RequestInit | undefined) => Response | Promise, + capture: { url: string; init: RequestInit | undefined }[], +) { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + capture.push({ url, init }); + return responder(url, init); + }) as typeof fetch; +} + +const configured = forgejo.isConfigured(); + +describe("ForgejoService", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("isConfigured() reports whether all three FORGEJO_* vars are set", () => { + expect(forgejo.isConfigured()).to.equal( + env.FORGEJO_BASE_URL !== undefined && + env.FORGEJO_TOKEN !== undefined && + env.FORGEJO_REPO !== undefined, + ); + }); + + describe("createIssue", () => { + it("POSTs to the repo's issues endpoint with a `token` auth header and { title, body }", async function () { + if (!configured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const calls: { url: string; init: RequestInit | undefined }[] = []; + stubFetch( + () => + new Response(JSON.stringify({ number: 42, html_url: "https://forge/x/y/issues/42" }), { + status: 201, + }), + calls, + ); + + const result = await forgejo.createIssue({ title: "Un titre", body: "Un corps" }); + + expect(result).to.deep.equal({ number: 42, htmlUrl: "https://forge/x/y/issues/42" }); + expect(calls).to.have.lengthOf(1); + const [call] = calls; + expect(call.url).to.equal(`${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`); + expect(call.init?.method).to.equal("POST"); + const headers = call.init?.headers as Record; + expect(headers.Authorization).to.equal(`token ${env.FORGEJO_TOKEN}`); + expect(JSON.parse(String(call.init?.body))).to.deep.equal({ + title: "Un titre", + body: "Un corps", + }); + }); + + it("throws 502 FORGEJO_REQUEST_FAILED on a non-2xx response", async function () { + if (!configured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubFetch(() => new Response("nope", { status: 403 }), []); + + try { + await forgejo.createIssue({ title: "t", body: "b" }); + expect.fail("expected createIssue to throw"); + } catch (err) { + expect(err).to.be.instanceOf(HttpError); + expect((err as HttpError).status).to.equal(502); + expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED); + } + }); + + it("throws 502 FORGEJO_REQUEST_FAILED on a network error", async function () { + if (!configured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + stubFetch(() => { + throw new Error("ECONNREFUSED"); + }, []); + + try { + await forgejo.createIssue({ title: "t", body: "b" }); + expect.fail("expected createIssue to throw"); + } catch (err) { + expect(err).to.be.instanceOf(HttpError); + expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED); + } + }); + }); +}); diff --git a/apps/web/cypress/e2e/admin-messages.cy.ts b/apps/web/cypress/e2e/admin-messages.cy.ts index c411264..feed386 100644 --- a/apps/web/cypress/e2e/admin-messages.cy.ts +++ b/apps/web/cypress/e2e/admin-messages.cy.ts @@ -1,6 +1,6 @@ // Mocks the admin API via cy.intercept — no live backend (see // .github/workflows/ci.yml); apps/api's Mocha suite covers -// `GET /admin/api/contact` against a real database. +// `GET /admin/api/contact` and the Forgejo routes against a real database. const adminBody = { id: 1, @@ -10,6 +10,10 @@ const adminBody = { lastLoginAt: "2026-08-28T09:00:00.000Z", }; +// Matches the list endpoint (`/contact`, `/contact?type=…`) but NOT the +// nested `/contact/forgejo/status` or `/contact/:id/forgejo-issue`. +const LIST_URL = /\/admin\/api\/contact(\?|$)/; + function messages() { return [ { @@ -21,6 +25,8 @@ function messages() { appVersion: "0.2.1", createdAt: "2026-09-07T10:00:00.000Z", author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" }, + forgejoIssueNumber: null, + forgejoIssueUrl: null, }, { id: 1, @@ -31,6 +37,8 @@ function messages() { appVersion: null, createdAt: "2026-09-06T08:30:00.000Z", author: null, + forgejoIssueNumber: null, + forgejoIssueUrl: null, }, ]; } @@ -39,12 +47,15 @@ describe("Admin contact messages (/admin/messages)", () => { beforeEach(() => { cy.viewport(1400, 1000); cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); + // Default: Forgejo integration off — individual tests override. + cy.intercept("GET", "**/admin/api/contact/forgejo/status", { + statusCode: 200, + body: { enabled: false }, + }).as("forgejoStatus"); }); it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => { - cy.intercept("GET", "**/admin/api/contact*", { statusCode: 200, body: messages() }).as( - "getMessages", - ); + cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages"); cy.visit("/admin/messages"); cy.wait("@getMessages"); @@ -52,8 +63,9 @@ describe("Admin contact messages (/admin/messages)", () => { cy.get(".messages-table tbody tr").should("have.length", 2); cy.contains(".messages-table tr", "page planning").should("contain.text", "alice@example.com"); cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé"); - // No triage controls on the page. - cy.get(".messages-table button").should("not.exist"); + // Integration off ⇒ no create button, and the "not configured" note shows. + cy.get(".messages-table__ticket-button").should("not.exist"); + cy.contains(".messages-forgejo-note", "non configurée").should("be.visible"); }); it("filters by type through the query param", () => { @@ -71,4 +83,77 @@ describe("Admin contact messages (/admin/messages)", () => { cy.get(".messages-table tbody tr").should("have.length", 1); cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible"); }); + + it("creates a Forgejo issue from a message through the pre-filled modal", () => { + cy.intercept("GET", "**/admin/api/contact/forgejo/status", { + statusCode: 200, + body: { enabled: true }, + }).as("forgejoStatus"); + cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages"); + cy.intercept("POST", "**/admin/api/contact/2/forgejo-issue", { + statusCode: 200, + body: { + ...messages()[0], + forgejoIssueNumber: 101, + forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/101", + }, + }).as("createIssue"); + + cy.visit("/admin/messages"); + cy.wait("@getMessages"); + + cy.contains(".messages-table tr", "page planning") + .find(".messages-table__ticket-button") + .click(); + + // Modal opens pre-filled from the message. + cy.get("#forgejoIssueTitle").should( + "have.value", + "[Bug] Le bouton Envoyer ne réagit pas sur la page planning.", + ); + cy.get("#forgejoIssueBody").should("contain.value", "Signalé par"); + + cy.contains(".dialog-panel__footer button", "Créer le ticket").click(); + cy.wait("@createIssue").its("request.body").should("have.keys", ["title", "body"]); + + // Modal closed, the row now shows the link instead of the button. + cy.get("#forgejoIssueTitle").should("not.exist"); + cy.contains(".messages-table tr", "page planning") + .find("a") + .should("have.attr", "href", "https://git.example/kyuno/batchCooking/issues/101") + .and("contain.text", "#101"); + }); + + it("shows the existing link and no create button for an already-linked message", () => { + cy.intercept("GET", "**/admin/api/contact/forgejo/status", { + statusCode: 200, + body: { enabled: true }, + }); + const linked = messages().map((m) => + m.id === 2 + ? { + ...m, + forgejoIssueNumber: 55, + forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/55", + } + : m, + ); + cy.intercept("GET", LIST_URL, { statusCode: 200, body: linked }).as("getMessages"); + + cy.visit("/admin/messages"); + cy.wait("@getMessages"); + + cy.contains(".messages-table tr", "page planning").within(() => { + cy.contains("a", "#55").should( + "have.attr", + "href", + "https://git.example/kyuno/batchCooking/issues/55", + ); + cy.get(".messages-table__ticket-button").should("not.exist"); + }); + // The still-unlinked suggestion row keeps its button. + cy.contains(".messages-table tr", "mode hors-ligne") + .find(".messages-table__ticket-button") + .should("be.visible"); + }); }); diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index e031bff..86acb03 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -6,8 +6,10 @@ import { type CatalogPlaceholderGroupView, type ContactMessageAdminView, type CorrectionAdminView, + type CreateForgejoIssueInput, type CreateTourScenarioInput, ErrorCode, + type ForgejoIntegrationStatus, type MarkPlaceholdersReviewedInput, type MetricsView, type PruneOrphansResultView, @@ -229,6 +231,28 @@ export class AdminApiClient { public getContactMessages(filters: { type?: string }): Promise { return this._request(`/contact${query(filters)}`); } + + /** Whether the "create a Forgejo issue from a message" feature is configured (issue #81) — drives whether the button is shown. */ + public getForgejoStatus(): Promise { + return this._request("/contact/forgejo/status"); + } + + /** + * Creates a Forgejo issue from contact message `id` with the admin's + * edited `title`/`body` (issue #81), and returns the message with its new + * `forgejoIssueNumber` / `forgejoIssueUrl`. Rejects with + * `FORGEJO_NOT_CONFIGURED` / `CONTACT_MESSAGE_ALREADY_LINKED` / + * `FORGEJO_REQUEST_FAILED`. + */ + public createForgejoIssue( + id: number, + input: CreateForgejoIssueInput, + ): Promise { + return this._request(`/contact/${id}/forgejo-issue`, { + method: "POST", + body: JSON.stringify(input), + }); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 118c460..3495dd6 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -57,7 +57,11 @@ "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", - "LEFTOVER_QUANTITY_EXCEEDED": "Il ne reste plus assez de portions disponibles" + "LEFTOVER_QUANTITY_EXCEEDED": "Il ne reste plus assez de portions disponibles", + "CONTACT_MESSAGE_NOT_FOUND": "Ce message n'existe pas", + "CONTACT_MESSAGE_ALREADY_LINKED": "Un ticket est déjà lié à ce message", + "FORGEJO_NOT_CONFIGURED": "L'intégration Forgejo n'est pas configurée", + "FORGEJO_REQUEST_FAILED": "La création du ticket Forgejo a échoué, réessayez plus tard" }, "auth": { "login": { @@ -1349,7 +1353,20 @@ "type": "Type", "message": "Message", "page": "Page", - "userAgent": "Client" + "userAgent": "Client", + "ticket": "Ticket" + }, + "forgejo": { + "notConfigured": "Intégration Forgejo non configurée", + "createButton": "Créer un ticket", + "linkTitle": "Ouvrir le ticket Forgejo", + "modalTitle": "Créer un ticket Forgejo", + "titleLabel": "Titre", + "bodyLabel": "Corps", + "submit": "Créer le ticket", + "submitting": "Création…", + "cancel": "Annuler", + "success": "Ticket #{{number}} créé" } }, "catalog": { diff --git a/apps/web/src/pages/admin/messages/CreateForgejoIssueModal.tsx b/apps/web/src/pages/admin/messages/CreateForgejoIssueModal.tsx new file mode 100644 index 0000000..b8c16e8 --- /dev/null +++ b/apps/web/src/pages/admin/messages/CreateForgejoIssueModal.tsx @@ -0,0 +1,94 @@ +import { type ContactMessageAdminView, ErrorCode } from "@batch-cooking/shared"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApiError, adminApiClient } from "../../../api/admin-client"; +import { Dialog } from "../../../components/ui/Dialog"; +import { errorMessageService } from "../../../services/error-message.service"; +import { buildIssueBody, buildIssueTitle } from "./messages"; + +/** + * Pre-filled, editable "create a Forgejo issue from this message" modal + * (issue #81). Title/body start from {@link buildIssueTitle} / + * {@link buildIssueBody}; the admin adjusts both, then confirms. On success + * the parent swaps the row for the returned {@link ContactMessageAdminView} + * (now carrying `forgejoIssueNumber` / `forgejoIssueUrl`) and closes this. + */ +export function CreateForgejoIssueModal({ + message, + onClose, + onCreated, +}: { + message: ContactMessageAdminView; + onClose: () => void; + onCreated: (updated: ContactMessageAdminView) => void; +}) { + const { t } = useTranslation(); + const [title, setTitle] = useState(() => buildIssueTitle(message)); + const [body, setBody] = useState(() => buildIssueBody(message)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const canSubmit = title.trim().length > 0 && body.trim().length > 0 && !busy; + + async function submit() { + setBusy(true); + setError(null); + try { + const updated = await adminApiClient.createForgejoIssue(message.id, { + title: title.trim(), + body: body.trim(), + }); + // Parent unmounts this modal via `onCreated` — no need to clear `busy`. + onCreated(updated); + } catch (err) { + setError( + errorMessageService.getLabel( + err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR, + ), + ); + setBusy(false); + } + } + + return ( + + + + + } + > + + setTitle(event.target.value)} + /> + + +