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 ( + + + {t("admin.messages.forgejo.cancel")} + + void submit()} + disabled={!canSubmit} + > + {busy ? t("admin.messages.forgejo.submitting") : t("admin.messages.forgejo.submit")} + + > + } + > + {t("admin.messages.forgejo.titleLabel")} + setTitle(event.target.value)} + /> + + {t("admin.messages.forgejo.bodyLabel")} + setBody(event.target.value)} + /> + + {error && {error}} + + ); +} diff --git a/apps/web/src/pages/admin/messages/MessagesPage.tsx b/apps/web/src/pages/admin/messages/MessagesPage.tsx index dfa0408..728d668 100644 --- a/apps/web/src/pages/admin/messages/MessagesPage.tsx +++ b/apps/web/src/pages/admin/messages/MessagesPage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { adminApiClient } from "../../../api/admin-client"; import "../admin-page.scss"; import "./messages-page.scss"; +import { CreateForgejoIssueModal } from "./CreateForgejoIssueModal"; type MessagesState = | { status: "loading" } @@ -13,13 +14,36 @@ type MessagesState = /** * Contact-form messages (issue #80) — a read-only list of the bug reports / * suggestions users have sent from `/parametres/contact`, newest first, - * capped server-side at 200. One optional filter (by `type`); no actions, - * no triage workflow in this first version. + * capped server-side at 200. One optional filter (by `type`). + * + * Per row (issue #81), when the `FORGEJO_*` integration is configured: a + * "Créer un ticket" button that opens a pre-filled modal and, on success, + * turns into a `#N` link to the created Forgejo issue. The message itself + * stays read-only — the link is the only state a creation adds. */ export function MessagesPage() { const { t } = useTranslation(); const [type, setType] = useState(""); const [state, setState] = useState({ status: "loading" }); + // `null` until the status probe resolves — the ticket column shows nothing + // actionable in the meantime. + const [forgejoEnabled, setForgejoEnabled] = useState(null); + const [modalFor, setModalFor] = useState(null); + + useEffect(() => { + let cancelled = false; + adminApiClient + .getForgejoStatus() + .then((status) => { + if (!cancelled) setForgejoEnabled(status.enabled); + }) + .catch(() => { + if (!cancelled) setForgejoEnabled(false); + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { let cancelled = false; @@ -37,6 +61,19 @@ export function MessagesPage() { }; }, [type]); + /** Swap one row in place after a Forgejo issue is created from it. */ + function applyCreatedIssue(updated: ContactMessageAdminView) { + setState((prev) => + prev.status === "loaded" + ? { + status: "loaded", + rows: prev.rows.map((row) => (row.id === updated.id ? updated : row)), + } + : prev, + ); + setModalFor(null); + } + return ( {t("admin.messages.title")} @@ -54,6 +91,9 @@ export function MessagesPage() { ))} + {forgejoEnabled === false && ( + {t("admin.messages.forgejo.notConfigured")} + )} {state.status === "loading" && ( @@ -76,6 +116,7 @@ export function MessagesPage() { {t("admin.messages.col.message")} {t("admin.messages.col.page")} {t("admin.messages.col.userAgent")} + {t("admin.messages.col.ticket")} @@ -107,12 +148,42 @@ export function MessagesPage() { {row.userAgent ?? "—"} + + {row.forgejoIssueNumber !== null && row.forgejoIssueUrl !== null ? ( + + #{row.forgejoIssueNumber} + + ) : forgejoEnabled ? ( + setModalFor(row)} + > + {t("admin.messages.forgejo.createButton")} + + ) : ( + "—" + )} + ))} )} + + {modalFor && ( + setModalFor(null)} + onCreated={applyCreatedIssue} + /> + )} ); } diff --git a/apps/web/src/pages/admin/messages/messages-page.scss b/apps/web/src/pages/admin/messages/messages-page.scss index 5795986..7a9727a 100644 --- a/apps/web/src/pages/admin/messages/messages-page.scss +++ b/apps/web/src/pages/admin/messages/messages-page.scss @@ -86,6 +86,108 @@ color: var(--color-text-muted); font-size: var(--font-size-xs); } + + &__ticket { + white-space: nowrap; + } + + // "Créer un ticket" — a compact secondary action, same language as the + // corrections table's inline buttons. + &__ticket-button { + padding: var(--space-xs) var(--space-sm); + font-family: var(--font-body); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + border-color: var(--color-primary); + color: var(--color-primary); + } + } +} + +// "Intégration Forgejo non configurée" — a quiet note next to the filter, +// shown only when the FORGEJO_* env isn't set. +.messages-forgejo-note { + margin: 0; + align-self: end; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + font-style: italic; +} + +.messages-badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: var(--radius-base); + font-size: var(--font-size-xs); + font-weight: 600; + background: var(--color-surface-alt); + color: var(--color-text); + border-left: 3px solid var(--color-border); + + &--bug { + border-left-color: var(--color-error); + } + &--suggestion { + border-left-color: var(--color-accent); + } + &--other { + border-left-color: var(--color-border); + } +} + +// Create-issue modal — the `Dialog` primitive supplies the panel/scroll; +// this only sizes the two fields and the footer actions. +.forgejo-issue-modal { + &.dialog-panel { + max-width: 44rem; + width: 100%; + } + + label { + display: block; + margin: var(--space-sm) 0 var(--space-xs); + font-size: var(--font-size-sm); + font-weight: 600; + } + + input, + textarea { + width: 100%; + padding: var(--space-sm); + font-family: var(--font-body); + font-size: var(--font-size-sm); + color: var(--color-text); + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + } + + textarea { + font-family: var(--font-mono); + resize: vertical; + min-height: 12rem; + } + + .field-error { + margin-top: var(--space-sm); + } + + &__submit { + color: #fff; + background: var(--color-primary); + border-color: var(--color-primary); + + &:hover:not(:disabled) { + opacity: 0.9; + } + } } .messages-badge { diff --git a/apps/web/src/pages/admin/messages/messages.ts b/apps/web/src/pages/admin/messages/messages.ts new file mode 100644 index 0000000..77ce19e --- /dev/null +++ b/apps/web/src/pages/admin/messages/messages.ts @@ -0,0 +1,51 @@ +import type { ContactMessageAdminView } from "@batch-cooking/shared"; + +// Pure helpers for MessagesPage's "create a Forgejo issue" modal (issue +// #81) — kept out of the .tsx so they're unit-testable and the component +// stays declarative. They produce the *pre-filled* title/body; the admin +// edits both before confirming, so these are a sensible starting point, +// not a final format. French strings are inlined on purpose: the output is +// a payload for a French-language Forgejo repo, not localized app UI. + +const TYPE_PREFIX: Record = { + bug: "[Bug]", + suggestion: "[Suggestion]", + other: "[Autre]", +}; + +/** Comfortably under Forgejo's 255-char title cap — the admin can still lengthen it. */ +const TITLE_MAX = 100; + +/** `[Bug] `. */ +export function buildIssueTitle(message: ContactMessageAdminView): string { + const firstLine = + message.message + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? message.message.trim(); + const prefix = TYPE_PREFIX[message.type]; + const room = TITLE_MAX - prefix.length - 1; + const snippet = firstLine.length > room ? `${firstLine.slice(0, room - 1)}…` : firstLine; + return `${prefix} ${snippet}`; +} + +/** The message text, then a `---` block of triage context, then a footer line. */ +export function buildIssueBody(message: ContactMessageAdminView): string { + const sender = message.author + ? `${message.author.firstName} ${message.author.lastName} <${message.author.email}>` + : "compte supprimé"; + return [ + message.message.trim(), + "", + "---", + "", + `**Signalé par :** ${sender}`, + `**Type :** ${message.type}`, + `**Page :** ${message.pageUrl ?? "—"}`, + `**Version de l'app :** ${message.appVersion ?? "—"}`, + `**Navigateur :** ${message.userAgent ?? "—"}`, + `**Reçu le :** ${new Date(message.createdAt).toLocaleString("fr-FR")}`, + "", + "🤖 Créé depuis le formulaire de contact", + ].join("\n"); +} diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index b78ef66..28aba11 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -81,10 +81,18 @@ export enum ErrorCode { TOUR_TARGET_NOT_FOUND = 4054, /** `POST /admin/api/tours` was given a `key` already used by another scenario. */ TOUR_KEY_ALREADY_IN_USE = 4025, + /** `POST /admin/api/contact/:id/forgejo-issue` on a message that already has a linked Forgejo issue (`forgejoIssueNumber` set). */ + CONTACT_MESSAGE_ALREADY_LINKED = 4026, /** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */ INVALID_CORRECTION_SPAN = 4002, + /** `POST /admin/api/contact/:id/forgejo-issue` (or a future contact-message admin route) given an id that doesn't match any `ContactMessage`. */ + CONTACT_MESSAGE_NOT_FOUND = 4055, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, + /** `POST /admin/api/contact/:id/forgejo-issue` while the `FORGEJO_*` env vars aren't all set — the integration fails closed (see `ForgejoService`). */ + FORGEJO_NOT_CONFIGURED = 5001, + /** The call to Forgejo's issue API failed — network error or a non-2xx response. */ + FORGEJO_REQUEST_FAILED = 5002, } /** diff --git a/packages/shared/src/schemas/contact.ts b/packages/shared/src/schemas/contact.ts index e4d588f..ab3c07c 100644 --- a/packages/shared/src/schemas/contact.ts +++ b/packages/shared/src/schemas/contact.ts @@ -42,3 +42,23 @@ export const listContactMessagesQuerySchema = z.object({ type: z.enum(CONTACT_MESSAGE_TYPES).optional(), }); export type ListContactMessagesQuery = z.infer; + +/** + * Body of `POST /admin/api/contact/:id/forgejo-issue` (issue #81). The + * admin edits both fields in a pre-filled modal before confirming — the + * server sends them to Forgejo verbatim. `title`'s 255 ceiling is Forgejo's + * own issue-title limit. + */ +export const createForgejoIssueSchema = z.object({ + title: z + .string() + .trim() + .min(1, "Le titre est obligatoire") + .max(255, "Le titre ne peut pas dépasser 255 caractères"), + body: z + .string() + .trim() + .min(1, "Le corps est obligatoire") + .max(60000, "Le corps ne peut pas dépasser 60000 caractères"), +}); +export type CreateForgejoIssueInput = z.infer; diff --git a/packages/shared/src/types/contact.ts b/packages/shared/src/types/contact.ts index 3939931..0513ff1 100644 --- a/packages/shared/src/types/contact.ts +++ b/packages/shared/src/types/contact.ts @@ -22,4 +22,20 @@ export interface ContactMessageAdminView { lastName: string; email: string; } | null; + /** + * The Forgejo issue an admin created from this message (issue #81), or + * `null` if none yet. Both fields are set together. `forgejoIssueUrl` is + * the issue's html URL, stored verbatim. + */ + forgejoIssueNumber: number | null; + forgejoIssueUrl: string | null; +} + +/** + * Whether the "create a Forgejo issue from a message" feature is usable — + * `GET /admin/api/contact/forgejo/status` (issue #81). `false` when the + * `FORGEJO_*` env vars aren't all set; the admin UI then hides the button. + */ +export interface ForgejoIntegrationStatus { + enabled: boolean; } diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index a10bfdc..3fdcd77 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -533,8 +533,32 @@ ci-dessus) : champs du message + `author: { id, firstName, lastName, email } | null` (`null` = compte supprimé depuis l'envoi, `authorId` étant `onDelete: SetNull`). -- **Lecture seule** : pas de route d'écriture, pas de workflow de traitement - (statut / assignation / note) dans cette première version. +- **Lecture seule** côté message : le message lui-même n'est jamais muté, + pas de statut / assignation / note. +- `POST /admin/api/contact/:id/forgejo-issue` (issue #81) — crée une issue + Forgejo depuis le message (`title` / `body` du schéma + `createForgejoIssueSchema`, édités par l'admin dans une modale + pré-remplie ; **aucun label** — l'admin les pose ensuite dans Forgejo) et + stocke `forgejoIssueNumber` / `forgejoIssueUrl` sur la ligne. Ordre : + `503 FORGEJO_NOT_CONFIGURED` → `404 CONTACT_MESSAGE_NOT_FOUND` → + `409 CONTACT_MESSAGE_ALREADY_LINKED` → appel Forgejo → écriture BDD. Un + échec de l'appel (`502 FORGEJO_REQUEST_FAILED`) **ne touche pas la BDD** + (pas d'état partiel). Renvoie le `ContactMessageAdminView` à jour. +- `GET /admin/api/contact/forgejo/status` → `{ enabled }` — pilote + l'affichage du bouton côté admin. + +**`ForgejoService`** (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`, +issue #81) — **première et seule** intégration Forgejo d'`apps/api`. +`isConfigured()` = les trois `FORGEJO_*` présentes ; `createIssue({ title, +body })` fait `POST {FORGEJO_BASE_URL}/api/v1/repos/{FORGEJO_REPO}/issues` +avec l'en-tête `Authorization: token {FORGEJO_TOKEN}` (schéma PAT de +Forgejo, pas `Bearer`), lit `{ number, html_url }`. Via `fetch` global (même +approche que `mobile.service.ts` / les adaptateurs de sources ; les tests +réassignent `globalThis.fetch`). Toute erreur réseau / réponse non-2xx / +JSON invalide ⇒ `HttpError 502 FORGEJO_REQUEST_FAILED`. **Fail-closed** : +sans les trois variables d'env (`FORGEJO_BASE_URL` `.url()`, `FORGEJO_TOKEN` +secret, `FORGEJO_REPO` `owner/repo`, toutes `.optional()` — même posture que +`ADMIN_JWT_SECRET`), le bouton est masqué et l'endpoint répond `503`. --- diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index 2ac6712..984a4dc 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -428,6 +428,11 @@ message libre. Écrit une fois, jamais modifié ensuite ; consulté en - `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. +- `forgejoIssueNumber` / `forgejoIssueUrl` (issue #81) : nullables, posés + ensemble quand un admin crée une issue Forgejo depuis le message + (`POST /admin/api/contact/:id/forgejo-issue`). Non-null ⇒ le bouton + « Créer un ticket » disparaît (un message ⇒ au plus une issue). + `forgejoIssueUrl` est l'URL html stockée telle quelle. - `@@index([createdAt])` — la vue admin trie par date décroissante. ---
{error}
{t("admin.messages.forgejo.notConfigured")}