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. # services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" 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: jobs:
# Five independent jobs, no needs: between them — each starts in parallel # 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_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops # 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") appVersion String? @map("app_version")
createdAt DateTime @default(now()) @map("created_at") 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) author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([createdAt]) @@index([createdAt])

View file

@ -143,6 +143,23 @@ const envSchema = z.object({
ADMIN_INITIAL_EMAIL: z.string().optional(), ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(), ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: 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. */ /** 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 { 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 { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js"; 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 * Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
* {@link requireAdmin}. Backs the `/admin/messages` page (issue #80): a * {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
* read-only list of the contact-form messages users have sent. No write * of contact-form messages (issue #80), plus "create a Forgejo issue from
* routes — the first version has no triage workflow. * this message" (issue #81). Still no message-mutation routes — the message
* itself stays read-only.
*/ */
export const adminContactRouter = Router(); export const adminContactRouter = Router();
@ -21,3 +31,29 @@ adminContactRouter.get(
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query))); .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 { import { HttpError } from "@batch-cooking/error-tools";
ContactMessageAdminView, import {
ContactMessageType, type ContactMessageAdminView,
ListContactMessagesQuery, type ContactMessageType,
type CreateForgejoIssueInput,
ErrorCode,
type ForgejoIntegrationStatus,
type ListContactMessagesQuery,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; 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`). */ /** 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; 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 // `null` when the sender's account was deleted after sending
// (`authorId` is `onDelete: SetNull`). // (`authorId` is `onDelete: SetNull`).
author: row.author, author: row.author,
forgejoIssueNumber: row.forgejoIssueNumber,
forgejoIssueUrl: row.forgejoIssueUrl,
}; };
} }
@ -60,3 +67,67 @@ export async function listContactMessages(
throw err; 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 { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
@ -49,6 +50,10 @@ async function seedMessage(overrides: {
} }
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; 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", () => { describe("Admin contact messages", () => {
const app = createApp(); 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].message).to.equal("signalé puis compte supprimé");
expect(res.body[0].author).to.equal(null); 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 // Mocks the admin API via cy.intercept — no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers // .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 = { const adminBody = {
id: 1, id: 1,
@ -10,6 +10,10 @@ const adminBody = {
lastLoginAt: "2026-08-28T09:00:00.000Z", 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() { function messages() {
return [ return [
{ {
@ -21,6 +25,8 @@ function messages() {
appVersion: "0.2.1", appVersion: "0.2.1",
createdAt: "2026-09-07T10:00:00.000Z", createdAt: "2026-09-07T10:00:00.000Z",
author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" }, author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" },
forgejoIssueNumber: null,
forgejoIssueUrl: null,
}, },
{ {
id: 1, id: 1,
@ -31,6 +37,8 @@ function messages() {
appVersion: null, appVersion: null,
createdAt: "2026-09-06T08:30:00.000Z", createdAt: "2026-09-06T08:30:00.000Z",
author: null, author: null,
forgejoIssueNumber: null,
forgejoIssueUrl: null,
}, },
]; ];
} }
@ -39,12 +47,15 @@ describe("Admin contact messages (/admin/messages)", () => {
beforeEach(() => { beforeEach(() => {
cy.viewport(1400, 1000); cy.viewport(1400, 1000);
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); 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", () => { it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => {
cy.intercept("GET", "**/admin/api/contact*", { statusCode: 200, body: messages() }).as( cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
"getMessages",
);
cy.visit("/admin/messages"); cy.visit("/admin/messages");
cy.wait("@getMessages"); cy.wait("@getMessages");
@ -52,8 +63,9 @@ describe("Admin contact messages (/admin/messages)", () => {
cy.get(".messages-table tbody tr").should("have.length", 2); 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", "page planning").should("contain.text", "alice@example.com");
cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé"); cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé");
// No triage controls on the page. // Integration off ⇒ no create button, and the "not configured" note shows.
cy.get(".messages-table button").should("not.exist"); 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", () => { 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.get(".messages-table tbody tr").should("have.length", 1);
cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible"); 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 CatalogPlaceholderGroupView,
type ContactMessageAdminView, type ContactMessageAdminView,
type CorrectionAdminView, type CorrectionAdminView,
type CreateForgejoIssueInput,
type CreateTourScenarioInput, type CreateTourScenarioInput,
ErrorCode, ErrorCode,
type ForgejoIntegrationStatus,
type MarkPlaceholdersReviewedInput, type MarkPlaceholdersReviewedInput,
type MetricsView, type MetricsView,
type PruneOrphansResultView, type PruneOrphansResultView,
@ -229,6 +231,28 @@ export class AdminApiClient {
public getContactMessages(filters: { type?: string }): Promise<ContactMessageAdminView[]> { public getContactMessages(filters: { type?: string }): Promise<ContactMessageAdminView[]> {
return this._request(`/contact${query(filters)}`); 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`. */ /** 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", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", "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": { "auth": {
"login": { "login": {
@ -1349,7 +1353,20 @@
"type": "Type", "type": "Type",
"message": "Message", "message": "Message",
"page": "Page", "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": { "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 { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss"; import "../admin-page.scss";
import "./messages-page.scss"; import "./messages-page.scss";
import { CreateForgejoIssueModal } from "./CreateForgejoIssueModal";
type MessagesState = type MessagesState =
| { status: "loading" } | { status: "loading" }
@ -13,13 +14,36 @@ type MessagesState =
/** /**
* Contact-form messages (issue #80) — a read-only list of the bug reports / * Contact-form messages (issue #80) — a read-only list of the bug reports /
* suggestions users have sent from `/parametres/contact`, newest first, * suggestions users have sent from `/parametres/contact`, newest first,
* capped server-side at 200. One optional filter (by `type`); no actions, * capped server-side at 200. One optional filter (by `type`).
* no triage workflow in this first version. *
* 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() { export function MessagesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [type, setType] = useState(""); const [type, setType] = useState("");
const [state, setState] = useState<MessagesState>({ status: "loading" }); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -37,6 +61,19 @@ export function MessagesPage() {
}; };
}, [type]); }, [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 ( return (
<div className="admin-page"> <div className="admin-page">
<h1 className="admin-page__title">{t("admin.messages.title")}</h1> <h1 className="admin-page__title">{t("admin.messages.title")}</h1>
@ -54,6 +91,9 @@ export function MessagesPage() {
))} ))}
</select> </select>
</label> </label>
{forgejoEnabled === false && (
<p className="messages-forgejo-note">{t("admin.messages.forgejo.notConfigured")}</p>
)}
</div> </div>
{state.status === "loading" && ( {state.status === "loading" && (
@ -76,6 +116,7 @@ export function MessagesPage() {
<th>{t("admin.messages.col.message")}</th> <th>{t("admin.messages.col.message")}</th>
<th>{t("admin.messages.col.page")}</th> <th>{t("admin.messages.col.page")}</th>
<th>{t("admin.messages.col.userAgent")}</th> <th>{t("admin.messages.col.userAgent")}</th>
<th>{t("admin.messages.col.ticket")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -107,12 +148,42 @@ export function MessagesPage() {
<td className="messages-table__ua" title={row.userAgent ?? undefined}> <td className="messages-table__ua" title={row.userAgent ?? undefined}>
{row.userAgent ?? "—"} {row.userAgent ?? "—"}
</td> </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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{modalFor && (
<CreateForgejoIssueModal
message={modalFor}
onClose={() => setModalFor(null)}
onCreated={applyCreatedIssue}
/>
)}
</div> </div>
); );
} }

View file

@ -86,6 +86,108 @@
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-xs); 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 { .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, TOUR_TARGET_NOT_FOUND = 4054,
/** `POST /admin/api/tours` was given a `key` already used by another scenario. */ /** `POST /admin/api/tours` was given a `key` already used by another scenario. */
TOUR_KEY_ALREADY_IN_USE = 4025, 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`. */ /** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002, 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. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, 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(), type: z.enum(CONTACT_MESSAGE_TYPES).optional(),
}); });
export type ListContactMessagesQuery = z.infer<typeof listContactMessagesQuerySchema>; 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; lastName: string;
email: string; email: string;
} | null; } | 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` champs du message + `author: { id, firstName, lastName, email } | null`
(`null` = compte supprimé depuis l'envoi, `authorId` étant (`null` = compte supprimé depuis l'envoi, `authorId` étant
`onDelete: SetNull`). `onDelete: SetNull`).
- **Lecture seule** : pas de route d'écriture, pas de workflow de traitement - **Lecture seule** côté message : le message lui-même n'est jamais muté,
(statut / assignation / note) dans cette première version. 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é à - `pageUrl` / `userAgent` / `appVersion` : contexte technique capturé à
l'envoi, tous nullables. `userAgent` est lu de l'en-tête de requête côté l'envoi, tous nullables. `userAgent` est lu de l'en-tête de requête côté
API, jamais du corps. 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. - `@@index([createdAt])` — la vue admin trie par date décroissante.
--- ---