feat(admin): crée un ticket Forgejo depuis un message de contact (#81) #125
22 changed files with 1077 additions and 21 deletions
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
102
apps/api/src/lib/forgejo/forgejo.service.ts
Normal file
102
apps/api/src/lib/forgejo/forgejo.service.ts
Normal 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();
|
||||||
|
|
@ -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));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
113
apps/api/test/forgejo.service.test.ts
Normal file
113
apps/api/test/forgejo.service.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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`. */
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
51
apps/web/src/pages/admin/messages/messages.ts
Normal file
51
apps/web/src/pages/admin/messages/messages.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue