Admin : créer un ticket Forgejo depuis un message de contact #81

Closed
opened 2026-09-07 00:30:12 +02:00 by kyuno · 0 comments
Owner

Résumé

Depuis la page d'administration des messages de contact
(/admin/messages, voir
issue #80),
permettre à un admin de créer une issue Forgejo à partir d'un message
utilisateur
en un geste : un bouton ouvre une modale pré-remplie (titre +
corps éditables), l'admin ajuste et confirme, l'app crée l'issue dans le
dépôt via l'API Forgejo et mémorise le lien sur le message.

Ce serait la première intégration Forgejo dans apps/api (aucune
aujourd'hui — vérifié). Elle suit la même posture que les autres
intégrations externes du repo (INTENT_SERVICE_*, INTERNAL_WORKER_SECRET) :
config par variables d'environnement, fail-closed si incomplète (le
bouton n'apparaît pas, l'endpoint répond 503).

Dépendance

Nécessite #80 livré (modèle ContactMessage, module
admin-contact.{routes,service}.ts, page /admin/messages). Ce ticket
ajoute un delta dessus : 2 colonnes sur ContactMessage, 2 routes admin,
1 service ForgejoService, l'UI de la modale. Si #80 est encore en cours au
moment de démarrer, intégrer directement les 2 colonnes à sa migration.

Décisions de conception (validées avec l'utilisateur)

Question Décision Pourquoi
Création Modale pré-remplie éditable (titre proposé + corps = message + contexte auto + expéditeur), l'admin ajuste avant de confirmer Un message brut fait rarement un bon titre/corps d'issue ; l'admin connaît les conventions du dépôt.
Labels sur l'issue créée Aucun label automatique L'admin posera les labels dans Forgejo ensuite. Pas de mapping type→label, pas de chargement de la liste des labels.
État du message après création Juste le lien (forgejoIssueNumber + forgejoIssueUrl sur ContactMessage) — la ligne affiche #N, le bouton disparaît Pas de champ de statut : #80 reste en lecture seule. « A un ticket lié » suffit comme marqueur.
Priorité Medium Confort d'admin au-dessus de #80.

Modèle de données — delta sur ContactMessage (#80)

  /// Numéro de l'issue Forgejo créée depuis ce message via
  /// `/admin/messages`. `null` = pas encore de ticket. Non-null ⇒ le bouton
  /// « Créer un ticket » disparaît (un message = au plus un ticket).
  forgejoIssueNumber Int?    @map("forgejo_issue_number")
  /// URL html de l'issue (`<base>/<owner>/<repo>/issues/<n>`) — stockée
  /// telle quelle plutôt que reconstruite, pour rester juste si la config
  /// `FORGEJO_*` change ensuite.
  forgejoIssueUrl    String? @map("forgejo_issue_url")

Migration prisma migrate dev --name contact_message_forgejo_link — 2
colonnes nullables, aucun backfill.

Configuration (apps/api/src/config/env.ts)

Trois variables, toutes .optional() dans le schéma ; le service et
l'endpoint refusent tant que les trois ne sont pas là (même posture que
ADMIN_JWT_SECRET / INTENT_SERVICE_SECRET qui « fail closed ») :

  • FORGEJO_BASE_URL — ex. https://git.dev.kyuno.fr (.url(), pas de
    défaut : propre au déploiement).
  • FORGEJO_TOKEN — jeton d'accès personnel Forgejo avec le scope
    write:issue (ou issue). Secret — .env.example ne montre qu'un
    changeme.
  • FORGEJO_REPO — owner/repo, ex. kyuno/batchCooking
    (regex ^[^/\s]+/[^/\s]+$).

Documenter les trois dans apps/api/.env.example sous « seulement pour la
création d'issue depuis un message ; le bouton est masqué tant que non
configuré ».

Backend

apps/api/src/lib/forgejo/forgejo.service.ts — ForgejoService (une
vraie class + singleton export const forgejo = new ForgejoService(),
cf. logger / analytics ; conventions de dev) :

  • isConfigured(): boolean — les trois vars présentes.
  • createIssue({ title, body }): Promise<{ number: number; htmlUrl: string }>
    — POST {FORGEJO_BASE_URL}/api/v1/repos/{FORGEJO_REPO}/issues, en-tête
    Authorization: token {FORGEJO_TOKEN}, corps { title, body } ; lit
    { number, html_url } de la réponse. Via fetch global (même approche
    que les adaptateurs de sources ; the-meal-db.ts est le précédent d'un
    module qui parle à un vrai réseau externe, et ses tests réassignent
    globalThis.fetch). Réseau KO / réponse non-2xx ⇒ erreur typée
    (HttpError 502 FORGEJO_REQUEST_FAILED).

admin-contact.service.ts (#80) — ajouter
createForgejoIssueFromMessage(messageId, input) :

  1. 503 FORGEJO_NOT_CONFIGURED si !forgejo.isConfigured().
  2. Charger le ContactMessage — 404 CONTACT_MESSAGE_NOT_FOUND sinon.
  3. 409 CONTACT_MESSAGE_ALREADY_LINKED si forgejoIssueNumber !== null.
  4. forgejo.createIssue({ title, body }) (aucun label — décision).
  5. Succès ⇒ prisma.contactMessage.update(... { forgejoIssueNumber, forgejoIssueUrl }),
    renvoyer le ContactMessageAdminView à jour.
  6. Échec à l'étape 4 ⇒ propager 502, base non touchée (pas d'état
    partiel).

Plus getForgejoStatus(): { enabled: boolean }.

admin-contact.routes.ts (#80) — ajouter, derrière requireAdmin :

  • POST /admin/api/contact/:id/forgejo-issue — corps createForgejoIssueSchema.
  • GET /admin/api/contact/forgejo/status — { enabled }.

Contrat partagé (packages/shared/src) :

  • schemas/contact.ts (#80) — createForgejoIssueSchema :
    { title: z.string().trim().min(1).max(255), body: z.string().trim().min(1).max(60000) }
    (255 = limite de titre Forgejo).
  • types/contact.ts (#80) — étendre ContactMessageAdminView avec
    forgejoIssueNumber: number | null + forgejoIssueUrl: string | null ;
    ajouter ForgejoIntegrationStatus { enabled: boolean }.
  • errors/error-codes.ts — FORGEJO_NOT_CONFIGURED,
    FORGEJO_REQUEST_FAILED, CONTACT_MESSAGE_ALREADY_LINKED
    (+ CONTACT_MESSAGE_NOT_FOUND s'il n'existe pas déjà via #80).

Frontend (apps/web)

  • api/admin-client.ts — createForgejoIssue(id, { title, body }),
    getForgejoStatus().
  • pages/admin/messages/MessagesPage.tsx (#80) :
    • Au montage, getForgejoStatus() ; si !enabled, pas de bouton (petit
      libellé « intégration Forgejo non configurée »).
    • Ligne sans issue liée : bouton « Créer un ticket » → modale.
    • Ligne avec issue liée : lien #N vers forgejoIssueUrl
      (target="_blank"), pas de bouton.
  • Modale (CreateForgejoIssueModal.tsx) + helpers purs dans un
    messages.ts (hors .tsx, cf. conventions) :
    • buildIssueTitle(message) — ex. [Bug] <première ligne, tronquée>.
    • buildIssueBody(message) — le texte, puis un bloc --- :
      Signalé par : <nom> <email> · Page : <pageUrl> ·
      Version : <appVersion> · Navigateur : <userAgent> ·
      Reçu le : <date>, + un pied 🤖 Créé depuis le formulaire de contact.
    • Titre + corps éditables ; submit → createForgejoIssue → succès :
      fermer, la ligne montre #N ; erreur : message i18n
      (ErrorMessageService, jamais err.message brut).
  • i18n : bloc admin.messages.forgejo.* (bouton, titre modale, libellés,
    succès, erreurs, « non configuré ») + entrées code d'erreur →
    message dans ErrorMessageService.

Tests (obligatoires par PR, cf. specs/dev-conventions.md)

  • API (Mocha + Chai) :
    • forgejo.service — globalThis.fetch réassigné : createIssue tape la
      bonne URL avec l'en-tête Authorization: token et {title, body} ;
      lit {number, html_url} ; réponse non-2xx / échec réseau ⇒ erreur
      typée ; isConfigured() suit l'env.
    • admin-contact.service (étendu) — createForgejoIssueFromMessage :
      chemin nominal (stocke le lien, renvoie la vue à jour) ; 503 non
      configuré ; 404 message inconnu ; 409 déjà lié ; échec Forgejo ⇒
      502 et ligne intacte.
  • Web (Cypress, layout) — étendre admin-messages.cy.ts :
    forgejo/status mocké enabled:true ⇒ bouton visible, clic ⇒ modale
    pré-remplie, POST .../forgejo-issue mocké ⇒ la ligne affiche #N ;
    enabled:false ⇒ pas de bouton.

Documentation à mettre à jour

  • specs/backend-architecture.md — ForgejoService, les 2 routes admin,
    les 3 variables FORGEJO_*.
  • specs/batch-cooking-modele.md — les 2 colonnes forgejoIssue* de
    ContactMessage.

Découpage proposé

Une PR cohérente suffit, ou deux courtes stackées si on préfère
(cf. « PRs plutôt petites et scopées ») :

  1. feat(admin): ForgejoService + POST .../forgejo-issue — config env,
    ForgejoService, delta modèle + migration, service/route admin, contrat
    partagé, tests API.
  2. feat(admin): modale de création de ticket depuis un message —
    admin-client, bouton + modale + helpers, affichage du lien, i18n,
    Cypress.

Hors scope

  • Labels automatiques ou sélection de labels dans la modale (décision :
    aucun label auto).
  • Statut « traité » sur le message (décision : juste le lien ; #80 reste
    en lecture seule).
  • Synchronisation retour : statut/commentaires/fermeture de l'issue Forgejo
    répercutés sur le message — le lien est unidirectionnel.
  • Délier / recréer un ticket pour un message déjà lié.
  • Création dans un dépôt autre que FORGEJO_REPO, ou choix du dépôt.
  • Webhook Forgejo → app.
  • Rate-limiting de l'appel Forgejo (action manuelle d'admin, volume
    négligeable).

🤖 Filed by Claude Code

## Résumé Depuis la page d'administration des messages de contact (`/admin/messages`, voir [issue #80](https://git.dev.kyuno.fr/kyuno/batchCooking/issues/80)), permettre à un admin de **créer une issue Forgejo à partir d'un message utilisateur** en un geste : un bouton ouvre une modale pré-remplie (titre + corps éditables), l'admin ajuste et confirme, l'app crée l'issue dans le dépôt via l'API Forgejo et **mémorise le lien** sur le message. Ce serait la **première intégration Forgejo dans `apps/api`** (aucune aujourd'hui — vérifié). Elle suit la même posture que les autres intégrations externes du repo (`INTENT_SERVICE_*`, `INTERNAL_WORKER_SECRET`) : config par variables d'environnement, **fail-closed** si incomplète (le bouton n'apparaît pas, l'endpoint répond `503`). ## Dépendance Nécessite **#80** livré (modèle `ContactMessage`, module `admin-contact.{routes,service}.ts`, page `/admin/messages`). Ce ticket ajoute un **delta** dessus : 2 colonnes sur `ContactMessage`, 2 routes admin, 1 service `ForgejoService`, l'UI de la modale. Si #80 est encore en cours au moment de démarrer, intégrer directement les 2 colonnes à sa migration. ## Décisions de conception (validées avec l'utilisateur) | Question | Décision | Pourquoi | |---|---|---| | Création | **Modale pré-remplie éditable** (titre proposé + corps = message + contexte auto + expéditeur), l'admin ajuste avant de confirmer | Un message brut fait rarement un bon titre/corps d'issue ; l'admin connaît les conventions du dépôt. | | Labels sur l'issue créée | **Aucun label automatique** | L'admin posera les labels dans Forgejo ensuite. Pas de mapping type→label, pas de chargement de la liste des labels. | | État du message après création | **Juste le lien** (`forgejoIssueNumber` + `forgejoIssueUrl` sur `ContactMessage`) — la ligne affiche `#N`, le bouton disparaît | Pas de champ de statut : #80 reste en lecture seule. « A un ticket lié » suffit comme marqueur. | | Priorité | **Medium** | Confort d'admin au-dessus de #80. | ## Modèle de données — delta sur `ContactMessage` (#80) ```prisma /// Numéro de l'issue Forgejo créée depuis ce message via /// `/admin/messages`. `null` = pas encore de ticket. Non-null ⇒ le bouton /// « Créer un ticket » disparaît (un message = au plus un ticket). forgejoIssueNumber Int? @map("forgejo_issue_number") /// URL html de l'issue (`<base>/<owner>/<repo>/issues/<n>`) — stockée /// telle quelle plutôt que reconstruite, pour rester juste si la config /// `FORGEJO_*` change ensuite. forgejoIssueUrl String? @map("forgejo_issue_url") ``` Migration `prisma migrate dev --name contact_message_forgejo_link` — 2 colonnes nullables, aucun backfill. ## Configuration (`apps/api/src/config/env.ts`) Trois variables, toutes `.optional()` dans le schéma ; le service et l'endpoint refusent tant que les trois ne sont pas là (même posture que `ADMIN_JWT_SECRET` / `INTENT_SERVICE_SECRET` qui « fail closed ») : - `FORGEJO_BASE_URL` — ex. `https://git.dev.kyuno.fr` (`.url()`, pas de défaut : propre au déploiement). - `FORGEJO_TOKEN` — jeton d'accès personnel Forgejo avec le scope `write:issue` (ou `issue`). Secret — `.env.example` ne montre qu'un `changeme`. - `FORGEJO_REPO` — `owner/repo`, ex. `kyuno/batchCooking` (regex `^[^/\s]+/[^/\s]+$`). Documenter les trois dans `apps/api/.env.example` sous « seulement pour la création d'issue depuis un message ; le bouton est masqué tant que non configuré ». ## Backend **`apps/api/src/lib/forgejo/forgejo.service.ts`** — `ForgejoService` (une vraie `class` + singleton `export const forgejo = new ForgejoService()`, cf. `logger` / `analytics` ; conventions de dev) : - `isConfigured(): boolean` — les trois vars présentes. - `createIssue({ title, body }): Promise<{ number: number; htmlUrl: string }>` — `POST {FORGEJO_BASE_URL}/api/v1/repos/{FORGEJO_REPO}/issues`, en-tête `Authorization: token {FORGEJO_TOKEN}`, corps `{ title, body }` ; lit `{ number, html_url }` de la réponse. Via `fetch` global (même approche que les adaptateurs de sources ; `the-meal-db.ts` est le précédent d'un module qui parle à un vrai réseau externe, et ses tests réassignent `globalThis.fetch`). Réseau KO / réponse non-2xx ⇒ erreur typée (`HttpError 502 FORGEJO_REQUEST_FAILED`). **`admin-contact.service.ts`** (#80) — ajouter `createForgejoIssueFromMessage(messageId, input)` : 1. `503 FORGEJO_NOT_CONFIGURED` si `!forgejo.isConfigured()`. 2. Charger le `ContactMessage` — `404 CONTACT_MESSAGE_NOT_FOUND` sinon. 3. `409 CONTACT_MESSAGE_ALREADY_LINKED` si `forgejoIssueNumber !== null`. 4. `forgejo.createIssue({ title, body })` (aucun label — décision). 5. Succès ⇒ `prisma.contactMessage.update(... { forgejoIssueNumber, forgejoIssueUrl })`, renvoyer le `ContactMessageAdminView` à jour. 6. Échec à l'étape 4 ⇒ propager `502`, **base non touchée** (pas d'état partiel). Plus `getForgejoStatus(): { enabled: boolean }`. **`admin-contact.routes.ts`** (#80) — ajouter, derrière `requireAdmin` : - `POST /admin/api/contact/:id/forgejo-issue` — corps `createForgejoIssueSchema`. - `GET /admin/api/contact/forgejo/status` — `{ enabled }`. **Contrat partagé** (`packages/shared/src`) : - `schemas/contact.ts` (#80) — `createForgejoIssueSchema` : `{ title: z.string().trim().min(1).max(255), body: z.string().trim().min(1).max(60000) }` (255 = limite de titre Forgejo). - `types/contact.ts` (#80) — étendre `ContactMessageAdminView` avec `forgejoIssueNumber: number | null` + `forgejoIssueUrl: string | null` ; ajouter `ForgejoIntegrationStatus { enabled: boolean }`. - `errors/error-codes.ts` — `FORGEJO_NOT_CONFIGURED`, `FORGEJO_REQUEST_FAILED`, `CONTACT_MESSAGE_ALREADY_LINKED` (+ `CONTACT_MESSAGE_NOT_FOUND` s'il n'existe pas déjà via #80). ## Frontend (`apps/web`) - `api/admin-client.ts` — `createForgejoIssue(id, { title, body })`, `getForgejoStatus()`. - `pages/admin/messages/MessagesPage.tsx` (#80) : - Au montage, `getForgejoStatus()` ; si `!enabled`, pas de bouton (petit libellé « intégration Forgejo non configurée »). - Ligne sans issue liée : bouton **« Créer un ticket »** → modale. - Ligne avec issue liée : lien `#N` vers `forgejoIssueUrl` (`target="_blank"`), pas de bouton. - Modale (`CreateForgejoIssueModal.tsx`) + helpers purs dans un `messages.ts` (hors `.tsx`, cf. conventions) : - `buildIssueTitle(message)` — ex. `[Bug] <première ligne, tronquée>`. - `buildIssueBody(message)` — le texte, puis un bloc `---` : `Signalé par : <nom> <email>` · `Page : <pageUrl>` · `Version : <appVersion>` · `Navigateur : <userAgent>` · `Reçu le : <date>`, + un pied `🤖 Créé depuis le formulaire de contact`. - Titre + corps éditables ; submit → `createForgejoIssue` → succès : fermer, la ligne montre `#N` ; erreur : message i18n (`ErrorMessageService`, jamais `err.message` brut). - i18n : bloc `admin.messages.forgejo.*` (bouton, titre modale, libellés, succès, erreurs, « non configuré ») + entrées code d'erreur → message dans `ErrorMessageService`. ## Tests (obligatoires par PR, cf. specs/dev-conventions.md) - **API (Mocha + Chai)** : - `forgejo.service` — `globalThis.fetch` réassigné : `createIssue` tape la bonne URL avec l'en-tête `Authorization: token` et `{title, body}` ; lit `{number, html_url}` ; réponse non-2xx / échec réseau ⇒ erreur typée ; `isConfigured()` suit l'env. - `admin-contact.service` (étendu) — `createForgejoIssueFromMessage` : chemin nominal (stocke le lien, renvoie la vue à jour) ; `503` non configuré ; `404` message inconnu ; `409` déjà lié ; échec Forgejo ⇒ `502` **et** ligne intacte. - **Web (Cypress, layout)** — étendre `admin-messages.cy.ts` : `forgejo/status` mocké `enabled:true` ⇒ bouton visible, clic ⇒ modale pré-remplie, `POST .../forgejo-issue` mocké ⇒ la ligne affiche `#N` ; `enabled:false` ⇒ pas de bouton. ## Documentation à mettre à jour - `specs/backend-architecture.md` — `ForgejoService`, les 2 routes admin, les 3 variables `FORGEJO_*`. - `specs/batch-cooking-modele.md` — les 2 colonnes `forgejoIssue*` de `ContactMessage`. ## Découpage proposé Une PR cohérente suffit, ou deux courtes stackées si on préfère (cf. « PRs plutôt petites et scopées ») : 1. **`feat(admin): ForgejoService + POST .../forgejo-issue`** — config env, `ForgejoService`, delta modèle + migration, service/route admin, contrat partagé, tests API. 2. **`feat(admin): modale de création de ticket depuis un message`** — `admin-client`, bouton + modale + helpers, affichage du lien, i18n, Cypress. ## Hors scope - Labels automatiques ou sélection de labels dans la modale (décision : aucun label auto). - Statut « traité » sur le message (décision : juste le lien ; #80 reste en lecture seule). - Synchronisation retour : statut/commentaires/fermeture de l'issue Forgejo répercutés sur le message — le lien est unidirectionnel. - Délier / recréer un ticket pour un message déjà lié. - Création dans un dépôt autre que `FORGEJO_REPO`, ou choix du dépôt. - Webhook Forgejo → app. - Rate-limiting de l'appel Forgejo (action manuelle d'admin, volume négligeable). --- 🤖 Filed by [Claude Code](https://claude.com/claude-code)
kyuno added the
enhancement
planned
Medium
labels 2026-09-07 00:30:12 +02:00
kyuno closed this issue 2026-09-08 20:30:10 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: kyuno/batchCooking#81
No description provided.