feat(admin): crée un ticket Forgejo depuis un message de contact (#81)
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build (push) Successful in 3m29s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 10m24s
CI / test (push) Successful in 18m48s
CI / build-android (push) Has been skipped

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 <pat>`, 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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-08 20:10:54 +02:00
parent 71758e6d25
commit 30f975d29a
22 changed files with 1077 additions and 21 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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 (`<base>/<owner>/<repo>/issues/<n>`) — 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])

View file

@ -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. */

View file

@ -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 <pat>`, 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();

View file

@ -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));
}),
);

View file

@ -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<ContactMessageAdminView> {
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;
}
}

View file

@ -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);
});
});
});

View file

@ -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<Response>,
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<string, string>;
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);
}
});
});
});

View file

@ -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");
});
});

View file

@ -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<ContactMessageAdminView[]> {
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<ForgejoIntegrationStatus> {
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<ContactMessageAdminView> {
return this._request(`/contact/${id}/forgejo-issue`, {
method: "POST",
body: JSON.stringify(input),
});
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -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": {

View file

@ -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<string | null>(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 (
<Dialog
title={t("admin.messages.forgejo.modalTitle")}
onClose={onClose}
className="forgejo-issue-modal"
footer={
<>
<button type="button" onClick={onClose} disabled={busy}>
{t("admin.messages.forgejo.cancel")}
</button>
<button
type="button"
className="forgejo-issue-modal__submit"
onClick={() => void submit()}
disabled={!canSubmit}
>
{busy ? t("admin.messages.forgejo.submitting") : t("admin.messages.forgejo.submit")}
</button>
</>
}
>
<label htmlFor="forgejoIssueTitle">{t("admin.messages.forgejo.titleLabel")}</label>
<input
id="forgejoIssueTitle"
type="text"
value={title}
maxLength={255}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="forgejoIssueBody">{t("admin.messages.forgejo.bodyLabel")}</label>
<textarea
id="forgejoIssueBody"
value={body}
rows={16}
onChange={(event) => setBody(event.target.value)}
/>
{error && <p className="field-error">{error}</p>}
</Dialog>
);
}

View file

@ -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<MessagesState>({ status: "loading" });
// `null` until the status probe resolves — the ticket column shows nothing
// actionable in the meantime.
const [forgejoEnabled, setForgejoEnabled] = useState<boolean | null>(null);
const [modalFor, setModalFor] = useState<ContactMessageAdminView | null>(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 (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.messages.title")}</h1>
@ -54,6 +91,9 @@ export function MessagesPage() {
))}
</select>
</label>
{forgejoEnabled === false && (
<p className="messages-forgejo-note">{t("admin.messages.forgejo.notConfigured")}</p>
)}
</div>
{state.status === "loading" && (
@ -76,6 +116,7 @@ export function MessagesPage() {
<th>{t("admin.messages.col.message")}</th>
<th>{t("admin.messages.col.page")}</th>
<th>{t("admin.messages.col.userAgent")}</th>
<th>{t("admin.messages.col.ticket")}</th>
</tr>
</thead>
<tbody>
@ -107,12 +148,42 @@ export function MessagesPage() {
<td className="messages-table__ua" title={row.userAgent ?? undefined}>
{row.userAgent ?? "—"}
</td>
<td className="messages-table__ticket">
{row.forgejoIssueNumber !== null && row.forgejoIssueUrl !== null ? (
<a
href={row.forgejoIssueUrl}
target="_blank"
rel="noreferrer"
title={t("admin.messages.forgejo.linkTitle")}
>
#{row.forgejoIssueNumber}
</a>
) : forgejoEnabled ? (
<button
type="button"
className="messages-table__ticket-button"
onClick={() => setModalFor(row)}
>
{t("admin.messages.forgejo.createButton")}
</button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modalFor && (
<CreateForgejoIssueModal
message={modalFor}
onClose={() => setModalFor(null)}
onCreated={applyCreatedIssue}
/>
)}
</div>
);
}

View file

@ -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 {

View file

@ -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<ContactMessageAdminView["type"], string> = {
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] <first non-empty line of the message, trimmed>`. */
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");
}

View file

@ -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,
}
/**

View file

@ -42,3 +42,23 @@ export const listContactMessagesQuerySchema = z.object({
type: z.enum(CONTACT_MESSAGE_TYPES).optional(),
});
export type ListContactMessagesQuery = z.infer<typeof listContactMessagesQuerySchema>;
/**
* 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<typeof createForgejoIssueSchema>;

View file

@ -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;
}

View file

@ -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`.
---

View file

@ -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.
---