From af107d5666964767b24dceaae3ff6f742c4fe06f Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Fri, 13 Feb 2026 12:43:08 +0100 Subject: [PATCH 001/221] docs: reorganize docs/ structure (mvp/ archive + features/ specs) Move MVP docs to docs/mvp/, add docs/features/tags-rework/ with spec and roadmap for Phase 10. Update CLAUDE.md, PROGRESS.md and README accordingly. --- .claude/CLAUDE.md | 53 ++- .claude/context/PROGRESS.md | 68 +-- README.md | 11 +- docs/0 - brainstorming futur.md | 3 +- docs/features/tags-rework/ROADMAP.md | 82 ++++ docs/features/tags-rework/SPEC_TAGS_REWORK.md | 444 ++++++++++++++++++ docs/{ => mvp}/API_SPECIFICATION.md | 0 docs/{ => mvp}/ARCHITECTURE.md | 0 docs/{ => mvp}/BUSINESS_RULES.md | 0 docs/{ => mvp}/DEVELOPMENT_ROADMAP.md | 88 +--- docs/{ => mvp}/TESTS_IMPLEMENTATION_PLAN.md | 2 +- docs/{ => mvp}/USER_STORIES.md | 0 12 files changed, 583 insertions(+), 168 deletions(-) create mode 100644 docs/features/tags-rework/ROADMAP.md create mode 100644 docs/features/tags-rework/SPEC_TAGS_REWORK.md rename docs/{ => mvp}/API_SPECIFICATION.md (100%) rename docs/{ => mvp}/ARCHITECTURE.md (100%) rename docs/{ => mvp}/BUSINESS_RULES.md (100%) rename docs/{ => mvp}/DEVELOPMENT_ROADMAP.md (87%) rename docs/{ => mvp}/TESTS_IMPLEMENTATION_PLAN.md (99%) rename docs/{ => mvp}/USER_STORIES.md (100%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 252c1c9e..87686e20 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -33,41 +33,60 @@ npx prisma studio # DB GUI :5555 ## Git - **Main**: master -- **Branche courante**: CommunitiesBases +- **Branche courante**: Developement - **Commits**: Ne JAMAIS ajouter de Co-Authored-By pour Claude ## Phase actuelle -**Phase 4** (Recettes communautaires) - Backend 4.1 COMPLETE, Frontend 4.2 COMPLETE. -Voir `.claude/context/PROGRESS.md` pour le detail. +**Phase 10** - Rework Tags (3 niveaux : Global/Communaute/Pending, validation moderateurs, suggestions). +Voir `.claude/context/PROGRESS.md` pour le detail et les liens vers spec/roadmap. ## Codes erreur -AUTH_001 (non auth) | COMMUNITY_001-006 | RECIPE_001-002 | INVITE_001-003 | ADMIN_001-012 +AUTH_001-002 | COMMUNITY_001-006 | RECIPE_001-005 | INVITE_001-003 | ADMIN_001-012 | PROPOSAL_001-003 | SHARE_001-003 | TAG_001-007 ## Regle: maintenir `.claude/` a jour -Apres chaque modification (nouveau fichier, endpoint, migration, test, phase, branche), mettre a jour les fichiers `.claude/context/` concernes **ET `docs/DEVELOPMENT_ROADMAP.md`** (cocher les taches, maj checklist MVP, maj compteur tests) avant de terminer la session. +Apres chaque modification (nouveau fichier, endpoint, migration, test, phase, branche), mettre a jour : +- `.claude/context/` (PROGRESS, TESTS, API_MAP, DB_MODELS, FILE_MAP selon pertinence) +- La **roadmap de la feature en cours** (cocher les taches dans `docs/features/*/ROADMAP.md`) + Si une tache est en cours et que les tokens arrivent a leur limite, generer `.claude/context/RESUME.md` avec: tache en cours, etapes faites, etapes restantes, fichiers modifies, et tout contexte necessaire pour reprendre sans perte. ### PROGRESS.md : garder le fichier compact -- Le tableau des phases completees = 1 ligne par phase, suffisant comme historique -- Section "Phase en cours" : detail uniquement pour la phase active (checklist, sous-etapes) -- **Quand une phase est terminee** : supprimer sa section de detail, ajouter la ligne au tableau, c'est tout -- Le detail des anciennes phases reste tracable via git log et les docs (DEVELOPMENT_ROADMAP, etc.) +- Juste un lien vers la phase en cours (spec + roadmap dans `docs/features/`) +- Pas de duplication de la roadmap dans PROGRESS +- Le detail du MVP est dans `docs/mvp/DEVELOPMENT_ROADMAP.md` (archive, ne plus modifier) + +## Organisation docs/ + +``` +docs/ + 0 - brainstorming futur.md # Idees futures (transversal) + mvp/ # Docs du MVP (archive, phases 0-8) + features/ # Specs + roadmaps par feature post-MVP + tags-rework/ + SPEC_TAGS_REWORK.md + ROADMAP.md +``` + +Chaque nouvelle feature a son dossier dans `docs/features/` avec au minimum une spec et une roadmap. ## Contexte approfondi (lire selon le besoin) | Besoin | Fichier | |--------|---------| -| Avancement phases & resume | `.claude/context/PROGRESS.md` | -| Tests: commandes, fichiers, infra | `.claude/context/TESTS.md` | +| Avancement & phase en cours | `.claude/context/PROGRESS.md` | +| Tests: commandes, inventaire, infra | `.claude/context/TESTS.md` | | Endpoints API complets | `.claude/context/API_MAP.md` | | Schema DB & modeles Prisma | `.claude/context/DB_MODELS.md` | | Arborescence fichiers source | `.claude/context/FILE_MAP.md` | -| Regles metier detaillees | `docs/BUSINESS_RULES.md` | -| User stories | `docs/USER_STORIES.md` | -| Architecture & patterns | `docs/ARCHITECTURE.md` | -| Roadmap & plan de tests | `docs/DEVELOPMENT_ROADMAP.md` | -| Spec API (contrat REST) | `docs/API_SPECIFICATION.md` | -| Roadmap tests par sprint | `docs/TESTS_IMPLEMENTATION_PLAN.md` | +| **Feature en cours : Tags Rework** | | +| Spec Tags Rework | `docs/features/tags-rework/SPEC_TAGS_REWORK.md` | +| Roadmap Tags Rework | `docs/features/tags-rework/ROADMAP.md` | +| **Archive MVP** | | +| Regles metier | `docs/mvp/BUSINESS_RULES.md` | +| User stories | `docs/mvp/USER_STORIES.md` | +| Architecture & patterns | `docs/mvp/ARCHITECTURE.md` | +| Roadmap MVP (archive) | `docs/mvp/DEVELOPMENT_ROADMAP.md` | +| Spec API (contrat REST) | `docs/mvp/API_SPECIFICATION.md` | diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index d437684b..0c0b0f38 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -1,68 +1,22 @@ # Avancement du projet -## Phases completees - -| Phase | Description | Status | -|-------|-------------|--------| -| 0 | Setup & Infrastructure | DONE | -| 0.5 | SuperAdmin & Briques (2FA, Features, Admin API, Frontend admin) | DONE | -| 1 | Auth user (signup/login/logout, frontend auth, layout) | DONE | -| 2 | Catalogue personnel (CRUD recettes, autocomplete tags/ingredients, frontend) | DONE | -| 3.1 | Communities CRUD backend | DONE | -| 3.2 | Invitations backend | DONE | -| 3.3 | Members backend (list, promote, kick, leave) | DONE | -| 3.4 | Frontend Communities (pages, composants, sidebar Discord-style) | DONE | -| 3.5 | Frontend Invitations (pages, composants, notifications) | DONE | -| 3.6 | Frontend User Management (profil, menu, search) | DONE | -| 4.1 | Backend Recettes Communautaires (CRUD, copie perso, tests) | DONE | -| 4.2 | Frontend Recettes Communautaires (liste, creation, detail, permissions) | DONE | -| 5.1 | Backend Proposals (create, list, detail, accept, reject) | DONE | -| 5.2 | Backend Variants (list variants endpoint) | DONE | -| 5.3 | Frontend Proposals (modal, list, variants dropdown, RecipeDetailPage) | DONE | -| 5.4 | Backend Orphan Handling (auto-reject proposals on leave/kick) | DONE | -| 6.1 | Backend Activity Feed (community + personal endpoints) | DONE | -| 6.2 | Frontend Activity Feed (component, integration) | DONE | -| 7.1 | Backend Share (fork to other community, chain analytics) | DONE | -| 7.2 | Frontend Share (modal, badge "Shared from X") | DONE | -| 7.3 | Pre-Phase 8 corrections (13 fixes: bugs, UX, sync, side panel, publish) | DONE | -| 8.1 | Qualite (toast, ErrorBoundary, refactoring recipes.ts, soft delete audit) | DONE | -| 8.2 | Tests & Lint (499 tests, 0 lint errors frontend + backend) | DONE | -| 8.3 | Documentation (README utilisateur + guide deploiement) | DONE | -| 9.4 | Frontend Admin Pages (layout sidebar, 5 pages gestion, 35 tests) | DONE | -| 9.2 | Notifications temps reel (Socket.IO) + Dark mode (forest/winter) | DONE | -| 9.3 | Logging structure (Pino) + Tests supplementaires (+113 tests) | DONE | - ## MVP COMPLET -Toutes les phases (0 a 8) sont terminees. Le MVP est fonctionnel. -Phases post-MVP completees : 9.4 (admin frontend), 9.2 (WebSocket + dark mode), 9.3 (logging + tests). - -## Ce qui reste a faire - -### Checklist validation MVP (non cochees) - -- [ ] Tests manuels complets (parcours utilisateur end-to-end) -- [ ] Application stable sans erreurs bloquantes (validation) -- [ ] Donnees persistees correctement (validation) -- [ ] Responsive design (audit + corrections) -- [ ] Performance acceptable (<3s chargement page, audit) +Phases 0 a 9.3 terminees. 663 tests (273 frontend + 390 backend). -### Maintenance technique +## Phase en cours : 10 - Rework Tags -- [x] Remplacer `npm prune --production` par `--omit=dev` dans Dockerfile -- [x] Mettre a jour ESLint v8 -> v9 (flat config `eslint.config.mjs`) -- [x] Fix vulnerabilite npm axios (frontend) -- [x] Migrer config Prisma vers `prisma.config.ts` -> non necessaire (setup standard suffisant) -- [x] Migrer otplib v12 -> v13 (nouvelle API: generateSecret, generateSync, verifySync, generateURI) -- [x] Fix vulnerabilites npm: vitest 2->3, bcrypt 5->6 (0 vulns backend + frontend) +- **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` +- **Roadmap** : `docs/features/tags-rework/ROADMAP.md` +- **Sous-etape en cours** : Spec validee, implementation non commencee +- **Branche** : a creer (`tags-rework`) -## Tests actuels +## Prochains chantiers (a specifier) -| Suite | Fichiers | Tests | -|-------|----------|-------| -| Frontend | 47 | 273 | -| Backend | 27 | 390 | -| **Total** | **74** | **663** | +- Rework systeme d'ingredients +- Rework pages recettes v2 (etapes, temps, portions) +- Systeme d'upload de photos (Cloudflare R2) +- Audit refactorisation complete back + front ## Resume de reprise diff --git a/README.md b/README.md index 84ac1464..a63c5cc9 100644 --- a/README.md +++ b/README.md @@ -286,11 +286,12 @@ Le script demande un username et un mot de passe, puis genere un QR code TOTP a | Document | Description | | ---------------------------------------------- | ---------------------------------- | -| [Architecture](docs/ARCHITECTURE.md) | Architecture technique et patterns | -| [Specification API](docs/API_SPECIFICATION.md) | Contrat REST complet | -| [Regles metier](docs/BUSINESS_RULES.md) | Regles metier detaillees | -| [User Stories](docs/USER_STORIES.md) | Fonctionnalites utilisateur | -| [Roadmap](docs/DEVELOPMENT_ROADMAP.md) | Plan de developpement | +| [Architecture](docs/mvp/ARCHITECTURE.md) | Architecture technique et patterns | +| [Specification API](docs/mvp/API_SPECIFICATION.md) | Contrat REST complet | +| [Regles metier](docs/mvp/BUSINESS_RULES.md) | Regles metier detaillees | +| [User Stories](docs/mvp/USER_STORIES.md) | Fonctionnalites utilisateur | +| [Roadmap](docs/mvp/DEVELOPMENT_ROADMAP.md) | Plan de developpement | +| [Spec Tags Rework](docs/features/tags-rework/SPEC_TAGS_REWORK.md) | Rework systeme de tags | ## Auteur diff --git a/docs/0 - brainstorming futur.md b/docs/0 - brainstorming futur.md index b35da034..77b8a040 100644 --- a/docs/0 - brainstorming futur.md +++ b/docs/0 - brainstorming futur.md @@ -6,8 +6,6 @@ Tout doit être cohérent avec l'application et son fonctionnement actuel. Ce so Tout doit être clair et maitrisé, pensé pour être maintenable et évoluer dans le temps. Toute la logique business doit être validé et sans zone d'ombre restante avant d'écrire du code. -# audit refactorisation complete back + front - # Rework du système de tags avoir une liste par défaut globale à la création d'une communauté, et permettre à chaque communauté de créer ses propres tags complémentaires ??? @@ -17,6 +15,7 @@ avoir une liste par défaut globale à la création d'une communauté, et permet Problèmes similaires au système de tags : -> vu que les recettes sont associé à un user, comment faire de manière logique ? +proposer une modification de recette doit aussi prendre en compte les ingrédients (je crois que ce n'est pas le cas) # Rework des pages recettes (v2) diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md new file mode 100644 index 00000000..5d93481b --- /dev/null +++ b/docs/features/tags-rework/ROADMAP.md @@ -0,0 +1,82 @@ +# Roadmap : Rework Tags (Phase 10) + +> **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` +> **Branche** : a creer (`tags-rework`) + +--- + +## 10.1 - Schema & Migration + +- [ ] Migration Prisma : enrichir Tag (scope, communityId, status, createdById) +- [ ] Nouveau modele TagSuggestion +- [ ] Nouveau modele UserCommunityTagPreference +- [ ] Nouveau modele ModeratorNotificationPreference +- [ ] Migration des tags existants → scope=GLOBAL, status=APPROVED +- [ ] Tests migration + +## 10.2 - Backend Tags (CRUD + validation) + +- [ ] Refactoring recipeService : upsertTags → logique scope-aware +- [ ] Endpoint autocomplete tags : scope-aware (global + communaute) +- [ ] Creation tag pending (membre sur recette communautaire) +- [ ] Reutilisation tag pending existant (meme communaute) +- [ ] Regles d'unicite (global vs communaute vs pending) +- [ ] Gestion tags au fork inter-communaute (tags inconnus → pending) +- [ ] Tags sur recette perso : global + communautes selon preferences +- [ ] Tests unitaires + integration + +## 10.3 - Backend Administration tags + +- [ ] Endpoints moderateur : GET/POST/PUT/DELETE community tags +- [ ] Endpoints moderateur : approve/reject tag pending +- [ ] Cascade rejet : hard delete tag + RecipeTags + notifications +- [ ] Cascade validation : update status + notifications +- [ ] Adaptation endpoints SuperAdmin (filtre scope) +- [ ] Tests + +## 10.4 - Backend TagSuggestion + +- [ ] Endpoint POST tag-suggestion (suggerer tag sur recette d'autrui) +- [ ] Endpoint accept/reject par owner +- [ ] Workflow 2 etapes : owner → moderateur (si tag inconnu) +- [ ] Auto-rejet suggestions sur recettes orphelines +- [ ] Tests + +## 10.5 - Backend Preferences & Notifications + +- [ ] Endpoints UserCommunityTagPreference (GET/PUT) +- [ ] Endpoints ModeratorNotificationPreference (GET/PUT) +- [ ] Notifications WebSocket : tag:pending, tag:approved, tag:rejected +- [ ] Notifications WebSocket : tag-suggestion:new, approved, rejected +- [ ] Filtrage notifications selon preferences moderateur +- [ ] Tests + +## 10.6 - Frontend Tags (refactoring) + +- [ ] Composant TagBadge : style normal vs pending +- [ ] Autocomplete tags : scope-aware (global + communaute filtree) +- [ ] Creation recette : gestion tag inconnu → pending +- [ ] Edition recette : idem +- [ ] Affichage tags pending sur RecipeDetailPage +- [ ] Tests composants + +## 10.7 - Frontend Administration tags + +- [ ] Page moderateur : liste tags communaute (APPROVED + PENDING) +- [ ] Actions moderateur : creer, renommer, supprimer tag communaute +- [ ] Actions moderateur : valider/rejeter tag pending +- [ ] Adaptation pages SuperAdmin (filtre scope) +- [ ] Tests + +## 10.8 - Frontend TagSuggestion + +- [ ] Bouton "Suggerer un tag" sur recette d'autrui +- [ ] Vue owner : liste suggestions recues, accept/reject +- [ ] Notifications temps reel tag suggestions +- [ ] Tests + +## 10.9 - Frontend Preferences + +- [ ] Page profil : toggle visibilite tags par communaute +- [ ] Page profil moderateur : toggle notifications tags par communaute + global +- [ ] Tests diff --git a/docs/features/tags-rework/SPEC_TAGS_REWORK.md b/docs/features/tags-rework/SPEC_TAGS_REWORK.md new file mode 100644 index 00000000..65fed8bf --- /dev/null +++ b/docs/features/tags-rework/SPEC_TAGS_REWORK.md @@ -0,0 +1,444 @@ +# Specification : Rework du systeme de Tags + +> **Statut** : SPEC VALIDEE - En attente d'implementation +> **Date** : 2026-02-12 +> **Prerequis** : MVP complet (phases 0-8) +> **Roadmap** : `ROADMAP.md` (meme dossier) + +--- + +## 1. Vue d'ensemble + +Le systeme actuel de tags est "plat" : une table globale unique, creation a la volee par n'importe quel utilisateur. Ce rework introduit **3 niveaux de portee** et un **systeme de validation** par les moderateurs. + +### 1.1 Les 3 niveaux de tags + +| Niveau | Cree par | Visible par | Cycle de vie | +|--------|----------|-------------|--------------| +| **Global** | SuperAdmin | Tout le monde, partout | Permanent (CRUD SuperAdmin) | +| **Communaute** | Moderateur (ou valide par moderateur) | Membres de la communaute | Permanent tant que la communaute existe | +| **Pending** | N'importe quel membre | Membres de la communaute (style different) | Temporaire, en attente de validation | + +### 1.2 Principe cle + +La creation libre de tags a la volee est **supprimee**. Seuls les SuperAdmin (global) et moderateurs (communaute) peuvent creer des tags definitifs. Les membres peuvent **proposer** des tags, qui passent par un processus de validation. + +--- + +## 2. Schema de donnees + +### 2.1 Modifications du modele Tag + +``` +Tag (modifie) + id String @id @default(uuid()) + name String @unique + scope TagScope @default(GLOBAL) // NOUVEAU + communityId String? // NOUVEAU - null si GLOBAL + status TagStatus @default(APPROVED) // NOUVEAU + createdById String? // NOUVEAU - userId du createur (null si SuperAdmin) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + community Community? @relation(fields: [communityId], references: [id]) + createdBy User? @relation(fields: [createdById], references: [id]) + recipes RecipeTag[] + + @@unique([name, communityId]) // Remplace @@unique sur name seul + @@index([communityId, status]) + @@index([name]) +``` + +### 2.2 Nouveaux enums + +``` +TagScope: GLOBAL | COMMUNITY + +TagStatus: APPROVED | PENDING | REJECTED +``` + +### 2.3 Nouveau modele : TagSuggestion + +Pour les suggestions de tags sur les recettes d'autres membres. + +``` +TagSuggestion + id String @id @default(uuid()) + recipeId String + tagName String // Nom du tag suggere (normalise) + suggestedById String + status TagSuggestionStatus @default(PENDING_OWNER) + createdAt DateTime @default(now()) + decidedAt DateTime? + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + suggestedBy User @relation(fields: [suggestedById], references: [id]) + + @@unique([recipeId, tagName, suggestedById]) + @@index([recipeId, status]) +``` + +``` +TagSuggestionStatus: PENDING_OWNER | PENDING_MODERATOR | APPROVED | REJECTED +``` + +### 2.4 Nouveau modele : UserCommunityTagPreference + +Preference de visibilite des tags communautaires dans le catalogue personnel. + +``` +UserCommunityTagPreference + userId String + communityId String + showTags Boolean @default(true) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + + @@id([userId, communityId]) +``` + +### 2.5 Nouveau modele : ModeratorNotificationPreference + +Preference de notification des moderateurs pour les tags pending. + +``` +ModeratorNotificationPreference + userId String + communityId String? // null = preference globale (toutes les communautes) + tagNotifications Boolean @default(true) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + community Community? @relation(fields: [communityId], references: [id], onDelete: Cascade) + + @@id([userId, communityId]) +``` + +### 2.6 RecipeTag (inchange) + +``` +RecipeTag + recipeId String + tagId String + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([recipeId, tagId]) +``` + +--- + +## 3. Regles d'unicite des noms + +| Situation | Autorise ? | +|-----------|------------| +| Tag global "Italien" + tag communaute "Italien" | **NON** - le global a la priorite | +| Tag communaute A "Fait maison" + tag communaute B "Fait maison" | **OUI** - communautes isolees | +| Tag communaute A "Pizza" (APPROVED) + tag pending communaute A "Pizza" | **NON** - doublon dans la meme communaute | +| Tag pending communaute A "Pizza" + un autre user veut "Pizza" dans A | **OK** - reutilise le tag pending existant | +| Tag global "Dessert" + creation d'un tag communaute "Dessert" | **NON** - erreur, le global existe deja | + +**Regle de validation a la creation :** +1. Verifier qu'aucun tag GLOBAL n'a le meme nom (normalise) +2. Verifier qu'aucun tag (COMMUNITY, APPROVED ou PENDING) n'a le meme nom dans la meme communaute + +--- + +## 4. Cycles de vie + +### 4.1 Creation de tag global (SuperAdmin) + +``` +1. SuperAdmin cree un tag via le panneau admin +2. Tag cree : scope=GLOBAL, communityId=null, status=APPROVED +3. Verifier unicite : aucun tag global avec le meme nom +4. AdminActivityLog : TAG_CREATED +5. Le tag est immediatement visible partout +``` + +### 4.2 Creation de tag communaute (Moderateur) + +``` +1. Moderateur cree un tag via le panneau d'admin tags de la communaute +2. Tag cree : scope=COMMUNITY, communityId=X, status=APPROVED +3. Verifier unicite : pas de global avec meme nom + pas de communaute meme nom dans X +4. ActivityLog : TAG_CREATED (dans la communaute) +5. Le tag est immediatement visible dans la communaute +``` + +### 4.3 Tag pending (Membre cree un tag inconnu) + +``` +DECLENCHEUR : Membre cree/edite une recette communautaire avec un tag qui n'existe pas + (ni en global, ni en communaute pour cette communaute) + +ACTIONS : +1. Tag cree : scope=COMMUNITY, communityId=X, status=PENDING, createdById=userId +2. RecipeTag cree normalement (la recette est associee au tag pending) +3. Notification envoyee aux moderateurs de la communaute (selon preferences) +4. La recette est creee/mise a jour normalement +5. Le tag pending est affiche avec un style different (couleur/badge) + +REUTILISATION D'UN TAG PENDING : + Si un autre utilisateur (ou le meme) veut utiliser le meme tag pending + sur une autre recette de la meme communaute : + → PAS de refus, PAS de creation d'un nouveau tag + → RecipeTag cree en pointant vers le tag PENDING existant + → Pas de nouvelle notification (les moderateurs savent deja) + → Le tag pending accumule des RecipeTag en attendant la decision + +RESOLUTION PAR MODERATEUR : + A) Valider : + → Tag.status = APPROVED + → TOUTES les recettes utilisant ce tag passent en style normal + → Notification au createur du tag : "Votre tag X a ete valide" + + B) Rejeter : + → Hard delete du tag + → Hard delete de TOUS les RecipeTag associes (cascade) + → Notification au createur du tag : "Votre tag X a ete rejete" + → Notification aux autres utilisateurs ayant utilise ce tag pending +``` + +### 4.4 TagSuggestion (Suggestion de tag sur la recette d'un autre) + +``` +DECLENCHEUR : Membre veut ajouter un tag sur une recette communautaire dont il n'est pas + le createur + +ETAPE 1 - Validation par le proprietaire de la recette : +1. TagSuggestion cree : status=PENDING_OWNER +2. Notification au proprietaire de la recette +3. Le proprietaire accepte ou refuse : + A) Refuse → TagSuggestion.status = REJECTED, fin + B) Accepte → passer a l'etape 2 + +ETAPE 2 - Le tag existe-t-il deja ? + A) Tag global ou tag communaute APPROVED existe : + → RecipeTag cree directement + → TagSuggestion.status = APPROVED, fin + + B) Tag inconnu (n'existe pas en global ni en communaute) : + → Tag cree en PENDING (comme section 4.3) + → RecipeTag cree (tag pending, style different) + → TagSuggestion.status = PENDING_MODERATOR + → Notification aux moderateurs + → Quand le moderateur valide le tag pending : + → TagSuggestion.status = APPROVED + → Quand le moderateur rejette le tag pending : + → TagSuggestion.status = REJECTED + → RecipeTag supprime (cascade du tag) +``` + +--- + +## 5. Visibilite des tags + +### 5.1 Dans une communaute + +L'autocomplete propose : +1. Tous les tags **GLOBAL** (status=APPROVED) +2. Tous les tags **COMMUNITY** de cette communaute (status=APPROVED) +3. *Pas* les tags pending, *pas* les tags d'autres communautes + +### 5.2 Dans le catalogue personnel + +L'autocomplete propose : +1. Tous les tags **GLOBAL** +2. Les tags **COMMUNITY** (APPROVED) des communautes auxquelles l'utilisateur appartient + **ET** pour lesquelles `UserCommunityTagPreference.showTags = true` (defaut: true) + +### 5.3 Affichage sur une recette + +| Type de tag | Style | +|-------------|-------| +| Global (APPROVED) | Normal (couleur principale) | +| Communaute (APPROVED) | Normal (couleur principale) | +| Pending | Couleur differente (ex: gris, contour pointille, badge "en attente") | + +--- + +## 6. Partage inter-communaute (fork) et tags + +``` +Quand une recette est partagee (fork) vers une communaute cible : + +POUR CHAQUE TAG de la recette source : + 1. Tag GLOBAL → copie directe, rien a faire + 2. Tag COMMUNITY qui existe aussi dans la communaute cible → copie directe + 3. Tag COMMUNITY qui N'EXISTE PAS dans la communaute cible : + → Tag cree en PENDING dans la communaute cible + → RecipeTag cree (tag pending) + → Notification aux moderateurs de la communaute cible + → Les moderateurs valident ou rejettent (meme process que 4.3) +``` + +--- + +## 7. Synchronisation (rappel) + +**Les tags restent LOCAUX a chaque recette.** Pas de synchronisation entre : +- Recette personnelle et copies communautaires +- Copies communautaires dans differentes communautes + +Seuls titre, contenu, imageUrl et ingredients sont synchronises (comportement existant inchange). + +--- + +## 8. Administration + +### 8.1 SuperAdmin (existant, etendu) + +Les endpoints admin existants restent, avec adaptation : +- **Lister tags** : filtre par scope (GLOBAL / COMMUNITY / tous) +- **Creer tag** : scope=GLOBAL uniquement +- **Renommer tag** : n'importe quel tag (global ou communaute) +- **Supprimer tag** : n'importe quel tag +- **Fusionner tags** : les deux tags doivent etre du meme scope et meme communaute (ou les deux globaux) + +### 8.2 Panneau moderateur (NOUVEAU) + +Accessible aux moderateurs d'une communaute. + +**Endpoints :** + +``` +GET /api/communities/:id/tags → Liste des tags (APPROVED + PENDING) +POST /api/communities/:id/tags → Creer un tag communaute +PUT /api/communities/:id/tags/:tagId → Renommer un tag communaute +DELETE /api/communities/:id/tags/:tagId → Supprimer un tag communaute +POST /api/communities/:id/tags/:tagId/approve → Valider un tag pending +POST /api/communities/:id/tags/:tagId/reject → Rejeter un tag pending +``` + +**Contraintes :** +- Seuls les MODERATOR/ADMIN de la communaute peuvent acceder +- Un moderateur ne peut pas modifier/supprimer un tag GLOBAL (c'est le SuperAdmin) +- Un moderateur ne peut agir que sur les tags de sa communaute + +### 8.3 TagSuggestion endpoints (NOUVEAU) + +``` +POST /api/recipes/:recipeId/tag-suggestions → Suggerer un tag +GET /api/recipes/:recipeId/tag-suggestions → Voir les suggestions (owner/moderator) +POST /api/tag-suggestions/:id/accept → Owner accepte la suggestion +POST /api/tag-suggestions/:id/reject → Owner rejette la suggestion +``` + +--- + +## 9. Preferences utilisateur + +### 9.1 Visibilite tags communautaires (catalogue perso) + +``` +GET /api/users/me/tag-preferences → Liste des preferences +PUT /api/users/me/tag-preferences/:communityId → Activer/desactiver +``` + +**Defaut :** `showTags = true` pour chaque communaute. L'entree est creee a la volee quand l'utilisateur rejoint une communaute (ou au premier toggle). + +### 9.2 Notifications moderateur + +``` +GET /api/users/me/notification-preferences → Preferences de notification +PUT /api/users/me/notification-preferences/tags → Toggle global +PUT /api/users/me/notification-preferences/tags/:communityId → Toggle par communaute +``` + +**Defaut :** `tagNotifications = true` globalement. Le moderateur peut desactiver globalement ou par communaute. + +--- + +## 10. Notifications (WebSocket) + +### 10.1 Evenements emis + +| Evenement | Destinataires | Declencheur | +|-----------|---------------|-------------| +| `tag:pending` | Moderateurs de la communaute (si notifications activees) | Nouveau tag pending cree | +| `tag:approved` | Createur du tag pending | Moderateur valide le tag | +| `tag:rejected` | Createur du tag pending | Moderateur rejette le tag | +| `tag-suggestion:new` | Proprietaire de la recette | Nouveau TagSuggestion | +| `tag-suggestion:approved` | Auteur de la suggestion | Owner accepte | +| `tag-suggestion:rejected` | Auteur de la suggestion | Owner rejette | +| `tag-suggestion:pending-mod` | Moderateurs | Suggestion acceptee par owner mais tag inconnu | + +--- + +## 11. Migration de l'existant + +### 11.1 Tags existants + +``` +1. Tous les tags existants deviennent scope=GLOBAL, status=APPROVED, communityId=null +2. Les RecipeTag existants restent inchanges +3. La contrainte @@unique passe de [name] a [name, communityId] +4. Ajout des nouveaux champs avec valeurs par defaut +``` + +### 11.2 Aucune perte de donnees + +- Les tags deja associes aux recettes restent associes +- Les recettes ne perdent aucun tag +- Le SuperAdmin retrouve tous les tags dans son panneau (filtres GLOBAL) + +--- + +## 12. Regles de validation + +### 12.1 Tag + +```typescript +{ + name: string, min: 2, max: 50, normalise (trim + lowercase) +} +``` + +### 12.2 Limites + +- Max **10 tags** par recette (inchange) +- Max **100 tags COMMUNITY** par communaute (nouveau, pour eviter l'abus) +- Pas de limite sur les tags GLOBAL (gere par SuperAdmin) + +--- + +## 13. Recettes orphelines et tags pending + +``` +SI une recette communautaire devient orpheline (createur parti) : + - Les tags APPROVED restent sur la recette + - Les tags PENDING restent (le moderateur peut toujours valider/rejeter) + - Les TagSuggestion PENDING_OWNER sont auto-rejetees (plus de proprietaire) +``` + +--- + +## 14. Suppression de communaute et tags + +``` +SI une communaute est supprimee (soft delete) : + - Tous les tags COMMUNITY de cette communaute deviennent inaccessibles + (filtres par community.deletedAt dans les requetes) + - Les RecipeTag restent en base (les recettes sont aussi soft deleted) + - Les TagSuggestion en cours sont auto-rejetees + - Les UserCommunityTagPreference restent (nettoyees par le soft delete filter) + - Les ModeratorNotificationPreference restent (idem) +``` + +--- + +## 15. Codes d'erreur (NOUVEAUX) + +| Code | Message | Contexte | +|------|---------|----------| +| `TAG_001` | Tag non trouve | ID invalide ou supprime | +| `TAG_002` | Nom de tag deja utilise | Unicite violee (global ou meme communaute) | +| `TAG_003` | Limite de tags atteinte | >10 sur une recette ou >100 dans une communaute | +| `TAG_004` | Permission insuffisante | Non-moderateur essaie d'admin les tags | +| `TAG_005` | Tag global non modifiable | Moderateur essaie de modifier un tag global | +| `TAG_006` | Suggestion deja existante | Meme tag suggere par meme user sur meme recette | +| `TAG_007` | Auto-suggestion interdite | User suggere un tag sur sa propre recette | diff --git a/docs/API_SPECIFICATION.md b/docs/mvp/API_SPECIFICATION.md similarity index 100% rename from docs/API_SPECIFICATION.md rename to docs/mvp/API_SPECIFICATION.md diff --git a/docs/ARCHITECTURE.md b/docs/mvp/ARCHITECTURE.md similarity index 100% rename from docs/ARCHITECTURE.md rename to docs/mvp/ARCHITECTURE.md diff --git a/docs/BUSINESS_RULES.md b/docs/mvp/BUSINESS_RULES.md similarity index 100% rename from docs/BUSINESS_RULES.md rename to docs/mvp/BUSINESS_RULES.md diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/mvp/DEVELOPMENT_ROADMAP.md similarity index 87% rename from docs/DEVELOPMENT_ROADMAP.md rename to docs/mvp/DEVELOPMENT_ROADMAP.md index deaa73b6..185801af 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/mvp/DEVELOPMENT_ROADMAP.md @@ -535,7 +535,7 @@ Ce document decrit les phases de developpement du MVP de Forest Manager, avec le ## Phase 7: Partage Inter-Communautes - + **Decisions metier validees (voir BUSINESS_RULES.md section 5):** @@ -828,88 +828,4 @@ Phase 8 (Finitions MVP) --- -## Tests - -### Infrastructure - -**Backend**: - -- Framework: Vitest + Supertest -- DB: PostgreSQL test database (via `testPrisma`) -- Helpers: `backend/src/__tests__/setup/testHelpers.ts` -- Config: `backend/vitest.config.ts` - -**Frontend**: - -- Framework: Vitest + Testing Library + MSW -- Mocks: `frontend/src/__tests__/setup/mswHandlers.ts` -- Utils: `frontend/src/__tests__/setup/testUtils.tsx` -- Config: `frontend/vitest.config.ts` - -### Commandes - -```bash -# Backend -cd backend && npm test # Lancer tous les tests -cd backend && npm run test:coverage # Tests avec couverture - -# Frontend -cd frontend && npm test # Lancer tous les tests -cd frontend && npm run test:coverage # Tests avec couverture - -# CI/CD -# Les tests sont executes automatiquement dans deploy.yml: -# - Job: test-backend -# - Job: test-frontend -``` - -### Couverture cible - -- Backend: > 80% sur controllers/routes -- Frontend: > 70% sur composants critiques - -### Template pour nouvelles fonctionnalites - -Lors de l'ajout d'une nouvelle fonctionnalite, inclure les tests suivants: - -```markdown -### X.Y Nouvelle Fonctionnalite - -- [ ] Implementation backend -- [ ] Implementation frontend -- [ ] **Tests backend**: [fichiers .test.ts] - - Tests CRUD endpoints - - Tests validation input - - Tests error cases - - Tests authentication/authorization -- [ ] **Tests frontend**: [fichiers .test.tsx] - - Tests rendu composants - - Tests interactions utilisateur - - Tests etats (loading, error, success) - - Tests integration avec API (MSW) -``` - -### Resume des tests implementes - -| Categorie | Fichiers | Tests | -| ------------------- | ------------------------------------------------------------------------------------------- | -------- | -| Backend Auth | auth.test.ts, adminAuth.test.ts | ~30 | -| Backend Admin API | adminTags, adminIngredients, adminFeatures, adminCommunities, adminDashboard, adminActivity | ~50 | -| Backend User API | recipes.test.ts, tags.test.ts, ingredients.test.ts | ~42 | -| Backend Communities | communities.test.ts, communityRecipes.test.ts, invitations.test.ts, members.test.ts | ~112 | -| Backend Proposals | proposals.test.ts | ~31 | -| Backend Variants | variants.test.ts | ~10 | -| Backend Activity | activity.test.ts | ~15 | -| Frontend Contexts | AuthContext, AdminAuthContext | ~13 | -| Frontend Auth | LoginModal, Modal, SignUpPage, ProtectedRoute, NavBar | ~25 | -| Frontend Admin | AdminProtectedRoute, AdminLoginPage, AdminDashboardPage, AdminLayout | ~19 | -| Frontend Admin Pages| AdminTagsPage, AdminIngredientsPage, AdminFeaturesPage, AdminCommunitiesPage, AdminActivityPage | ~35 | -| Frontend Recipes | RecipeCard, RecipeFilters, TagSelector, IngredientList | ~28 | -| Frontend Pages | HomePage, RecipesPage, MainLayout, Sidebar | ~25 | -| Frontend Activity | ActivityFeed.test.tsx | ~8 | -| Backend Share | share.test.ts | ~28 | -| Frontend Communities| CommunitiesPage, CommunityDetailPage | ~18 | -| Frontend Invitations| InviteCard, MembersList, InviteUserModal | ~16 | -| Frontend Share | ShareRecipeModal | ~7 | -| Frontend ErrorBoundary | ErrorBoundary.test.tsx | ~2 | -| **Total** | | **~534** | +> **Tests** : Infrastructure, commandes, inventaire et conventions sont dans `.claude/context/TESTS.md` (reference unique). diff --git a/docs/TESTS_IMPLEMENTATION_PLAN.md b/docs/mvp/TESTS_IMPLEMENTATION_PLAN.md similarity index 99% rename from docs/TESTS_IMPLEMENTATION_PLAN.md rename to docs/mvp/TESTS_IMPLEMENTATION_PLAN.md index 26af8c51..bc8c6eb9 100644 --- a/docs/TESTS_IMPLEMENTATION_PLAN.md +++ b/docs/mvp/TESTS_IMPLEMENTATION_PLAN.md @@ -227,7 +227,7 @@ frontend/src/__tests__/ 1. `backend/src/__tests__/setup/testHelpers.ts` - Ajouter helpers 2. `frontend/src/__tests__/setup/mswHandlers.ts` - Ajouter mocks admin API -3. `docs/DEVELOPMENT_ROADMAP.md` - Ajouter sections tests +3. `DEVELOPMENT_ROADMAP.md` - Ajouter sections tests --- diff --git a/docs/USER_STORIES.md b/docs/mvp/USER_STORIES.md similarity index 100% rename from docs/USER_STORIES.md rename to docs/mvp/USER_STORIES.md From 1eef56239d0acd41d71513c3968d301cfbf9df7d Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Fri, 13 Feb 2026 13:16:05 +0100 Subject: [PATCH 002/221] feat: tags rework schema & migration (Phase 10.1) Add 3-level tag scope system (Global/Community/Pending) with new enums (TagScope, TagStatus, TagSuggestionStatus), enriched Tag model, and 3 new models (TagSuggestion, UserCommunityTagPreference, ModeratorNotificationPreference). Existing tags migrated to scope=GLOBAL, status=APPROVED. Adapt tag upsert/lookup to findFirst pattern for nullable compound unique compatibility. --- .claude/context/DB_MODELS.md | 45 ++++++--- .../migration.sql | 97 +++++++++++++++++++ backend/prisma/schema.prisma | 94 +++++++++++++++++- .../__tests__/integration/adminTags.test.ts | 4 +- backend/src/__tests__/setup/testHelpers.ts | 38 ++++++-- .../src/admin/controllers/tagsController.ts | 8 +- .../src/services/communityRecipeService.ts | 9 +- backend/src/services/recipeService.ts | 9 +- backend/src/util/prismaSelects.ts | 5 +- docs/features/tags-rework/ROADMAP.md | 14 +-- 10 files changed, 276 insertions(+), 47 deletions(-) create mode 100644 backend/prisma/migrations/20260213120000_tags_rework_schema/migration.sql diff --git a/.claude/context/DB_MODELS.md b/.claude/context/DB_MODELS.md index 206d5ddb..bf88e7f2 100644 --- a/.claude/context/DB_MODELS.md +++ b/.claude/context/DB_MODELS.md @@ -1,9 +1,9 @@ # Database Schema Reference -Source: `backend/prisma/schema.prisma` (489 lines) +Source: `backend/prisma/schema.prisma` DB: PostgreSQL | ORM: Prisma -## Models (20 total) +## Models (24 total) ### Sessions (isolees) | Model | Champs cles | Notes | @@ -32,11 +32,18 @@ DB: PostgreSQL | ORM: Prisma |-------|-------------|-------| | Recipe | id, title, content, imageUrl?, isVariant, creatorId, communityId?, originRecipeId?, sharedFromCommunityId?, deletedAt? | Soft delete. communityId=null → perso | | RecipeUpdateProposal | recipeId, proposerId, proposedTitle, proposedContent, status(PENDING/ACCEPTED/REJECTED), deletedAt? | Soft delete | -| Tag | id, name(unique) | Index name | +| Tag | id, name, scope(GLOBAL/COMMUNITY), status(APPROVED/PENDING), communityId?, createdById?, createdAt, updatedAt | @@unique(name,communityId) + partial unique index global. Index name, communityId+status | | RecipeTag | recipeId, tagId | PK composite, **Cascade** delete | | Ingredient | id, name(unique) | Index name | | RecipeIngredient | recipeId, ingredientId, quantity?, order | **Cascade** delete, @@unique(recipeId,ingredientId) | +### Tags (3 models - Phase 10) +| Model | Champs cles | Notes | +|-------|-------------|-------| +| TagSuggestion | id, recipeId, tagName, suggestedById, status(TagSuggestionStatus), createdAt, decidedAt? | @@unique(recipeId,tagName,suggestedById), Cascade on recipe delete | +| UserCommunityTagPreference | userId, communityId, showTags(default true), updatedAt | PK composite(userId,communityId), Cascade delete | +| ModeratorNotificationPreference | id, userId, communityId?(null=global), tagNotifications(default true), updatedAt | @@unique(userId,communityId), Cascade delete | + ### Analytics (2 models - futur) | Model | Champs cles | Notes | |-------|-------------|-------| @@ -56,6 +63,10 @@ Visibility: INVITE_ONLY InviteStatus: PENDING | ACCEPTED | REJECTED | CANCELLED ProposalStatus: PENDING | ACCEPTED | REJECTED +TagScope: GLOBAL | COMMUNITY +TagStatus: APPROVED | PENDING +TagSuggestionStatus: PENDING_OWNER | PENDING_MODERATOR | APPROVED | REJECTED + AdminActionType: TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_MERGED | INGREDIENT_CREATED | INGREDIENT_UPDATED | INGREDIENT_DELETED | INGREDIENT_MERGED | COMMUNITY_RENAMED | COMMUNITY_DELETED | @@ -71,15 +82,23 @@ ActivityType: RECIPE_CREATED | RECIPE_UPDATED | RECIPE_DELETED | RECIPE_SHARED | ## Relations cles ``` -User ←N:N→ Community (via UserCommunity avec role) -User ←1:N→ Recipe (creatorId) -Recipe ←N:N→ Tag (via RecipeTag, cascade) -Recipe ←N:N→ Ingredient (via RecipeIngredient, cascade) -Recipe ←self→ Recipe (originRecipeId → variantes/forks) -Community ←1:N→ CommunityInvite -User ←1:N→ CommunityInvite (inviter + invitee) -Community ←N:N→ Feature (via CommunityFeature, soft revoke) -AdminUser ←1:N→ AdminActivityLog +User <-N:N-> Community (via UserCommunity avec role) +User <-1:N-> Recipe (creatorId) +User <-1:N-> Tag (createdById, relation "TagCreator") +User <-1:N-> TagSuggestion (suggestedById) +User <-1:N-> UserCommunityTagPreference +User <-1:N-> ModeratorNotificationPreference +Recipe <-N:N-> Tag (via RecipeTag, cascade) +Recipe <-N:N-> Ingredient (via RecipeIngredient, cascade) +Recipe <-self-> Recipe (originRecipeId -> variantes/forks) +Recipe <-1:N-> TagSuggestion (cascade on delete) +Community <-1:N-> CommunityInvite +Community <-1:N-> Tag (communityId) +Community <-1:N-> UserCommunityTagPreference (cascade) +Community <-1:N-> ModeratorNotificationPreference (cascade) +User <-1:N-> CommunityInvite (inviter + invitee) +Community <-N:N-> Feature (via CommunityFeature, soft revoke) +AdminUser <-1:N-> AdminActivityLog ``` ## Regles delete @@ -87,5 +106,5 @@ AdminUser ←1:N→ AdminActivityLog | Type | Modeles | Methode | |------|---------|---------| | Soft delete (deletedAt) | User, Community, UserCommunity, Recipe, RecipeUpdateProposal, CommunityInvite | Applicatif (where deletedAt: null) | -| Hard delete (Cascade) | RecipeTag, RecipeIngredient, RecipeAnalytics, RecipeView | DB cascade | +| Hard delete (Cascade) | RecipeTag, RecipeIngredient, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, ModeratorNotificationPreference | DB cascade | | Soft revoke | CommunityFeature | revokedAt timestamp | diff --git a/backend/prisma/migrations/20260213120000_tags_rework_schema/migration.sql b/backend/prisma/migrations/20260213120000_tags_rework_schema/migration.sql new file mode 100644 index 00000000..11c0c246 --- /dev/null +++ b/backend/prisma/migrations/20260213120000_tags_rework_schema/migration.sql @@ -0,0 +1,97 @@ +-- CreateEnum +CREATE TYPE "TagScope" AS ENUM ('GLOBAL', 'COMMUNITY'); + +-- CreateEnum +CREATE TYPE "TagStatus" AS ENUM ('APPROVED', 'PENDING'); + +-- CreateEnum +CREATE TYPE "TagSuggestionStatus" AS ENUM ('PENDING_OWNER', 'PENDING_MODERATOR', 'APPROVED', 'REJECTED'); + +-- AlterTable: Enrich Tag model +-- Step 1: Add columns with defaults for existing rows +ALTER TABLE "Tag" ADD COLUMN "scope" "TagScope" NOT NULL DEFAULT 'GLOBAL'; +ALTER TABLE "Tag" ADD COLUMN "status" "TagStatus" NOT NULL DEFAULT 'APPROVED'; +ALTER TABLE "Tag" ADD COLUMN "communityId" TEXT; +ALTER TABLE "Tag" ADD COLUMN "createdById" TEXT; +ALTER TABLE "Tag" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "Tag" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Step 2: Drop old unique constraint on name, add new compound unique +DROP INDEX "Tag_name_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_name_communityId_key" ON "Tag"("name", "communityId"); + +-- Partial unique index for global tags (communityId IS NULL) +-- PostgreSQL treats NULLs as distinct in regular unique constraints +CREATE UNIQUE INDEX "Tag_name_global_unique" ON "Tag"("name") WHERE "communityId" IS NULL; + +-- CreateIndex +CREATE INDEX "Tag_communityId_status_idx" ON "Tag"("communityId", "status"); + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "TagSuggestion" ( + "id" TEXT NOT NULL, + "recipeId" TEXT NOT NULL, + "tagName" TEXT NOT NULL, + "suggestedById" TEXT NOT NULL, + "status" "TagSuggestionStatus" NOT NULL DEFAULT 'PENDING_OWNER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "decidedAt" TIMESTAMP(3), + + CONSTRAINT "TagSuggestion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TagSuggestion_recipeId_tagName_suggestedById_key" ON "TagSuggestion"("recipeId", "tagName", "suggestedById"); + +-- CreateIndex +CREATE INDEX "TagSuggestion_recipeId_status_idx" ON "TagSuggestion"("recipeId", "status"); + +-- AddForeignKey +ALTER TABLE "TagSuggestion" ADD CONSTRAINT "TagSuggestion_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagSuggestion" ADD CONSTRAINT "TagSuggestion_suggestedById_fkey" FOREIGN KEY ("suggestedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "UserCommunityTagPreference" ( + "userId" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "showTags" BOOLEAN NOT NULL DEFAULT true, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserCommunityTagPreference_pkey" PRIMARY KEY ("userId","communityId") +); + +-- AddForeignKey +ALTER TABLE "UserCommunityTagPreference" ADD CONSTRAINT "UserCommunityTagPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCommunityTagPreference" ADD CONSTRAINT "UserCommunityTagPreference_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "ModeratorNotificationPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "communityId" TEXT, + "tagNotifications" BOOLEAN NOT NULL DEFAULT true, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ModeratorNotificationPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ModeratorNotificationPreference_userId_communityId_key" ON "ModeratorNotificationPreference"("userId", "communityId"); + +-- AddForeignKey +ALTER TABLE "ModeratorNotificationPreference" ADD CONSTRAINT "ModeratorNotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModeratorNotificationPreference" ADD CONSTRAINT "ModeratorNotificationPreference_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46bda9be..438ed567 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -170,6 +170,10 @@ model User { recipeViews RecipeView[] invitesSent CommunityInvite[] @relation("InviteSender") invitesReceived CommunityInvite[] @relation("InviteReceiver") + createdTags Tag[] @relation("TagCreator") + tagSuggestions TagSuggestion[] + tagPreferences UserCommunityTagPreference[] + moderatorNotifPrefs ModeratorNotificationPreference[] @@index([email]) @@index([username]) @@ -196,6 +200,9 @@ model Community { activities ActivityLog[] invites CommunityInvite[] features CommunityFeature[] // Briques attribuees a cette communaute + tags Tag[] + tagPreferences UserCommunityTagPreference[] + moderatorNotifPrefs ModeratorNotificationPreference[] @@index([deletedAt]) } @@ -307,6 +314,7 @@ model Recipe { analytics RecipeAnalytics? views RecipeView[] activities ActivityLog[] + tagSuggestions TagSuggestion[] @@index([creatorId]) @@index([communityId]) @@ -352,14 +360,43 @@ enum ProposalStatus { // Note: Tables pivot - Cascade OK (hard delete quand recette supprimee) // ============================================================================= +enum TagScope { + GLOBAL + COMMUNITY +} + +enum TagStatus { + APPROVED + PENDING +} + +enum TagSuggestionStatus { + PENDING_OWNER + PENDING_MODERATOR + APPROVED + REJECTED +} + model Tag { - id String @id @default(uuid()) - name String @unique + id String @id @default(uuid()) + name String + scope TagScope @default(GLOBAL) + status TagStatus @default(APPROVED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations + communityId String? + community Community? @relation(fields: [communityId], references: [id]) + + createdById String? + createdBy User? @relation("TagCreator", fields: [createdById], references: [id]) + recipes RecipeTag[] + @@unique([name, communityId]) @@index([name]) + @@index([communityId, status]) } model RecipeTag { @@ -373,6 +410,59 @@ model RecipeTag { @@index([tagId]) } +// ============================================================================= +// TAG SUGGESTION (Suggestions de tags sur recettes d'autrui) +// ============================================================================= + +model TagSuggestion { + id String @id @default(uuid()) + recipeId String + tagName String + suggestedById String + status TagSuggestionStatus @default(PENDING_OWNER) + createdAt DateTime @default(now()) + decidedAt DateTime? + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + suggestedBy User @relation(fields: [suggestedById], references: [id]) + + @@unique([recipeId, tagName, suggestedById]) + @@index([recipeId, status]) +} + +// ============================================================================= +// USER COMMUNITY TAG PREFERENCE (Visibilite tags par communaute) +// ============================================================================= + +model UserCommunityTagPreference { + userId String + communityId String + showTags Boolean @default(true) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + + @@id([userId, communityId]) +} + +// ============================================================================= +// MODERATOR NOTIFICATION PREFERENCE (Preferences notifications tags) +// ============================================================================= + +model ModeratorNotificationPreference { + id String @id @default(uuid()) + userId String + communityId String? + tagNotifications Boolean @default(true) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + community Community? @relation(fields: [communityId], references: [id], onDelete: Cascade) + + @@unique([userId, communityId]) +} + // ============================================================================= // INGREDIENT & RECIPE-INGREDIENT // Note: Ingredients crees a la volee (comme les tags) diff --git a/backend/src/__tests__/integration/adminTags.test.ts b/backend/src/__tests__/integration/adminTags.test.ts index 2a90cb1e..d2967019 100644 --- a/backend/src/__tests__/integration/adminTags.test.ts +++ b/backend/src/__tests__/integration/adminTags.test.ts @@ -84,8 +84,8 @@ describe('Admin Tags API', () => { expect(res.body.tag.id).toBeDefined(); // Verifier en DB - const tag = await testPrisma.tag.findUnique({ - where: { name: 'new test tag' }, + const tag = await testPrisma.tag.findFirst({ + where: { name: 'new test tag', communityId: null }, }); expect(tag).not.toBeNull(); }); diff --git a/backend/src/__tests__/setup/testHelpers.ts b/backend/src/__tests__/setup/testHelpers.ts index 6550f986..e144a67d 100644 --- a/backend/src/__tests__/setup/testHelpers.ts +++ b/backend/src/__tests__/setup/testHelpers.ts @@ -136,21 +136,27 @@ export async function createTestRecipe( ingredients: Array<{ name: string; quantity?: string }>; }> ): Promise { + // Creer/trouver les tags en amont (compound unique avec nullable ne supporte pas connectOrCreate) + const tagIds: string[] = []; + if (data?.tags) { + for (const tagName of data.tags) { + const normalized = tagName.toLowerCase().trim(); + let tag = await testPrisma.tag.findFirst({ where: { name: normalized, communityId: null } }); + if (!tag) { + tag = await testPrisma.tag.create({ data: { name: normalized } }); + } + tagIds.push(tag.id); + } + } + const recipe = await testPrisma.recipe.create({ data: { title: data?.title ?? `Test Recipe ${Date.now()}`, content: data?.content ?? 'Test recipe content', imageUrl: data?.imageUrl ?? null, creatorId, - tags: data?.tags ? { - create: data.tags.map(tagName => ({ - tag: { - connectOrCreate: { - where: { name: tagName.toLowerCase().trim() }, - create: { name: tagName.toLowerCase().trim() }, - }, - }, - })), + tags: tagIds.length > 0 ? { + create: tagIds.map(tagId => ({ tagId })), } : undefined, ingredients: data?.ingredients ? { create: data.ingredients.map((ing, index) => ({ @@ -179,10 +185,22 @@ export async function createTestRecipe( /** * Creer un tag de test */ -export async function createTestTag(name?: string) { +export async function createTestTag( + name?: string, + options?: Partial<{ + scope: 'GLOBAL' | 'COMMUNITY'; + status: 'APPROVED' | 'PENDING'; + communityId: string; + createdById: string; + }> +) { return testPrisma.tag.create({ data: { name: name ?? `tag_${Date.now()}`, + scope: options?.scope, + status: options?.status, + communityId: options?.communityId, + createdById: options?.createdById, }, }); } diff --git a/backend/src/admin/controllers/tagsController.ts b/backend/src/admin/controllers/tagsController.ts index 157a05fd..216c6620 100644 --- a/backend/src/admin/controllers/tagsController.ts +++ b/backend/src/admin/controllers/tagsController.ts @@ -49,8 +49,8 @@ export const create: RequestHandler = async (req, res, next) => { const normalized = name.trim().toLowerCase(); - const existing = await prisma.tag.findUnique({ - where: { name: normalized }, + const existing = await prisma.tag.findFirst({ + where: { name: normalized, communityId: null }, }); if (existing) { @@ -100,8 +100,8 @@ export const update: RequestHandler = async (req, res, next) => { const normalized = name.trim().toLowerCase(); if (normalized !== tag.name) { - const existing = await prisma.tag.findUnique({ - where: { name: normalized }, + const existing = await prisma.tag.findFirst({ + where: { name: normalized, communityId: null }, }); if (existing) { throw createHttpError(409, "ADMIN_TAG_002: Tag already exists"); diff --git a/backend/src/services/communityRecipeService.ts b/backend/src/services/communityRecipeService.ts index cd7812cd..bdd7c837 100644 --- a/backend/src/services/communityRecipeService.ts +++ b/backend/src/services/communityRecipeService.ts @@ -63,11 +63,12 @@ export async function createCommunityRecipe( const normalizedTags = normalizeNames(data.tags); for (const tagName of normalizedTags) { - const tag = await tx.tag.upsert({ - where: { name: tagName }, - create: { name: tagName }, - update: {}, + let tag = await tx.tag.findFirst({ + where: { name: tagName, communityId: null }, }); + if (!tag) { + tag = await tx.tag.create({ data: { name: tagName } }); + } await tx.recipeTag.createMany({ data: [ diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index a436c85a..9e34ed60 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -19,11 +19,12 @@ export async function upsertTags(tx: TransactionClient, recipeId: string, tags: const normalizedTags = normalizeNames(tags); for (const tagName of normalizedTags) { - const tag = await tx.tag.upsert({ - where: { name: tagName }, - create: { name: tagName }, - update: {}, + let tag = await tx.tag.findFirst({ + where: { name: tagName, communityId: null }, }); + if (!tag) { + tag = await tx.tag.create({ data: { name: tagName } }); + } await tx.recipeTag.create({ data: { recipeId, tagId: tag.id }, diff --git a/backend/src/util/prismaSelects.ts b/backend/src/util/prismaSelects.ts index ee707760..32d7d525 100644 --- a/backend/src/util/prismaSelects.ts +++ b/backend/src/util/prismaSelects.ts @@ -3,13 +3,16 @@ * Centralise les patterns repetes dans les controllers. */ -/** Select pour les tags d'une recette (retourne { tag: { id, name } }) */ +/** Select pour les tags d'une recette (retourne { tag: { id, name, scope, status, communityId } }) */ export const RECIPE_TAGS_SELECT = { select: { tag: { select: { id: true, name: true, + scope: true, + status: true, + communityId: true, }, }, }, diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 5d93481b..dea15e57 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -1,18 +1,18 @@ # Roadmap : Rework Tags (Phase 10) > **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` -> **Branche** : a creer (`tags-rework`) +> **Branche** : `TagsRework` --- ## 10.1 - Schema & Migration -- [ ] Migration Prisma : enrichir Tag (scope, communityId, status, createdById) -- [ ] Nouveau modele TagSuggestion -- [ ] Nouveau modele UserCommunityTagPreference -- [ ] Nouveau modele ModeratorNotificationPreference -- [ ] Migration des tags existants → scope=GLOBAL, status=APPROVED -- [ ] Tests migration +- [x] Migration Prisma : enrichir Tag (scope, communityId, status, createdById) +- [x] Nouveau modele TagSuggestion +- [x] Nouveau modele UserCommunityTagPreference +- [x] Nouveau modele ModeratorNotificationPreference +- [x] Migration des tags existants → scope=GLOBAL, status=APPROVED +- [x] Tests migration ## 10.2 - Backend Tags (CRUD + validation) From 66c8fe8bb5eae71e9ff9767e9c23bbaae015a80f Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Fri, 13 Feb 2026 16:43:38 +0100 Subject: [PATCH 003/221] feat: scope-aware tag resolution & autocomplete (Phase 10.2) Add tagService with resolveTagsForRecipe, getAutocompleteTags, and resolveTagsForFork. Unknown tags in community context become PENDING, global tags are reused directly, and forks resolve tags per target community. Autocomplete filters by scope and user preferences. 12 new integration tests (402 total). --- .claude/context/FILE_MAP.md | 5 + .../integration/communityRecipes.test.ts | 117 +++++++ .../src/__tests__/integration/share.test.ts | 109 +++++++ .../src/__tests__/integration/tags.test.ts | 108 +++++++ .../__tests__/unit/responseFormatters.test.ts | 8 +- backend/src/controllers/recipeShare.ts | 2 +- backend/src/controllers/recipes.ts | 13 +- backend/src/controllers/tags.ts | 52 ++- .../src/services/communityRecipeService.ts | 54 +--- backend/src/services/recipeService.ts | 30 +- backend/src/services/shareService.ts | 26 +- backend/src/services/tagService.ts | 300 ++++++++++++++++++ backend/src/util/responseFormatters.ts | 10 +- docs/features/tags-rework/ROADMAP.md | 16 +- 14 files changed, 724 insertions(+), 126 deletions(-) create mode 100644 backend/src/services/tagService.ts diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index 48bbab81..c7ec3cc7 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -69,6 +69,11 @@ admin/ ### Services ``` services/ +├── tagService.ts # Logique scope-aware tags (resolve, autocomplete, fork) +├── recipeService.ts # upsertTags, upsertIngredients, createRecipe, updateRecipe +├── communityRecipeService.ts # createCommunityRecipe (perso + comm) +├── shareService.ts # forkRecipe, publishRecipe, getRecipeFamilyCommunities +├── membershipService.ts # requireRecipeAccess, requireRecipeOwnership ├── orphanHandling.ts # Gestion recettes orphelines (auto-reject proposals) ├── eventEmitter.ts # AppEventEmitter singleton (emit activity events) └── socketServer.ts # Socket.IO server init, auth middleware, room management diff --git a/backend/src/__tests__/integration/communityRecipes.test.ts b/backend/src/__tests__/integration/communityRecipes.test.ts index 5a924413..bedf27c6 100644 --- a/backend/src/__tests__/integration/communityRecipes.test.ts +++ b/backend/src/__tests__/integration/communityRecipes.test.ts @@ -209,6 +209,123 @@ describe("Community Recipes API", () => { }); }); + // ===================================== + // Tags scope-aware + // ===================================== + describe("Tags scope-aware (POST /api/communities/:communityId/recipes)", () => { + it("should use existing GLOBAL APPROVED tag directly", async () => { + // Creer un tag global via recette perso + await request(app) + .post("/api/recipes") + .set("Cookie", memberCookie) + .send({ title: "Perso", content: "c", tags: ["existing_global"] }); + + // Creer recette communautaire avec le meme tag + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ title: "Comm", content: "c", tags: ["existing_global"] }); + + expect(res.status).toBe(201); + const communityTags = res.body.community.tags; + expect(communityTags).toHaveLength(1); + expect(communityTags[0].name).toBe("existing_global"); + expect(communityTags[0].scope).toBe("GLOBAL"); + expect(communityTags[0].status).toBe("APPROVED"); + }); + + it("should create PENDING community tag for unknown tag", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ title: "Recette", content: "c", tags: ["brand_new_tag"] }); + + expect(res.status).toBe(201); + + // La recette communautaire doit avoir un tag PENDING + const communityTags = res.body.community.tags; + expect(communityTags).toHaveLength(1); + expect(communityTags[0].name).toBe("brand_new_tag"); + expect(communityTags[0].scope).toBe("COMMUNITY"); + expect(communityTags[0].status).toBe("PENDING"); + expect(communityTags[0].communityId).toBe(community.id); + + // La recette perso doit avoir un tag GLOBAL APPROVED (creation libre) + const personalTags = res.body.personal.tags; + expect(personalTags).toHaveLength(1); + expect(personalTags[0].name).toBe("brand_new_tag"); + expect(personalTags[0].scope).toBe("GLOBAL"); + expect(personalTags[0].status).toBe("APPROVED"); + }); + + it("should reuse existing COMMUNITY APPROVED tag", async () => { + // Creer un tag APPROVED dans la communaute + await testPrisma.tag.create({ + data: { + name: "approved_comm_tag", + scope: "COMMUNITY", + status: "APPROVED", + communityId: community.id, + }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ title: "R", content: "c", tags: ["approved_comm_tag"] }); + + expect(res.status).toBe(201); + const communityTags = res.body.community.tags; + expect(communityTags).toHaveLength(1); + expect(communityTags[0].name).toBe("approved_comm_tag"); + expect(communityTags[0].scope).toBe("COMMUNITY"); + expect(communityTags[0].status).toBe("APPROVED"); + }); + + it("should reuse existing PENDING tag in same community (no duplicate)", async () => { + // Creer un tag PENDING directement en DB + const pendingTag = await testPrisma.tag.create({ + data: { + name: "pending_reuse", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + createdById: member.id, + }, + }); + + // Creer une recette avec ce tag → doit reutiliser le PENDING existant + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ title: "R2", content: "c", tags: ["pending_reuse"] }); + + expect(res.status).toBe(201); + const communityTags = res.body.community.tags; + expect(communityTags).toHaveLength(1); + expect(communityTags[0].name).toBe("pending_reuse"); + expect(communityTags[0].status).toBe("PENDING"); + expect(communityTags[0].id).toBe(pendingTag.id); + + // Verifier qu'il n'y a qu'un seul tag COMMUNITY dans la DB + const dbTags = await testPrisma.tag.findMany({ + where: { name: "pending_reuse", communityId: community.id }, + }); + expect(dbTags).toHaveLength(1); + }); + + it("should reject more than 10 tags per recipe (TAG_003)", async () => { + const tags = Array.from({ length: 11 }, (_, i) => `tag_${i}`); + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ title: "Too many tags", content: "c", tags }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_003"); + }); + }); + // ===================================== // GET /api/communities/:communityId/recipes // ===================================== diff --git a/backend/src/__tests__/integration/share.test.ts b/backend/src/__tests__/integration/share.test.ts index a4860ffd..8d780586 100644 --- a/backend/src/__tests__/integration/share.test.ts +++ b/backend/src/__tests__/integration/share.test.ts @@ -325,6 +325,115 @@ describe("Share Recipe API", () => { }); }); + // ===================================== + // Fork tags scope-aware + // ===================================== + describe("Fork tags scope-aware", () => { + it("should copy GLOBAL tags directly during fork", async () => { + // Creer un tag GLOBAL APPROVED et l'attacher a la recette source + const globalTag = await testPrisma.tag.create({ + data: { name: "global_fork_tag", scope: "GLOBAL", status: "APPROVED" }, + }); + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: globalTag.id }, + }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/share`) + .set("Cookie", user1Cookie) + .send({ targetCommunityId: targetCommunity.id }); + + expect(res.status).toBe(201); + const forkGlobalTag = res.body.tags.find((t: { name: string }) => t.name === "global_fork_tag"); + expect(forkGlobalTag).toBeDefined(); + expect(forkGlobalTag.scope).toBe("GLOBAL"); + expect(forkGlobalTag.id).toBe(globalTag.id); + }); + + it("should create PENDING tag in target community for COMMUNITY tag", async () => { + // Creer un tag COMMUNITY APPROVED dans la source + await testPrisma.tag.create({ + data: { + name: "source_community_tag", + scope: "COMMUNITY", + status: "APPROVED", + communityId: sourceCommunity.id, + }, + }); + + // Attacher ce tag a la recette source + const tag = await testPrisma.tag.findFirst({ + where: { name: "source_community_tag", communityId: sourceCommunity.id }, + }); + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: tag!.id }, + }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/share`) + .set("Cookie", user1Cookie) + .send({ targetCommunityId: targetCommunity.id }); + + expect(res.status).toBe(201); + + // Verifier qu'un tag PENDING a ete cree dans la communaute cible + const pendingTag = await testPrisma.tag.findFirst({ + where: { + name: "source_community_tag", + communityId: targetCommunity.id, + scope: "COMMUNITY", + status: "PENDING", + }, + }); + expect(pendingTag).not.toBeNull(); + + // Verifier que le fork a ce tag + const forkTag = res.body.tags.find((t: { name: string }) => t.name === "source_community_tag"); + expect(forkTag).toBeDefined(); + expect(forkTag.communityId).toBe(targetCommunity.id); + }); + + it("should reuse existing APPROVED community tag in target during fork", async () => { + // Creer un tag COMMUNITY APPROVED dans la source et la cible avec le meme nom + await testPrisma.tag.create({ + data: { + name: "shared_name_tag", + scope: "COMMUNITY", + status: "APPROVED", + communityId: sourceCommunity.id, + }, + }); + const targetTag = await testPrisma.tag.create({ + data: { + name: "shared_name_tag", + scope: "COMMUNITY", + status: "APPROVED", + communityId: targetCommunity.id, + }, + }); + + // Attacher le tag source a la recette + const sourceTag = await testPrisma.tag.findFirst({ + where: { name: "shared_name_tag", communityId: sourceCommunity.id }, + }); + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: sourceTag!.id }, + }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/share`) + .set("Cookie", user1Cookie) + .send({ targetCommunityId: targetCommunity.id }); + + expect(res.status).toBe(201); + + // Le fork doit utiliser le tag APPROVED de la cible + const forkTag = res.body.tags.find((t: { name: string }) => t.name === "shared_name_tag"); + expect(forkTag).toBeDefined(); + expect(forkTag.id).toBe(targetTag.id); + }); + }); + // ===================================== // Chain analytics (fork of fork) // ===================================== diff --git a/backend/src/__tests__/integration/tags.test.ts b/backend/src/__tests__/integration/tags.test.ts index 7e702bd8..0b0ae687 100644 --- a/backend/src/__tests__/integration/tags.test.ts +++ b/backend/src/__tests__/integration/tags.test.ts @@ -4,8 +4,10 @@ import app from '../../app'; import { createTestUser, createTestRecipe, + createTestTag, extractSessionCookie, } from '../setup/testHelpers'; +import { testPrisma } from '../setup/globalSetup'; describe('Tags API', () => { let userCookie: string; @@ -101,5 +103,111 @@ describe('Tags API', () => { expect(res.status).toBe(401); }); + + it('should return only GLOBAL APPROVED tags without communityId', async () => { + // Creer un tag GLOBAL APPROVED via recette perso + await createTestRecipe(userId, { tags: ['global_tag'] }); + + // Creer un tag COMMUNITY APPROVED (ne devrait pas apparaitre sans communityId specifique) + const community = await testPrisma.community.create({ + data: { name: `TagTest Community ${Date.now()}` }, + }); + await testPrisma.userCommunity.create({ + data: { userId, communityId: community.id, role: 'MEMBER' }, + }); + await createTestTag('community_only_tag', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + }); + + const res = await request(app) + .get('/api/tags') + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + const globalTag = res.body.data.find((t: { name: string }) => t.name === 'global_tag'); + expect(globalTag).toBeDefined(); + expect(globalTag.scope).toBe('GLOBAL'); + + // Le tag communautaire apparait aussi car l'user est membre et showTags=true par defaut + const communityTag = res.body.data.find((t: { name: string }) => t.name === 'community_only_tag'); + expect(communityTag).toBeDefined(); + expect(communityTag.scope).toBe('COMMUNITY'); + }); + + it('should return GLOBAL + COMMUNITY APPROVED tags with communityId', async () => { + await createTestRecipe(userId, { tags: ['global_for_community'] }); + + const community = await testPrisma.community.create({ + data: { name: `TagCommunity ${Date.now()}` }, + }); + await createTestTag('comm_approved', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + }); + + const res = await request(app) + .get(`/api/tags?communityId=${community.id}`) + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + const names = res.body.data.map((t: { name: string }) => t.name); + expect(names).toContain('global_for_community'); + expect(names).toContain('comm_approved'); + }); + + it('should exclude PENDING tags from autocomplete', async () => { + const community = await testPrisma.community.create({ + data: { name: `TagPending ${Date.now()}` }, + }); + await createTestTag('pending_tag', { + scope: 'COMMUNITY', + status: 'PENDING', + communityId: community.id, + }); + await createTestTag('approved_tag', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + }); + + const res = await request(app) + .get(`/api/tags?communityId=${community.id}`) + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + const names = res.body.data.map((t: { name: string }) => t.name); + expect(names).toContain('approved_tag'); + expect(names).not.toContain('pending_tag'); + }); + + it('should respect showTags=false preference in personal context', async () => { + const community = await testPrisma.community.create({ + data: { name: `TagHidden ${Date.now()}` }, + }); + await testPrisma.userCommunity.create({ + data: { userId, communityId: community.id, role: 'MEMBER' }, + }); + await createTestTag('hidden_comm_tag', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + }); + + // Desactiver showTags pour cette communaute + await testPrisma.userCommunityTagPreference.create({ + data: { userId, communityId: community.id, showTags: false }, + }); + + const res = await request(app) + .get('/api/tags') + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + const names = res.body.data.map((t: { name: string }) => t.name); + expect(names).not.toContain('hidden_comm_tag'); + }); }); }); diff --git a/backend/src/__tests__/unit/responseFormatters.test.ts b/backend/src/__tests__/unit/responseFormatters.test.ts index 843e0b16..2e3c66f3 100644 --- a/backend/src/__tests__/unit/responseFormatters.test.ts +++ b/backend/src/__tests__/unit/responseFormatters.test.ts @@ -4,12 +4,12 @@ import { formatTags, formatIngredients } from "../../util/responseFormatters"; describe("formatTags", () => { it("should extract tags from pivot format", () => { const raw = [ - { tag: { id: "t1", name: "dessert" } }, - { tag: { id: "t2", name: "vegan" } }, + { tag: { id: "t1", name: "dessert", scope: "GLOBAL", status: "APPROVED", communityId: null } }, + { tag: { id: "t2", name: "vegan", scope: "COMMUNITY", status: "PENDING", communityId: "c1" } }, ]; expect(formatTags(raw)).toEqual([ - { id: "t1", name: "dessert" }, - { id: "t2", name: "vegan" }, + { id: "t1", name: "dessert", scope: "GLOBAL", status: "APPROVED", communityId: null }, + { id: "t2", name: "vegan", scope: "COMMUNITY", status: "PENDING", communityId: "c1" }, ]); }); diff --git a/backend/src/controllers/recipeShare.ts b/backend/src/controllers/recipeShare.ts index 1b7dc7fc..e66838a1 100644 --- a/backend/src/controllers/recipeShare.ts +++ b/backend/src/controllers/recipeShare.ts @@ -45,7 +45,7 @@ export const shareRecipe: RequestHandler< imageUrl: true, communityId: true, creatorId: true, - tags: { select: { tagId: true } }, + tags: { select: { tagId: true, tag: { select: { id: true, name: true, scope: true, communityId: true } } } }, ingredients: { select: { ingredientId: true, quantity: true, order: true }, orderBy: { order: "asc" }, diff --git a/backend/src/controllers/recipes.ts b/backend/src/controllers/recipes.ts index 30a850c8..f9012f51 100644 --- a/backend/src/controllers/recipes.ts +++ b/backend/src/controllers/recipes.ts @@ -151,16 +151,7 @@ export const getRecipe: RequestHandler = async (req, res, next) => { name: true, }, }, - tags: { - select: { - tag: { - select: { - id: true, - name: true, - }, - }, - }, - }, + tags: RECIPE_TAGS_SELECT, ingredients: { select: { id: true, @@ -313,7 +304,7 @@ export const updateRecipe: RequestHandler = async (req, res, next) => { const authenticatedUserId = req.session.userId; const search = req.query.search?.trim().toLowerCase() || ""; + const communityId = req.query.communityId?.trim() || null; const { limit } = parsePagination(req.query); try { assertIsDefine(authenticatedUserId); - const tags = await prisma.tag.findMany({ - where: search - ? { - name: { - contains: search, - mode: "insensitive", - }, - } - : undefined, - select: { - id: true, - name: true, - _count: { - select: { - recipes: { - where: { - recipe: { - deletedAt: null, - creatorId: authenticatedUserId, - communityId: null, - }, - }, - }, - }, - }, - }, - orderBy: { - name: "asc", + const tags = await getAutocompleteTags(authenticatedUserId, communityId, search, limit); + + // Enrichir avec recipeCount (recettes perso de l'user ou recettes de la communaute) + const tagIds = tags.map((t) => t.id); + const recipeFilter = communityId + ? { deletedAt: null, communityId } + : { deletedAt: null, creatorId: authenticatedUserId, communityId: null }; + + const counts = await prisma.recipeTag.groupBy({ + by: ["tagId"], + where: { + tagId: { in: tagIds }, + recipe: recipeFilter, }, - take: limit, + _count: { tagId: true }, }); + const countMap = new Map(counts.map((c) => [c.tagId, c._count.tagId])); + const data = tags.map((tag) => ({ id: tag.id, name: tag.name, - recipeCount: tag._count.recipes, + scope: tag.scope, + communityId: tag.communityId, + recipeCount: countMap.get(tag.id) || 0, })); res.status(200).json({ data }); diff --git a/backend/src/services/communityRecipeService.ts b/backend/src/services/communityRecipeService.ts index bdd7c837..be2d9f08 100644 --- a/backend/src/services/communityRecipeService.ts +++ b/backend/src/services/communityRecipeService.ts @@ -1,7 +1,6 @@ import prisma from "../util/db"; -import { normalizeNames } from "../util/validation"; import { RECIPE_TAGS_SELECT, RECIPE_INGREDIENTS_SELECT } from "../util/prismaSelects"; -import { IngredientInput } from "./recipeService"; +import { IngredientInput, upsertTags, upsertIngredients } from "./recipeService"; interface CreateCommunityRecipeData { title: string; @@ -59,55 +58,16 @@ export async function createCommunityRecipe( }); // 3. Gerer tags/ingredients sur les DEUX recettes + // IMPORTANT: traiter la recette communautaire EN PREMIER pour que les tags + // inconnus deviennent COMMUNITY PENDING (et non GLOBAL APPROVED via le perso) if (data.tags.length > 0) { - const normalizedTags = normalizeNames(data.tags); - - for (const tagName of normalizedTags) { - let tag = await tx.tag.findFirst({ - where: { name: tagName, communityId: null }, - }); - if (!tag) { - tag = await tx.tag.create({ data: { name: tagName } }); - } - - await tx.recipeTag.createMany({ - data: [ - { recipeId: personalRecipe.id, tagId: tag.id }, - { recipeId: communityRecipe.id, tagId: tag.id }, - ], - }); - } + await upsertTags(tx, communityRecipe.id, data.tags, userId, communityId); + await upsertTags(tx, personalRecipe.id, data.tags, userId, null); } if (data.ingredients.length > 0) { - for (let i = 0; i < data.ingredients.length; i++) { - const ing = data.ingredients[i]; - const ingredientName = ing.name.trim().toLowerCase(); - if (!ingredientName) continue; - - const ingredient = await tx.ingredient.upsert({ - where: { name: ingredientName }, - create: { name: ingredientName }, - update: {}, - }); - - await tx.recipeIngredient.createMany({ - data: [ - { - recipeId: personalRecipe.id, - ingredientId: ingredient.id, - quantity: ing.quantity?.trim() || null, - order: i, - }, - { - recipeId: communityRecipe.id, - ingredientId: ingredient.id, - quantity: ing.quantity?.trim() || null, - order: i, - }, - ], - }); - } + await upsertIngredients(tx, personalRecipe.id, data.ingredients); + await upsertIngredients(tx, communityRecipe.id, data.ingredients); } // 4. Creer ActivityLog diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index 9e34ed60..86b25a27 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -1,7 +1,7 @@ import prisma from "../util/db"; import { PrismaClient } from "@prisma/client"; -import { normalizeNames } from "../util/validation"; import { RECIPE_TAGS_SELECT, RECIPE_INGREDIENTS_SELECT } from "../util/prismaSelects"; +import { resolveTagsForRecipe } from "./tagService"; type TransactionClient = Omit< PrismaClient, @@ -15,19 +15,18 @@ export interface IngredientInput { // --- Helpers partages pour tags/ingredients --- -export async function upsertTags(tx: TransactionClient, recipeId: string, tags: string[]) { - const normalizedTags = normalizeNames(tags); - - for (const tagName of normalizedTags) { - let tag = await tx.tag.findFirst({ - where: { name: tagName, communityId: null }, - }); - if (!tag) { - tag = await tx.tag.create({ data: { name: tagName } }); - } +export async function upsertTags( + tx: TransactionClient, + recipeId: string, + tags: string[], + userId: string, + communityId: string | null +) { + const { tagIds } = await resolveTagsForRecipe(tx, tags, userId, communityId); + for (const tagId of tagIds) { await tx.recipeTag.create({ - data: { recipeId, tagId: tag.id }, + data: { recipeId, tagId }, }); } } @@ -95,7 +94,7 @@ export async function createRecipe(userId: string, data: CreateRecipeData) { }); if (data.tags.length > 0) { - await upsertTags(tx, recipe.id, data.tags); + await upsertTags(tx, recipe.id, data.tags, userId, null); } if (data.ingredients.length > 0) { @@ -127,7 +126,8 @@ interface RecipeForSync { export async function updateRecipe( recipeId: string, data: UpdateRecipeData, - recipe: RecipeForSync + recipe: RecipeForSync, + userId: string ) { return prisma.$transaction(async (tx) => { // Mettre a jour les champs de base @@ -143,7 +143,7 @@ export async function updateRecipe( // Remplacer tags si fournis if (data.tags !== undefined) { await tx.recipeTag.deleteMany({ where: { recipeId } }); - await upsertTags(tx, recipeId, data.tags); + await upsertTags(tx, recipeId, data.tags, userId, recipe.communityId); } // Remplacer ingredients si fournis diff --git a/backend/src/services/shareService.ts b/backend/src/services/shareService.ts index 1682ee61..0d7d9577 100644 --- a/backend/src/services/shareService.ts +++ b/backend/src/services/shareService.ts @@ -1,5 +1,6 @@ import prisma from "../util/db"; import { RECIPE_TAGS_SELECT, RECIPE_INGREDIENTS_SELECT } from "../util/prismaSelects"; +import { resolveTagsForFork } from "./tagService"; interface SourceRecipeForShare { id: string; @@ -7,7 +8,7 @@ interface SourceRecipeForShare { content: string; imageUrl: string | null; communityId: string; - tags: { tagId: string }[]; + tags: { tagId: string; tag: { id: string; name: string; scope: string; communityId: string | null } }[]; ingredients: { ingredientId: string; quantity: string | null; order: number }[]; } @@ -36,14 +37,23 @@ export async function forkRecipe( }, }); - // Copier les tags + // Copier les tags (scope-aware) if (sourceRecipe.tags.length > 0) { - await tx.recipeTag.createMany({ - data: sourceRecipe.tags.map((rt) => ({ - recipeId: forkedRecipe.id, - tagId: rt.tagId, - })), - }); + const sourceTags = sourceRecipe.tags.map((rt) => ({ + id: rt.tag.id, + name: rt.tag.name, + scope: rt.tag.scope, + communityId: rt.tag.communityId, + })); + const tagIds = await resolveTagsForFork(tx, sourceTags, targetCommunityId, userId); + if (tagIds.length > 0) { + await tx.recipeTag.createMany({ + data: tagIds.map((tagId) => ({ + recipeId: forkedRecipe.id, + tagId, + })), + }); + } } // Copier les ingredients diff --git a/backend/src/services/tagService.ts b/backend/src/services/tagService.ts new file mode 100644 index 00000000..938479af --- /dev/null +++ b/backend/src/services/tagService.ts @@ -0,0 +1,300 @@ +import { PrismaClient } from "@prisma/client"; +import createHttpError from "http-errors"; +import prisma from "../util/db"; +import { normalizeNames } from "../util/validation"; + +type TransactionClient = Omit< + PrismaClient, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends" +>; + +const MAX_TAGS_PER_RECIPE = 10; +const MAX_COMMUNITY_TAGS = 100; + +interface ResolveTagsResult { + tagIds: string[]; + pendingTagIds: string[]; +} + +/** + * Resout les tags pour une recette selon le scope : + * - GLOBAL APPROVED existant → reutilise + * - COMMUNITY (APPROVED ou PENDING) existant dans la communaute → reutilise + * - Rien trouve + communityId → cree tag COMMUNITY PENDING + * - Rien trouve + pas de communityId (perso) → cree tag GLOBAL APPROVED + */ +export async function resolveTagsForRecipe( + tx: TransactionClient, + tagNames: string[], + userId: string, + communityId: string | null +): Promise { + const normalized = normalizeNames(tagNames); + + if (normalized.length > MAX_TAGS_PER_RECIPE) { + throw createHttpError(400, "TAG_003: Maximum 10 tags per recipe"); + } + + const tagIds: string[] = []; + const pendingTagIds: string[] = []; + + for (const tagName of normalized) { + // 1. Chercher un tag GLOBAL APPROVED + let tag = await tx.tag.findFirst({ + where: { name: tagName, scope: "GLOBAL", status: "APPROVED", communityId: null }, + }); + + if (tag) { + tagIds.push(tag.id); + continue; + } + + // 2. Si communityId : chercher tag COMMUNITY (APPROVED ou PENDING) dans cette communaute + if (communityId) { + tag = await tx.tag.findFirst({ + where: { name: tagName, scope: "COMMUNITY", communityId }, + }); + + if (tag) { + tagIds.push(tag.id); + if (tag.status === "PENDING") { + pendingTagIds.push(tag.id); + } + continue; + } + + // 3. Rien trouve → verifier limite et creer tag COMMUNITY PENDING + const communityTagCount = await tx.tag.count({ + where: { communityId, scope: "COMMUNITY" }, + }); + + if (communityTagCount >= MAX_COMMUNITY_TAGS) { + throw createHttpError(400, "TAG_003: Community tag limit reached (100)"); + } + + const newTag = await tx.tag.create({ + data: { + name: tagName, + scope: "COMMUNITY", + status: "PENDING", + communityId, + createdById: userId, + }, + }); + + tagIds.push(newTag.id); + pendingTagIds.push(newTag.id); + continue; + } + + // 4. Pas de communityId (recette perso) → creer tag GLOBAL APPROVED + const newTag = await tx.tag.create({ + data: { name: tagName }, + }); + + tagIds.push(newTag.id); + } + + return { tagIds, pendingTagIds }; +} + +interface AutocompleteTag { + id: string; + name: string; + scope: string; + communityId: string | null; +} + +/** + * Retourne les tags pour l'autocomplete selon le contexte : + * - Avec communityId : GLOBAL APPROVED + COMMUNITY APPROVED de cette communaute + * - Sans communityId (perso) : GLOBAL APPROVED + COMMUNITY APPROVED des communautes de l'user + * (filtre par UserCommunityTagPreference.showTags) + */ +export async function getAutocompleteTags( + userId: string, + communityId: string | null, + search: string, + limit: number +): Promise { + const searchFilter = search + ? { name: { contains: search, mode: "insensitive" as const } } + : {}; + + if (communityId) { + // Tags GLOBAL APPROVED + COMMUNITY APPROVED de cette communaute + return tx_getTagsForCommunity(communityId, searchFilter, limit); + } + + // Recettes perso : GLOBAL APPROVED + COMMUNITY APPROVED des communautes de l'user + return tx_getTagsForPersonal(userId, searchFilter, limit); +} + +async function tx_getTagsForCommunity( + communityId: string, + searchFilter: object, + limit: number +): Promise { + const tags = await prisma.tag.findMany({ + where: { + ...searchFilter, + status: "APPROVED", + OR: [ + { scope: "GLOBAL", communityId: null }, + { scope: "COMMUNITY", communityId }, + ], + }, + select: { id: true, name: true, scope: true, communityId: true }, + orderBy: { name: "asc" }, + take: limit, + }); + + return tags.map((t) => ({ + id: t.id, + name: t.name, + scope: t.scope, + communityId: t.communityId, + })); +} + +async function tx_getTagsForPersonal( + userId: string, + searchFilter: object, + limit: number +): Promise { + // Trouver les communautes de l'user ou showTags != false + const memberships = await prisma.userCommunity.findMany({ + where: { userId, deletedAt: null }, + select: { communityId: true }, + }); + + const communityIds = memberships.map((m) => m.communityId); + + // Filtrer par preferences (exclure showTags=false) + if (communityIds.length > 0) { + const hiddenPrefs = await prisma.userCommunityTagPreference.findMany({ + where: { userId, communityId: { in: communityIds }, showTags: false }, + select: { communityId: true }, + }); + const hiddenIds = new Set(hiddenPrefs.map((p) => p.communityId)); + const visibleCommunityIds = communityIds.filter((id) => !hiddenIds.has(id)); + + const tags = await prisma.tag.findMany({ + where: { + ...searchFilter, + status: "APPROVED", + OR: [ + { scope: "GLOBAL", communityId: null }, + ...(visibleCommunityIds.length > 0 + ? [{ scope: "COMMUNITY" as const, communityId: { in: visibleCommunityIds } }] + : []), + ], + }, + select: { id: true, name: true, scope: true, communityId: true }, + orderBy: { name: "asc" }, + take: limit, + }); + + return tags.map((t) => ({ + id: t.id, + name: t.name, + scope: t.scope, + communityId: t.communityId, + })); + } + + // Pas de communautes → uniquement tags GLOBAL APPROVED + const tags = await prisma.tag.findMany({ + where: { + ...searchFilter, + scope: "GLOBAL", + status: "APPROVED", + communityId: null, + }, + select: { id: true, name: true, scope: true, communityId: true }, + orderBy: { name: "asc" }, + take: limit, + }); + + return tags.map((t) => ({ + id: t.id, + name: t.name, + scope: t.scope, + communityId: t.communityId, + })); +} + +interface SourceTag { + id: string; + name: string; + scope: string; + communityId: string | null; +} + +/** + * Resout les tags lors d'un fork de recette : + * - Tag GLOBAL → copie directe (meme tagId) + * - Tag COMMUNITY : chercher tag APPROVED avec meme nom dans la cible → copie avec tagId cible + * - Tag COMMUNITY inexistant dans la cible → creer tag PENDING dans la cible + */ +export async function resolveTagsForFork( + tx: TransactionClient, + sourceTags: SourceTag[], + targetCommunityId: string, + userId: string +): Promise { + const tagIds: string[] = []; + + for (const sourceTag of sourceTags) { + if (sourceTag.scope === "GLOBAL") { + // Tag GLOBAL → copie directe + tagIds.push(sourceTag.id); + continue; + } + + // Tag COMMUNITY → chercher equivalent dans la communaute cible + let targetTag = await tx.tag.findFirst({ + where: { + name: sourceTag.name, + scope: "COMMUNITY", + status: "APPROVED", + communityId: targetCommunityId, + }, + }); + + if (targetTag) { + tagIds.push(targetTag.id); + continue; + } + + // Chercher aussi un tag PENDING existant pour eviter les doublons + targetTag = await tx.tag.findFirst({ + where: { + name: sourceTag.name, + scope: "COMMUNITY", + status: "PENDING", + communityId: targetCommunityId, + }, + }); + + if (targetTag) { + tagIds.push(targetTag.id); + continue; + } + + // Creer tag PENDING dans la communaute cible + const newTag = await tx.tag.create({ + data: { + name: sourceTag.name, + scope: "COMMUNITY", + status: "PENDING", + communityId: targetCommunityId, + createdById: userId, + }, + }); + + tagIds.push(newTag.id); + } + + return tagIds; +} diff --git a/backend/src/util/responseFormatters.ts b/backend/src/util/responseFormatters.ts index c2bcf4a1..267dfe49 100644 --- a/backend/src/util/responseFormatters.ts +++ b/backend/src/util/responseFormatters.ts @@ -3,7 +3,7 @@ * Centralise les mappings repetes dans les controllers. */ -type RawTag = { tag: { id: string; name: string } }; +type RawTag = { tag: { id: string; name: string; scope: string; status: string; communityId: string | null } }; type RawIngredient = { id: string; quantity: string | null; @@ -13,7 +13,13 @@ type RawIngredient = { /** Extrait les tags depuis le format Prisma pivot */ export function formatTags(tags: RawTag[]) { - return tags.map((rt) => rt.tag); + return tags.map((rt) => ({ + id: rt.tag.id, + name: rt.tag.name, + scope: rt.tag.scope, + status: rt.tag.status, + communityId: rt.tag.communityId, + })); } /** Formate les ingredients depuis le format Prisma pivot */ diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index dea15e57..9e5d649b 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -16,14 +16,14 @@ ## 10.2 - Backend Tags (CRUD + validation) -- [ ] Refactoring recipeService : upsertTags → logique scope-aware -- [ ] Endpoint autocomplete tags : scope-aware (global + communaute) -- [ ] Creation tag pending (membre sur recette communautaire) -- [ ] Reutilisation tag pending existant (meme communaute) -- [ ] Regles d'unicite (global vs communaute vs pending) -- [ ] Gestion tags au fork inter-communaute (tags inconnus → pending) -- [ ] Tags sur recette perso : global + communautes selon preferences -- [ ] Tests unitaires + integration +- [x] Refactoring recipeService : upsertTags → logique scope-aware +- [x] Endpoint autocomplete tags : scope-aware (global + communaute) +- [x] Creation tag pending (membre sur recette communautaire) +- [x] Reutilisation tag pending existant (meme communaute) +- [x] Regles d'unicite (global vs communaute vs pending) +- [x] Gestion tags au fork inter-communaute (tags inconnus → pending) +- [x] Tags sur recette perso : global + communautes selon preferences +- [x] Tests unitaires + integration ## 10.3 - Backend Administration tags From bd56b27b01dc461d379a8ccaaf21347ddfd54507 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Fri, 13 Feb 2026 16:55:12 +0100 Subject: [PATCH 004/221] feat: moderator tag management & admin scope filter (Phase 10.3) Add community tag endpoints (GET/POST/PATCH/DELETE + approve/reject) for moderators. Pending tags are hard-deleted on reject with cascade to RecipeTag. Admin tags listing now supports scope filter (GLOBAL/ COMMUNITY) and returns scope/status/community info. Migration adds TAG_CREATED/UPDATED/DELETED/APPROVED/REJECTED to ActivityType enum. 29 new tests (431 total). --- .claude/context/API_MAP.md | 23 +- .claude/context/FILE_MAP.md | 4 +- .../migration.sql | 6 + backend/prisma/schema.prisma | 7 + .../__tests__/integration/adminTags.test.ts | 61 +++ .../integration/communityTags.test.ts | 494 ++++++++++++++++++ .../src/admin/controllers/tagsController.ts | 28 +- backend/src/controllers/communityTags.ts | 348 ++++++++++++ backend/src/routes/communities.ts | 53 ++ docs/features/tags-rework/ROADMAP.md | 12 +- 10 files changed, 1018 insertions(+), 18 deletions(-) create mode 100644 backend/prisma/migrations/20260213160000_add_tag_activity_types/migration.sql create mode 100644 backend/src/__tests__/integration/communityTags.test.ts create mode 100644 backend/src/controllers/communityTags.ts diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 26ad710c..9290cf11 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -32,7 +32,7 @@ Controller: `controllers/recipes.ts` | Route: `routes/recipes.ts` ## Tags (/api/tags) - requireAuth ``` -GET /api/tags/ # autocomplete (search, recipeCount) +GET /api/tags/ # autocomplete scope-aware (search, communityId?, recipeCount) ``` Controller: `controllers/tags.ts` | Route: `routes/tags.ts` @@ -74,6 +74,17 @@ DELETE /api/communities/:communityId/invites/:inviteId # cancel (MODERATOR) ``` Controller: `controllers/invites.ts` +### Tags (nested under /api/communities/:communityId) - MODERATOR +``` +GET /api/communities/:communityId/tags # list (APPROVED + PENDING, ?status=, ?search=) +POST /api/communities/:communityId/tags # create APPROVED community tag +PATCH /api/communities/:communityId/tags/:tagId # rename +DELETE /api/communities/:communityId/tags/:tagId # delete (hard, cascade RecipeTag) +POST /api/communities/:communityId/tags/:tagId/approve # approve PENDING → APPROVED +POST /api/communities/:communityId/tags/:tagId/reject # reject PENDING → hard delete +``` +Controller: `controllers/communityTags.ts` + ### Activity (nested under /api/communities/:communityId) ``` GET /api/communities/:communityId/activity # feed (memberOf, paginated) @@ -124,10 +135,10 @@ Controller: `admin/controllers/authController.ts` | Route: `admin/routes/authRou ## Admin Tags (/api/admin/tags) - requireSuperAdmin ``` -GET /api/admin/tags/ # list all -POST /api/admin/tags/ # create -PATCH /api/admin/tags/:id # update -DELETE /api/admin/tags/:id # delete +GET /api/admin/tags/ # list all (?scope=GLOBAL|COMMUNITY, ?search=) +POST /api/admin/tags/ # create (GLOBAL only) +PATCH /api/admin/tags/:id # update (any tag) +DELETE /api/admin/tags/:id # delete (any tag) POST /api/admin/tags/:id/merge # merge into another ``` Controller: `admin/controllers/tagsController.ts` | Route: `admin/routes/tagsRoutes.ts` @@ -183,4 +194,4 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti | adminRateLimiter | middleware/security.ts | 30 req/min global admin | | authRateLimiter | routes config | 5/15min sur auth endpoints | -## Total: 65 endpoints (38 user + 27 admin + 1 health) +## Total: 71 endpoints (44 user + 27 admin + 1 health) diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index c7ec3cc7..e4214b6f 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -9,13 +9,14 @@ controllers/ ├── auth.ts # signup, login, logout, me ├── communities.ts # CRUD communautes ├── communityRecipes.ts # create, list recettes communautaires +├── communityTags.ts # CRUD + approve/reject tags communaute (moderateur) ├── members.ts # list, promote, kick/leave membres ├── invites.ts # create, list, cancel, accept, reject invitations ├── proposals.ts # create, list, detail, accept, reject propositions ├── recipes.ts # CRUD recettes personnelles (get, create, update, delete) ├── recipeVariants.ts # getVariants (liste variantes d'une recette) ├── recipeShare.ts # shareRecipe, publishToCommunities, getRecipeCommunities -├── tags.ts # autocomplete tags +├── tags.ts # autocomplete tags (scope-aware) ├── ingredients.ts # autocomplete ingredients └── users.ts # search users, update profile ``` @@ -121,6 +122,7 @@ __tests__/ ├── ingredients.test.ts ├── communities.test.ts ├── communityRecipes.test.ts + ├── communityTags.test.ts ├── invitations.test.ts ├── members.test.ts ├── adminAuth.test.ts diff --git a/backend/prisma/migrations/20260213160000_add_tag_activity_types/migration.sql b/backend/prisma/migrations/20260213160000_add_tag_activity_types/migration.sql new file mode 100644 index 00000000..a8f440af --- /dev/null +++ b/backend/prisma/migrations/20260213160000_add_tag_activity_types/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "ActivityType" ADD VALUE 'TAG_CREATED'; +ALTER TYPE "ActivityType" ADD VALUE 'TAG_UPDATED'; +ALTER TYPE "ActivityType" ADD VALUE 'TAG_DELETED'; +ALTER TYPE "ActivityType" ADD VALUE 'TAG_APPROVED'; +ALTER TYPE "ActivityType" ADD VALUE 'TAG_REJECTED'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 438ed567..126d978a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -543,6 +543,13 @@ enum ActivityType { INVITE_ACCEPTED INVITE_REJECTED INVITE_CANCELLED + + // Tags communaute + TAG_CREATED + TAG_UPDATED + TAG_DELETED + TAG_APPROVED + TAG_REJECTED } // ============================================================================= diff --git a/backend/src/__tests__/integration/adminTags.test.ts b/backend/src/__tests__/integration/adminTags.test.ts index d2967019..cc6fdfb5 100644 --- a/backend/src/__tests__/integration/adminTags.test.ts +++ b/backend/src/__tests__/integration/adminTags.test.ts @@ -66,6 +66,67 @@ describe('Admin Tags API', () => { expect(res.status).toBe(401); }); + + it('should filter by scope=GLOBAL', async () => { + await createTestTag('global_filter_tag'); + const user = await createTestUser(); + const community = await testPrisma.community.create({ + data: { name: `Admin Filter ${Date.now()}` }, + }); + await createTestTag('comm_filter_tag', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + createdById: user.id, + }); + + const res = await request(app) + .get('/api/admin/tags?scope=GLOBAL') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + const names = res.body.tags.map((t: { name: string }) => t.name); + expect(names).toContain('global_filter_tag'); + expect(names).not.toContain('comm_filter_tag'); + }); + + it('should filter by scope=COMMUNITY', async () => { + await createTestTag('global_excluded'); + const user = await createTestUser(); + const community = await testPrisma.community.create({ + data: { name: `Admin Filter2 ${Date.now()}` }, + }); + await createTestTag('comm_included', { + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: community.id, + createdById: user.id, + }); + + const res = await request(app) + .get('/api/admin/tags?scope=COMMUNITY') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + const names = res.body.tags.map((t: { name: string }) => t.name); + expect(names).not.toContain('global_excluded'); + expect(names).toContain('comm_included'); + }); + + it('should include scope and community info in response', async () => { + await createTestTag('scope_info_tag'); + + const res = await request(app) + .get('/api/admin/tags') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + const tag = res.body.tags.find((t: { name: string }) => t.name === 'scope_info_tag'); + expect(tag).toBeDefined(); + expect(tag.scope).toBe('GLOBAL'); + expect(tag.status).toBe('APPROVED'); + expect(tag.communityId).toBeNull(); + }); }); // ===================================== diff --git a/backend/src/__tests__/integration/communityTags.test.ts b/backend/src/__tests__/integration/communityTags.test.ts new file mode 100644 index 00000000..15d1b989 --- /dev/null +++ b/backend/src/__tests__/integration/communityTags.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +const uniqueSuffix = () => + `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + +describe("Community Tags API", () => { + let _moderator: { id: string }; + let moderatorCookie: string; + let member: { id: string }; + let memberCookie: string; + let community: { id: string; name: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Create moderator + const modSignup = await request(app).post("/api/auth/signup").send({ + username: `ctmod_${suffix}`, + email: `ctmod_${suffix}@example.com`, + password: "Test123!Password", + }); + moderatorCookie = extractSessionCookie(modSignup)!; + _moderator = (await testPrisma.user.findFirst({ + where: { email: `ctmod_${suffix}@example.com` }, + }))!; + + // Create community (moderator becomes MODERATOR) + const createRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `Tags Community ${suffix}` }); + community = createRes.body; + + // Create member + const memSignup = await request(app).post("/api/auth/signup").send({ + username: `ctmem_${suffix}`, + email: `ctmem_${suffix}@example.com`, + password: "Test123!Password", + }); + memberCookie = extractSessionCookie(memSignup)!; + member = (await testPrisma.user.findFirst({ + where: { email: `ctmem_${suffix}@example.com` }, + }))!; + + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId: community.id, role: "MEMBER" }, + }); + }); + + // ===================================== + // GET /api/communities/:communityId/tags + // ===================================== + describe("GET /api/communities/:communityId/tags", () => { + it("should list community tags for moderator", async () => { + // Creer des tags communaute + await testPrisma.tag.create({ + data: { name: "approved_tag", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + await testPrisma.tag.create({ + data: { name: "pending_tag", scope: "COMMUNITY", status: "PENDING", communityId: community.id, createdById: member.id }, + }); + + const res = await request(app) + .get(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.total).toBe(2); + }); + + it("should filter by status", async () => { + await testPrisma.tag.create({ + data: { name: "approved_only", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + await testPrisma.tag.create({ + data: { name: "pending_only", scope: "COMMUNITY", status: "PENDING", communityId: community.id }, + }); + + const res = await request(app) + .get(`/api/communities/${community.id}/tags?status=PENDING`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe("pending_only"); + }); + + it("should filter by search", async () => { + await testPrisma.tag.create({ + data: { name: "chocolate", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + await testPrisma.tag.create({ + data: { name: "vanilla", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .get(`/api/communities/${community.id}/tags?search=choc`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe("chocolate"); + }); + + it("should return 403 for non-moderator member", async () => { + const res = await request(app) + .get(`/api/communities/${community.id}/tags`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); + + // ===================================== + // POST /api/communities/:communityId/tags + // ===================================== + describe("POST /api/communities/:communityId/tags", () => { + it("should create an APPROVED community tag", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "New Tag" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("new tag"); + expect(res.body.scope).toBe("COMMUNITY"); + expect(res.body.status).toBe("APPROVED"); + expect(res.body.communityId).toBe(community.id); + }); + + it("should reject duplicate name in same community", async () => { + await testPrisma.tag.create({ + data: { name: "existing", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "Existing" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("TAG_002"); + }); + + it("should reject name conflicting with global tag", async () => { + await testPrisma.tag.create({ + data: { name: "global_conflict", scope: "GLOBAL", status: "APPROVED" }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "global_conflict" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("TAG_002"); + }); + + it("should reject empty name", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_001"); + }); + + it("should reject name shorter than 2 chars", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "a" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_001"); + }); + + it("should return 403 for non-moderator", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", memberCookie) + .send({ name: "nope" }); + + expect(res.status).toBe(403); + }); + + it("should create activity log entry", async () => { + await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", moderatorCookie) + .send({ name: "logged tag" }); + + const log = await testPrisma.activityLog.findFirst({ + where: { type: "TAG_CREATED", communityId: community.id }, + }); + expect(log).not.toBeNull(); + }); + }); + + // ===================================== + // PATCH /api/communities/:communityId/tags/:tagId + // ===================================== + describe("PATCH /api/communities/:communityId/tags/:tagId", () => { + it("should rename a community tag", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "oldname", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/${tag.id}`) + .set("Cookie", moderatorCookie) + .send({ name: "newname" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("newname"); + }); + + it("should reject renaming to existing name", async () => { + const tag1 = await testPrisma.tag.create({ + data: { name: "first_tag", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + await testPrisma.tag.create({ + data: { name: "second_tag", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/${tag1.id}`) + .set("Cookie", moderatorCookie) + .send({ name: "second_tag" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("TAG_002"); + }); + + it("should reject modifying a tag from another community", async () => { + // Creer une autre communaute + const otherRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `Other ${uniqueSuffix()}` }); + const otherCommunity = otherRes.body; + + const otherTag = await testPrisma.tag.create({ + data: { name: "other_comm_tag", scope: "COMMUNITY", status: "APPROVED", communityId: otherCommunity.id }, + }); + + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/${otherTag.id}`) + .set("Cookie", moderatorCookie) + .send({ name: "hijack" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("TAG_005"); + }); + + it("should return 404 for non-existent tag", async () => { + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/00000000-0000-0000-0000-000000000000`) + .set("Cookie", moderatorCookie) + .send({ name: "nope" }); + + expect(res.status).toBe(404); + }); + }); + + // ===================================== + // DELETE /api/communities/:communityId/tags/:tagId + // ===================================== + describe("DELETE /api/communities/:communityId/tags/:tagId", () => { + it("should delete a community tag (hard delete + cascade RecipeTag)", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "to_delete", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + // Attacher a une recette + const recipeRes = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", moderatorCookie) + .send({ title: "R", content: "c" }); + const recipeId = recipeRes.body.community.id; + + await testPrisma.recipeTag.create({ + data: { recipeId, tagId: tag.id }, + }); + + const res = await request(app) + .delete(`/api/communities/${community.id}/tags/${tag.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + + // Tag supprime + const deleted = await testPrisma.tag.findUnique({ where: { id: tag.id } }); + expect(deleted).toBeNull(); + + // RecipeTag cascade + const rt = await testPrisma.recipeTag.findFirst({ where: { tagId: tag.id } }); + expect(rt).toBeNull(); + }); + + it("should return 403 when deleting tag from another community", async () => { + const otherRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `Other ${uniqueSuffix()}` }); + const otherTag = await testPrisma.tag.create({ + data: { name: "other_del", scope: "COMMUNITY", status: "APPROVED", communityId: otherRes.body.id }, + }); + + const res = await request(app) + .delete(`/api/communities/${community.id}/tags/${otherTag.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(403); + }); + + it("should return 403 for non-moderator", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "nodelete", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .delete(`/api/communities/${community.id}/tags/${tag.id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); + + // ===================================== + // POST /api/communities/:communityId/tags/:tagId/approve + // ===================================== + describe("POST /:communityId/tags/:tagId/approve", () => { + it("should approve a PENDING tag", async () => { + const tag = await testPrisma.tag.create({ + data: { + name: "pending_approve", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + createdById: member.id, + }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/approve`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("APPROVED"); + expect(res.body.name).toBe("pending_approve"); + + // Verifier en DB + const updated = await testPrisma.tag.findUnique({ where: { id: tag.id } }); + expect(updated?.status).toBe("APPROVED"); + }); + + it("should create activity log on approve", async () => { + const tag = await testPrisma.tag.create({ + data: { + name: "log_approve", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + }, + }); + + await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/approve`) + .set("Cookie", moderatorCookie); + + const log = await testPrisma.activityLog.findFirst({ + where: { type: "TAG_APPROVED", communityId: community.id }, + }); + expect(log).not.toBeNull(); + }); + + it("should reject approving an already APPROVED tag", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "already_approved", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/approve`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_004"); + }); + + it("should return 403 for non-moderator", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "noapprove", scope: "COMMUNITY", status: "PENDING", communityId: community.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/approve`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); + + // ===================================== + // POST /api/communities/:communityId/tags/:tagId/reject + // ===================================== + describe("POST /:communityId/tags/:tagId/reject", () => { + it("should reject a PENDING tag (hard delete + cascade)", async () => { + const tag = await testPrisma.tag.create({ + data: { + name: "pending_reject", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + createdById: member.id, + }, + }); + + // Attacher a une recette + const recipeRes = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", moderatorCookie) + .send({ title: "R", content: "c" }); + await testPrisma.recipeTag.create({ + data: { recipeId: recipeRes.body.community.id, tagId: tag.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/reject`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + + // Tag hard deleted + const deleted = await testPrisma.tag.findUnique({ where: { id: tag.id } }); + expect(deleted).toBeNull(); + + // RecipeTag cascade deleted + const rt = await testPrisma.recipeTag.findFirst({ where: { tagId: tag.id } }); + expect(rt).toBeNull(); + }); + + it("should create activity log on reject", async () => { + const tag = await testPrisma.tag.create({ + data: { + name: "log_reject", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + }, + }); + + await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/reject`) + .set("Cookie", moderatorCookie); + + const log = await testPrisma.activityLog.findFirst({ + where: { type: "TAG_REJECTED", communityId: community.id }, + }); + expect(log).not.toBeNull(); + }); + + it("should reject rejecting an APPROVED tag", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "no_reject_approved", scope: "COMMUNITY", status: "APPROVED", communityId: community.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/reject`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_004"); + }); + + it("should return 403 for non-moderator", async () => { + const tag = await testPrisma.tag.create({ + data: { name: "noreject", scope: "COMMUNITY", status: "PENDING", communityId: community.id }, + }); + + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${tag.id}/reject`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/backend/src/admin/controllers/tagsController.ts b/backend/src/admin/controllers/tagsController.ts index 216c6620..c9a1684d 100644 --- a/backend/src/admin/controllers/tagsController.ts +++ b/backend/src/admin/controllers/tagsController.ts @@ -9,14 +9,25 @@ import { assertIsDefine } from "../../util/assertIsDefine"; */ export const getAll: RequestHandler = async (req, res, next) => { try { - const { search } = req.query; + const { search, scope } = req.query; + + const where: Record = {}; + + if (search) { + where.name = { contains: String(search), mode: "insensitive" }; + } + + if (scope === "GLOBAL") { + where.scope = "GLOBAL"; + } else if (scope === "COMMUNITY") { + where.scope = "COMMUNITY"; + } const tags = await prisma.tag.findMany({ - where: search - ? { name: { contains: String(search), mode: "insensitive" } } - : undefined, + where, include: { _count: { select: { recipes: true } }, + community: { select: { id: true, name: true } }, }, orderBy: { name: "asc" }, }); @@ -25,6 +36,10 @@ export const getAll: RequestHandler = async (req, res, next) => { tags: tags.map((t) => ({ id: t.id, name: t.name, + scope: t.scope, + status: t.status, + communityId: t.communityId, + community: t.community, recipeCount: t._count.recipes, })), }); @@ -100,12 +115,15 @@ export const update: RequestHandler = async (req, res, next) => { const normalized = name.trim().toLowerCase(); if (normalized !== tag.name) { + // Verifier unicite dans le meme scope const existing = await prisma.tag.findFirst({ - where: { name: normalized, communityId: null }, + where: { name: normalized, communityId: tag.communityId, id: { not: tag.id } }, }); if (existing) { throw createHttpError(409, "ADMIN_TAG_002: Tag already exists"); } + // Si c'est un tag global, verifier aussi qu'aucun tag communaute n'a ce nom + // (pas necessaire car la contrainte unique est [name, communityId]) } const oldName = tag.name; diff --git a/backend/src/controllers/communityTags.ts b/backend/src/controllers/communityTags.ts new file mode 100644 index 00000000..dcc46edc --- /dev/null +++ b/backend/src/controllers/communityTags.ts @@ -0,0 +1,348 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import prisma from "../util/db"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { parsePagination } from "../util/pagination"; + +/** + * GET /api/communities/:communityId/tags + * Liste les tags de la communaute (APPROVED + PENDING) + * Accessible aux moderateurs uniquement + */ +export const getCommunityTags: RequestHandler = async (req, res, next) => { + const { communityId } = req.params; + const { search, status } = req.query as { search?: string; status?: string }; + const { limit, offset } = parsePagination(req.query as Record); + + try { + const where: Record = { + communityId, + scope: "COMMUNITY", + }; + + if (search) { + where.name = { contains: String(search), mode: "insensitive" }; + } + + if (status === "APPROVED" || status === "PENDING") { + where.status = status; + } + + const [tags, total] = await Promise.all([ + prisma.tag.findMany({ + where, + include: { + _count: { select: { recipes: true } }, + createdBy: { select: { id: true, username: true } }, + }, + orderBy: { name: "asc" }, + skip: offset, + take: limit, + }), + prisma.tag.count({ where }), + ]); + + const data = tags.map((t) => ({ + id: t.id, + name: t.name, + scope: t.scope, + status: t.status, + communityId: t.communityId, + createdBy: t.createdBy, + recipeCount: t._count.recipes, + createdAt: t.createdAt, + })); + + res.status(200).json({ data, total }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/tags + * Cree un tag communaute (APPROVED directement, par moderateur) + */ +export const createCommunityTag: RequestHandler = async (req, res, next) => { + const { communityId } = req.params; + const { name } = req.body; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + if (!name || typeof name !== "string" || name.trim().length === 0) { + throw createHttpError(400, "TAG_001: Tag name is required"); + } + + const normalized = name.trim().toLowerCase(); + + if (normalized.length < 2 || normalized.length > 50) { + throw createHttpError(400, "TAG_001: Tag name must be between 2 and 50 characters"); + } + + // Verifier qu'aucun tag GLOBAL n'a ce nom + const existingGlobal = await prisma.tag.findFirst({ + where: { name: normalized, scope: "GLOBAL", communityId: null }, + }); + if (existingGlobal) { + throw createHttpError(409, "TAG_002: A global tag with this name already exists"); + } + + // Verifier qu'aucun tag COMMUNITY n'a ce nom dans cette communaute + const existingCommunity = await prisma.tag.findFirst({ + where: { name: normalized, communityId }, + }); + if (existingCommunity) { + throw createHttpError(409, "TAG_002: A tag with this name already exists in this community"); + } + + // Verifier limite 100 tags par communaute + const count = await prisma.tag.count({ + where: { communityId, scope: "COMMUNITY" }, + }); + if (count >= 100) { + throw createHttpError(400, "TAG_003: Community tag limit reached (100)"); + } + + const tag = await prisma.tag.create({ + data: { + name: normalized, + scope: "COMMUNITY", + status: "APPROVED", + communityId, + createdById: userId, + }, + }); + + // ActivityLog + await prisma.activityLog.create({ + data: { + type: "TAG_CREATED", + userId, + communityId, + metadata: { tagId: tag.id, tagName: normalized }, + }, + }); + + res.status(201).json({ + id: tag.id, + name: tag.name, + scope: tag.scope, + status: tag.status, + communityId: tag.communityId, + }); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/tags/:tagId + * Renomme un tag communaute + */ +export const updateCommunityTag: RequestHandler = async (req, res, next) => { + const { communityId, tagId } = req.params; + const { name } = req.body; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + assertIsDefine(tagId); + + if (!name || typeof name !== "string" || name.trim().length === 0) { + throw createHttpError(400, "TAG_001: Tag name is required"); + } + + const normalized = name.trim().toLowerCase(); + + if (normalized.length < 2 || normalized.length > 50) { + throw createHttpError(400, "TAG_001: Tag name must be between 2 and 50 characters"); + } + + const tag = await prisma.tag.findUnique({ where: { id: tagId } }); + if (!tag) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + + // Verifier que le tag appartient a cette communaute + if (tag.communityId !== communityId || tag.scope !== "COMMUNITY") { + throw createHttpError(403, "TAG_005: Cannot modify a tag that does not belong to this community"); + } + + if (normalized !== tag.name) { + // Verifier unicite GLOBAL + const existingGlobal = await prisma.tag.findFirst({ + where: { name: normalized, scope: "GLOBAL", communityId: null }, + }); + if (existingGlobal) { + throw createHttpError(409, "TAG_002: A global tag with this name already exists"); + } + + // Verifier unicite dans la communaute + const existingCommunity = await prisma.tag.findFirst({ + where: { name: normalized, communityId, id: { not: tagId } }, + }); + if (existingCommunity) { + throw createHttpError(409, "TAG_002: A tag with this name already exists in this community"); + } + } + + const oldName = tag.name; + const updated = await prisma.tag.update({ + where: { id: tagId }, + data: { name: normalized }, + }); + + await prisma.activityLog.create({ + data: { + type: "TAG_UPDATED", + userId, + communityId, + metadata: { tagId, oldName, newName: normalized }, + }, + }); + + res.status(200).json({ + id: updated.id, + name: updated.name, + scope: updated.scope, + status: updated.status, + communityId: updated.communityId, + }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/tags/:tagId + * Supprime un tag communaute (hard delete, RecipeTag cascade) + */ +export const deleteCommunityTag: RequestHandler = async (req, res, next) => { + const { communityId, tagId } = req.params; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + assertIsDefine(tagId); + + const tag = await prisma.tag.findUnique({ where: { id: tagId } }); + if (!tag) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + + if (tag.communityId !== communityId || tag.scope !== "COMMUNITY") { + throw createHttpError(403, "TAG_005: Cannot modify a tag that does not belong to this community"); + } + + await prisma.tag.delete({ where: { id: tagId } }); + + await prisma.activityLog.create({ + data: { + type: "TAG_DELETED", + userId, + communityId, + metadata: { tagId, tagName: tag.name }, + }, + }); + + res.status(200).json({ message: "Tag deleted" }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/tags/:tagId/approve + * Valide un tag PENDING → APPROVED + */ +export const approveCommunityTag: RequestHandler = async (req, res, next) => { + const { communityId, tagId } = req.params; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + assertIsDefine(tagId); + + const tag = await prisma.tag.findUnique({ where: { id: tagId } }); + if (!tag) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + + if (tag.communityId !== communityId || tag.scope !== "COMMUNITY") { + throw createHttpError(403, "TAG_005: Cannot modify a tag that does not belong to this community"); + } + + if (tag.status !== "PENDING") { + throw createHttpError(400, "TAG_004: Tag is not pending"); + } + + const updated = await prisma.tag.update({ + where: { id: tagId }, + data: { status: "APPROVED" }, + }); + + await prisma.activityLog.create({ + data: { + type: "TAG_APPROVED", + userId, + communityId, + metadata: { tagId, tagName: tag.name }, + }, + }); + + res.status(200).json({ + id: updated.id, + name: updated.name, + scope: updated.scope, + status: updated.status, + communityId: updated.communityId, + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/tags/:tagId/reject + * Rejette un tag PENDING → hard delete tag + cascade RecipeTags + */ +export const rejectCommunityTag: RequestHandler = async (req, res, next) => { + const { communityId, tagId } = req.params; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + assertIsDefine(tagId); + + const tag = await prisma.tag.findUnique({ where: { id: tagId } }); + if (!tag) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + + if (tag.communityId !== communityId || tag.scope !== "COMMUNITY") { + throw createHttpError(403, "TAG_005: Cannot modify a tag that does not belong to this community"); + } + + if (tag.status !== "PENDING") { + throw createHttpError(400, "TAG_004: Tag is not pending"); + } + + // Hard delete (cascade supprime les RecipeTag) + await prisma.tag.delete({ where: { id: tagId } }); + + await prisma.activityLog.create({ + data: { + type: "TAG_REJECTED", + userId, + communityId, + metadata: { tagId, tagName: tag.name, createdById: tag.createdById }, + }, + }); + + res.status(200).json({ message: "Tag rejected and removed" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/routes/communities.ts b/backend/src/routes/communities.ts index 5ac97b20..25f76f26 100644 --- a/backend/src/routes/communities.ts +++ b/backend/src/routes/communities.ts @@ -1,6 +1,7 @@ import express from "express"; import * as CommunitiesController from "../controllers/communities"; import * as CommunityRecipesController from "../controllers/communityRecipes"; +import * as CommunityTagsController from "../controllers/communityTags"; import * as InvitesController from "../controllers/invites"; import * as MembersController from "../controllers/members"; import * as ActivityController from "../controllers/activity"; @@ -85,6 +86,58 @@ router.delete( InvitesController.cancelInvite ); +// ===================================== +// Tag management routes (MODERATOR only) +// ===================================== + +// List community tags (APPROVED + PENDING) +router.get( + "/:communityId/tags", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.getCommunityTags +); + +// Create a community tag +router.post( + "/:communityId/tags", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.createCommunityTag +); + +// Rename a community tag +router.patch( + "/:communityId/tags/:tagId", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.updateCommunityTag +); + +// Delete a community tag +router.delete( + "/:communityId/tags/:tagId", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.deleteCommunityTag +); + +// Approve a pending tag +router.post( + "/:communityId/tags/:tagId/approve", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.approveCommunityTag +); + +// Reject a pending tag +router.post( + "/:communityId/tags/:tagId/reject", + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.rejectCommunityTag +); + // ===================================== // Activity feed // ===================================== diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 9e5d649b..f076fe40 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -27,12 +27,12 @@ ## 10.3 - Backend Administration tags -- [ ] Endpoints moderateur : GET/POST/PUT/DELETE community tags -- [ ] Endpoints moderateur : approve/reject tag pending -- [ ] Cascade rejet : hard delete tag + RecipeTags + notifications -- [ ] Cascade validation : update status + notifications -- [ ] Adaptation endpoints SuperAdmin (filtre scope) -- [ ] Tests +- [x] Endpoints moderateur : GET/POST/PUT/DELETE community tags +- [x] Endpoints moderateur : approve/reject tag pending +- [x] Cascade rejet : hard delete tag + RecipeTags + notifications +- [x] Cascade validation : update status + notifications +- [x] Adaptation endpoints SuperAdmin (filtre scope) +- [x] Tests ## 10.4 - Backend TagSuggestion From 98d50bce88be27ad59389f826565f1892560bd0d Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Fri, 13 Feb 2026 17:01:07 +0100 Subject: [PATCH 005/221] docs: update context files for Phase 10.2-10.3 progress --- .claude/context/DB_MODELS.md | 3 ++- .claude/context/PROGRESS.md | 7 ++++--- .claude/context/TESTS.md | 14 ++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.claude/context/DB_MODELS.md b/.claude/context/DB_MODELS.md index bf88e7f2..a93ca511 100644 --- a/.claude/context/DB_MODELS.md +++ b/.claude/context/DB_MODELS.md @@ -76,7 +76,8 @@ AdminActionType: TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_MERGED | ActivityType: RECIPE_CREATED | RECIPE_UPDATED | RECIPE_DELETED | RECIPE_SHARED | VARIANT_PROPOSED | VARIANT_CREATED | PROPOSAL_ACCEPTED | PROPOSAL_REJECTED | USER_JOINED | USER_LEFT | USER_KICKED | USER_PROMOTED | - INVITE_SENT | INVITE_ACCEPTED | INVITE_REJECTED | INVITE_CANCELLED + INVITE_SENT | INVITE_ACCEPTED | INVITE_REJECTED | INVITE_CANCELLED | + TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_APPROVED | TAG_REJECTED ``` ## Relations cles diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 0c0b0f38..09865c7f 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -2,14 +2,15 @@ ## MVP COMPLET -Phases 0 a 9.3 terminees. 663 tests (273 frontend + 390 backend). +Phases 0 a 9.3 terminees. ## Phase en cours : 10 - Rework Tags - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : Spec validee, implementation non commencee -- **Branche** : a creer (`tags-rework`) +- **Sous-etape en cours** : 10.3 termine, prochaine etape 10.4 (TagSuggestion) +- **Branche** : `TagsRework` +- **Tests** : 704 (273 frontend + 431 backend) ## Prochains chantiers (a specifier) diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index 9cc4311f..f1329a31 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -40,34 +40,36 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Mocks: `__tests__/setup/mswHandlers.ts` - Utils: `__tests__/setup/testUtils.tsx` -## Inventaire des tests (~663 tests) +## Inventaire des tests (~704 tests) -### Backend Integration (19 fichiers, ~339 tests) +### Backend Integration (20 fichiers, ~380 tests) | Fichier | Module | Tests | |---------|--------|-------| | activity.test.ts | Activity feed (community + personal) | 15 | | auth.test.ts | User signup/login/logout/me | 16 | | recipes.test.ts | CRUD recettes (perso + community access) | 32 | -| communityRecipes.test.ts | CRUD recettes communautaires | 28 | +| communityRecipes.test.ts | CRUD recettes communautaires (+ tags scope-aware) | 33 | | proposals.test.ts | Propositions modifications | 31 | | variants.test.ts | Liste variantes recettes | 10 | -| tags.test.ts | Autocomplete tags | 5 | +| tags.test.ts | Autocomplete tags (scope-aware) | 9 | | ingredients.test.ts | Autocomplete ingredients | 5 | | communities.test.ts | CRUD communautes | 27 | | invitations.test.ts | Workflow invitations | 35 | | members.test.ts | Membres: list, promote, kick, orphan handling | 26 | | adminAuth.test.ts | Auth 2FA admin | 14 | -| adminTags.test.ts | CRUD tags admin | 12 | +| adminTags.test.ts | CRUD tags admin (+ scope filter) | 15 | | adminIngredients.test.ts | CRUD ingredients admin | 12 | | adminFeatures.test.ts | Features grant/revoke | 10 | | adminCommunities.test.ts | Communities admin | 8 | | adminDashboard.test.ts | Stats dashboard | 4 | | adminActivity.test.ts | Logs activite | 4 | -| share.test.ts | Partage inter-communautes + publish + sync | 28 | +| share.test.ts | Partage inter-communautes + publish + sync + fork tags | 31 | +| communityTags.test.ts | CRUD + approve/reject tags communaute (moderateur) | 26 | ### Backend Unit (7 fichiers, ~51 tests) + | Fichier | Module | Tests | |---------|--------|-------| | eventEmitter.test.ts | Event emitter | 3 | From 566f4cb30fb8750fa3ac11c629eccba48b8e5858 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Mon, 16 Feb 2026 16:08:13 +0100 Subject: [PATCH 006/221] feat: tag suggestion workflow with owner accept/reject (Phase 10.4) Add TagSuggestion endpoints: members can suggest tags on community recipes they don't own. Owner accepts/rejects; unknown tags go through moderator validation (PENDING_MODERATOR). Includes cascade from moderator approve/reject to related suggestions, orphan recipe auto-reject, and 29 integration tests. --- .claude/context/API_MAP.md | 16 +- .claude/context/PROGRESS.md | 4 +- .../migration.sql | 3 + backend/prisma/schema.prisma | 4 + .../integration/tagSuggestions.test.ts | 676 ++++++++++++++++++ backend/src/__tests__/setup/testHelpers.ts | 23 + backend/src/app.ts | 2 + backend/src/controllers/communityTags.ts | 20 + backend/src/controllers/tagSuggestions.ts | 325 +++++++++ backend/src/routes/recipes.ts | 6 + backend/src/routes/tagSuggestions.ts | 12 + backend/src/services/tagSuggestionService.ts | 182 +++++ docs/features/tags-rework/ROADMAP.md | 11 +- 13 files changed, 1276 insertions(+), 8 deletions(-) create mode 100644 backend/prisma/migrations/20260216120000_add_tag_suggestion_activity_types/migration.sql create mode 100644 backend/src/__tests__/integration/tagSuggestions.test.ts create mode 100644 backend/src/controllers/tagSuggestions.ts create mode 100644 backend/src/routes/tagSuggestions.ts create mode 100644 backend/src/services/tagSuggestionService.ts diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 9290cf11..1e36be15 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -122,6 +122,20 @@ POST /api/recipes/:recipeId/proposals # creer proposition ``` Controller: `controllers/proposals.ts` | Route: `routes/recipes.ts` +## Tag Suggestions (/api/tag-suggestions) - requireAuth +``` +POST /api/tag-suggestions/:id/accept # owner accepte suggestion +POST /api/tag-suggestions/:id/reject # owner rejette suggestion +``` +Controller: `controllers/tagSuggestions.ts` | Route: `routes/tagSuggestions.ts` + +### Tag Suggestions (nested under /api/recipes/:recipeId) +``` +GET /api/recipes/:recipeId/tag-suggestions # list suggestions (?status=) +POST /api/recipes/:recipeId/tag-suggestions # suggerer un tag (membre, pas owner) +``` +Controller: `controllers/tagSuggestions.ts` | Route: `routes/recipes.ts` + --- ## Admin Auth (/api/admin/auth) - adminSession, rate limited 5/15min @@ -194,4 +208,4 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti | adminRateLimiter | middleware/security.ts | 30 req/min global admin | | authRateLimiter | routes config | 5/15min sur auth endpoints | -## Total: 71 endpoints (44 user + 27 admin + 1 health) +## Total: 75 endpoints (48 user + 27 admin + 1 health) diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 09865c7f..3ee82f71 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.3 termine, prochaine etape 10.4 (TagSuggestion) +- **Sous-etape en cours** : 10.4 termine, prochaine etape 10.5 (Preferences & Notifications) - **Branche** : `TagsRework` -- **Tests** : 704 (273 frontend + 431 backend) +- **Tests** : 733 (273 frontend + 460 backend) ## Prochains chantiers (a specifier) diff --git a/backend/prisma/migrations/20260216120000_add_tag_suggestion_activity_types/migration.sql b/backend/prisma/migrations/20260216120000_add_tag_suggestion_activity_types/migration.sql new file mode 100644 index 00000000..890f2cd4 --- /dev/null +++ b/backend/prisma/migrations/20260216120000_add_tag_suggestion_activity_types/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "ActivityType" ADD VALUE 'TAG_SUGGESTION_ACCEPTED'; +ALTER TYPE "ActivityType" ADD VALUE 'TAG_SUGGESTION_REJECTED'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 126d978a..d029142a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -550,6 +550,10 @@ enum ActivityType { TAG_DELETED TAG_APPROVED TAG_REJECTED + + // Tag suggestions + TAG_SUGGESTION_ACCEPTED + TAG_SUGGESTION_REJECTED } // ============================================================================= diff --git a/backend/src/__tests__/integration/tagSuggestions.test.ts b/backend/src/__tests__/integration/tagSuggestions.test.ts new file mode 100644 index 00000000..674dfc5f --- /dev/null +++ b/backend/src/__tests__/integration/tagSuggestions.test.ts @@ -0,0 +1,676 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +const uniqueSuffix = () => + `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + +describe("Tag Suggestions API", () => { + let moderator: { id: string }; + let moderatorCookie: string; + let owner: { id: string }; + let ownerCookie: string; + let suggester: { id: string }; + let suggesterCookie: string; + let community: { id: string }; + let communityRecipeId: string; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Create moderator (creates community) + const modSignup = await request(app).post("/api/auth/signup").send({ + username: `tsmod_${suffix}`, + email: `tsmod_${suffix}@example.com`, + password: "Test123!Password", + }); + moderatorCookie = extractSessionCookie(modSignup)!; + moderator = (await testPrisma.user.findFirst({ + where: { email: `tsmod_${suffix}@example.com` }, + }))!; + + // Create community + const createRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `TagSug Community ${suffix}` }); + community = createRes.body; + + // Create owner (member) + const ownerSignup = await request(app).post("/api/auth/signup").send({ + username: `tsown_${suffix}`, + email: `tsown_${suffix}@example.com`, + password: "Test123!Password", + }); + ownerCookie = extractSessionCookie(ownerSignup)!; + owner = (await testPrisma.user.findFirst({ + where: { email: `tsown_${suffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: owner.id, communityId: community.id, role: "MEMBER" }, + }); + + // Create suggester (member) + const sugSignup = await request(app).post("/api/auth/signup").send({ + username: `tssug_${suffix}`, + email: `tssug_${suffix}@example.com`, + password: "Test123!Password", + }); + suggesterCookie = extractSessionCookie(sugSignup)!; + suggester = (await testPrisma.user.findFirst({ + where: { email: `tssug_${suffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: suggester.id, communityId: community.id, role: "MEMBER" }, + }); + + // Create a community recipe owned by owner + const recipeRes = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", ownerCookie) + .send({ title: "Test Recipe", content: "Some content" }); + communityRecipeId = recipeRes.body.community.id; + }); + + // ===================================== + // POST /api/recipes/:recipeId/tag-suggestions + // ===================================== + describe("POST /api/recipes/:recipeId/tag-suggestions", () => { + it("should create a tag suggestion", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "Dessert" }); + + expect(res.status).toBe(201); + expect(res.body.tagName).toBe("dessert"); + expect(res.body.status).toBe("PENDING_OWNER"); + expect(res.body.suggestedBy.id).toBe(suggester.id); + }); + + it("should block self-suggestion (TAG_007)", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", ownerCookie) + .send({ tagName: "myowntag" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_007"); + }); + + it("should block duplicate suggestion (TAG_006)", async () => { + await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "duplicate" }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "Duplicate" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("TAG_006"); + }); + + it("should block non-member", async () => { + const suffix = uniqueSuffix(); + const outsiderSignup = await request(app).post("/api/auth/signup").send({ + username: `tsout_${suffix}`, + email: `tsout_${suffix}@example.com`, + password: "Test123!Password", + }); + const outsiderCookie = extractSessionCookie(outsiderSignup)!; + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", outsiderCookie) + .send({ tagName: "nope" }); + + expect(res.status).toBe(403); + }); + + it("should block suggestion on personal recipe", async () => { + // Create a personal recipe by suggester + const personalRes = await request(app) + .post("/api/recipes") + .set("Cookie", ownerCookie) + .send({ title: "Personal", content: "content" }); + + const res = await request(app) + .post(`/api/recipes/${personalRes.body.id}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "nope" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_007"); + }); + + it("should block when recipe has max tags (TAG_003)", async () => { + // Add 10 tags to the recipe + for (let i = 0; i < 10; i++) { + const tag = await testPrisma.tag.create({ + data: { name: `maxtag_${i}_${uniqueSuffix()}`, scope: "GLOBAL", status: "APPROVED" }, + }); + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: tag.id }, + }); + } + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "one too many" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_003"); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .post("/api/recipes/00000000-0000-0000-0000-000000000000/tag-suggestions") + .set("Cookie", suggesterCookie) + .send({ tagName: "nope" }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("RECIPE_001"); + }); + + it("should validate tag name (TAG_001)", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "a" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_001"); + }); + + it("should reject empty tag name", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", suggesterCookie) + .send({ tagName: "" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_001"); + }); + }); + + // ===================================== + // GET /api/recipes/:recipeId/tag-suggestions + // ===================================== + describe("GET /api/recipes/:recipeId/tag-suggestions", () => { + it("should list suggestions for members", async () => { + await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "suggestion1", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].tagName).toBe("suggestion1"); + expect(res.body.data[0].suggestedBy.id).toBe(suggester.id); + }); + + it("should filter by status", async () => { + await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "pending_one", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "approved_one", + suggestedById: suggester.id, + status: "APPROVED", + decidedAt: new Date(), + }, + }); + + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/tag-suggestions?status=PENDING_OWNER`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].tagName).toBe("pending_one"); + }); + + it("should include pagination", async () => { + await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "pag1", + suggestedById: suggester.id, + }, + }); + await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "pag2", + suggestedById: suggester.id, + }, + }); + + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/tag-suggestions?limit=1`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.pagination.total).toBe(2); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .get("/api/recipes/00000000-0000-0000-0000-000000000000/tag-suggestions") + .set("Cookie", suggesterCookie); + + expect(res.status).toBe(404); + }); + + it("should block non-members", async () => { + const suffix = uniqueSuffix(); + const outsiderSignup = await request(app).post("/api/auth/signup").send({ + username: `tsout2_${suffix}`, + email: `tsout2_${suffix}@example.com`, + password: "Test123!Password", + }); + const outsiderCookie = extractSessionCookie(outsiderSignup)!; + + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/tag-suggestions`) + .set("Cookie", outsiderCookie); + + expect(res.status).toBe(403); + }); + }); + + // ===================================== + // POST /api/tag-suggestions/:id/accept + // ===================================== + describe("POST /api/tag-suggestions/:id/accept", () => { + it("should accept suggestion with existing global tag -> APPROVED", async () => { + // Create an existing global tag + await testPrisma.tag.create({ + data: { name: "global_existing", scope: "GLOBAL", status: "APPROVED" }, + }); + + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "global_existing", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("APPROVED"); + + // RecipeTag should exist + const recipeTag = await testPrisma.recipeTag.findFirst({ + where: { recipeId: communityRecipeId }, + include: { tag: true }, + }); + expect(recipeTag).not.toBeNull(); + expect(recipeTag!.tag.name).toBe("global_existing"); + }); + + it("should accept suggestion with existing community tag -> APPROVED", async () => { + await testPrisma.tag.create({ + data: { + name: "comm_existing", + scope: "COMMUNITY", + status: "APPROVED", + communityId: community.id, + }, + }); + + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "comm_existing", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("APPROVED"); + }); + + it("should accept suggestion with unknown tag -> PENDING_MODERATOR", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "brand_new_tag", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("PENDING_MODERATOR"); + + // A PENDING community tag should have been created + const pendingTag = await testPrisma.tag.findFirst({ + where: { name: "brand_new_tag", scope: "COMMUNITY", status: "PENDING", communityId: community.id }, + }); + expect(pendingTag).not.toBeNull(); + + // RecipeTag should exist + const recipeTag = await testPrisma.recipeTag.findFirst({ + where: { recipeId: communityRecipeId, tagId: pendingTag!.id }, + }); + expect(recipeTag).not.toBeNull(); + }); + + it("should block non-owner", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "blocked_accept", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", suggesterCookie); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("RECIPE_002"); + }); + + it("should block already decided suggestion", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "already_done", + suggestedById: suggester.id, + status: "APPROVED", + decidedAt: new Date(), + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_007"); + }); + + it("should return 404 for non-existent suggestion", async () => { + const res = await request(app) + .post("/api/tag-suggestions/00000000-0000-0000-0000-000000000000/accept") + .set("Cookie", ownerCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("TAG_007"); + }); + + it("should create activity log", async () => { + await testPrisma.tag.create({ + data: { name: "log_tag", scope: "GLOBAL", status: "APPROVED" }, + }); + + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "log_tag", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + const log = await testPrisma.activityLog.findFirst({ + where: { type: "TAG_SUGGESTION_ACCEPTED", recipeId: communityRecipeId }, + }); + expect(log).not.toBeNull(); + }); + }); + + // ===================================== + // POST /api/tag-suggestions/:id/reject + // ===================================== + describe("POST /api/tag-suggestions/:id/reject", () => { + it("should reject suggestion", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "to_reject", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/reject`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("REJECTED"); + expect(res.body.decidedAt).not.toBeNull(); + }); + + it("should block non-owner", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "blocked_reject", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/reject`) + .set("Cookie", suggesterCookie); + + expect(res.status).toBe(403); + }); + + it("should block already decided suggestion", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "already_rejected", + suggestedById: suggester.id, + status: "REJECTED", + decidedAt: new Date(), + }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/reject`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_007"); + }); + + it("should return 404 for non-existent suggestion", async () => { + const res = await request(app) + .post("/api/tag-suggestions/00000000-0000-0000-0000-000000000000/reject") + .set("Cookie", ownerCookie); + + expect(res.status).toBe(404); + }); + + it("should create activity log", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "log_reject", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/reject`) + .set("Cookie", ownerCookie); + + const log = await testPrisma.activityLog.findFirst({ + where: { type: "TAG_SUGGESTION_REJECTED", recipeId: communityRecipeId }, + }); + expect(log).not.toBeNull(); + }); + }); + + // ===================================== + // Cascade: moderator approve/reject tag -> update TagSuggestions + // ===================================== + describe("Cascade: moderator tag decisions", () => { + it("should cascade approve to PENDING_MODERATOR suggestions", async () => { + // Create a PENDING tag in the community + const pendingTag = await testPrisma.tag.create({ + data: { + name: "cascade_approve", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + createdById: suggester.id, + }, + }); + + // Create RecipeTag with this pending tag + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: pendingTag.id }, + }); + + // Create a TagSuggestion in PENDING_MODERATOR + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "cascade_approve", + suggestedById: suggester.id, + status: "PENDING_MODERATOR", + }, + }); + + // Moderator approves the tag + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${pendingTag.id}/approve`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + + // TagSuggestion should now be APPROVED + const updated = await testPrisma.tagSuggestion.findUnique({ + where: { id: suggestion.id }, + }); + expect(updated!.status).toBe("APPROVED"); + expect(updated!.decidedAt).not.toBeNull(); + }); + + it("should cascade reject to PENDING_MODERATOR suggestions", async () => { + // Create a PENDING tag + const pendingTag = await testPrisma.tag.create({ + data: { + name: "cascade_reject", + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + createdById: suggester.id, + }, + }); + + // Create RecipeTag + await testPrisma.recipeTag.create({ + data: { recipeId: communityRecipeId, tagId: pendingTag.id }, + }); + + // Create a TagSuggestion in PENDING_MODERATOR + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "cascade_reject", + suggestedById: suggester.id, + status: "PENDING_MODERATOR", + }, + }); + + // Moderator rejects the tag + await request(app) + .post(`/api/communities/${community.id}/tags/${pendingTag.id}/reject`) + .set("Cookie", moderatorCookie); + + // TagSuggestion should now be REJECTED + const updated = await testPrisma.tagSuggestion.findUnique({ + where: { id: suggestion.id }, + }); + expect(updated!.status).toBe("REJECTED"); + expect(updated!.decidedAt).not.toBeNull(); + }); + }); + + // ===================================== + // Orphan recipe: auto-reject + // ===================================== + describe("Orphan recipe handling", () => { + it("should auto-reject suggestions on deleted recipes", async () => { + const suggestion = await testPrisma.tagSuggestion.create({ + data: { + recipeId: communityRecipeId, + tagName: "orphan_tag", + suggestedById: suggester.id, + status: "PENDING_OWNER", + }, + }); + + // Soft-delete the recipe + await testPrisma.recipe.update({ + where: { id: communityRecipeId }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .post(`/api/tag-suggestions/${suggestion.id}/accept`) + .set("Cookie", ownerCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_007"); + + // Suggestion should be rejected + const updated = await testPrisma.tagSuggestion.findUnique({ + where: { id: suggestion.id }, + }); + expect(updated!.status).toBe("REJECTED"); + }); + }); +}); diff --git a/backend/src/__tests__/setup/testHelpers.ts b/backend/src/__tests__/setup/testHelpers.ts index e144a67d..e4ac5699 100644 --- a/backend/src/__tests__/setup/testHelpers.ts +++ b/backend/src/__tests__/setup/testHelpers.ts @@ -376,6 +376,29 @@ export async function createTestFeature(data?: Partial<{ }; } +// ===================================== +// TagSuggestion Factory +// ===================================== + +export async function createTestTagSuggestion( + recipeId: string, + suggestedById: string, + tagName: string, + status?: 'PENDING_OWNER' | 'PENDING_MODERATOR' | 'APPROVED' | 'REJECTED' +) { + return testPrisma.tagSuggestion.create({ + data: { + recipeId, + suggestedById, + tagName: tagName.trim().toLowerCase(), + status: status ?? 'PENDING_OWNER', + decidedAt: status && status !== 'PENDING_OWNER' && status !== 'PENDING_MODERATOR' + ? new Date() + : null, + }, + }); +} + // ===================================== // Admin Login Helper // ===================================== diff --git a/backend/src/app.ts b/backend/src/app.ts index 611d723f..bfec825d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import communitiesRoutes from "./routes/communities"; import invitesRoutes from "./routes/invites"; import usersRoutes from "./routes/users"; import proposalsRoutes from "./routes/proposals"; +import tagSuggestionsRoutes from "./routes/tagSuggestions"; import adminAuthRoutes from "./admin/routes/authRoutes"; import adminTagsRoutes from "./admin/routes/tagsRoutes"; import adminIngredientsRoutes from "./admin/routes/ingredientsRoutes"; @@ -103,6 +104,7 @@ app.use("/api/communities", userSession, requireAuth, communitiesRoutes); app.use("/api/invites", userSession, requireAuth, invitesRoutes); app.use("/api/users", userSession, requireAuth, usersRoutes); app.use("/api/proposals", userSession, requireAuth, proposalsRoutes); +app.use("/api/tag-suggestions", userSession, requireAuth, tagSuggestionsRoutes); // Admin routes (avec admin session isolee + rate limiting global) app.use("/api/admin", adminRateLimiter); // Rate limit global admin (30 req/min) diff --git a/backend/src/controllers/communityTags.ts b/backend/src/controllers/communityTags.ts index dcc46edc..c28da492 100644 --- a/backend/src/controllers/communityTags.ts +++ b/backend/src/controllers/communityTags.ts @@ -283,6 +283,16 @@ export const approveCommunityTag: RequestHandler = async (req, res, next) => { data: { status: "APPROVED" }, }); + // Cascade : approuver les TagSuggestions PENDING_MODERATOR avec ce tagName dans cette communaute + await prisma.tagSuggestion.updateMany({ + where: { + tagName: tag.name, + status: "PENDING_MODERATOR", + recipe: { communityId, deletedAt: null }, + }, + data: { status: "APPROVED", decidedAt: new Date() }, + }); + await prisma.activityLog.create({ data: { type: "TAG_APPROVED", @@ -329,6 +339,16 @@ export const rejectCommunityTag: RequestHandler = async (req, res, next) => { throw createHttpError(400, "TAG_004: Tag is not pending"); } + // Cascade : rejeter les TagSuggestions PENDING_MODERATOR avec ce tagName dans cette communaute + await prisma.tagSuggestion.updateMany({ + where: { + tagName: tag.name, + status: "PENDING_MODERATOR", + recipe: { communityId, deletedAt: null }, + }, + data: { status: "REJECTED", decidedAt: new Date() }, + }); + // Hard delete (cascade supprime les RecipeTag) await prisma.tag.delete({ where: { id: tagId } }); diff --git a/backend/src/controllers/tagSuggestions.ts b/backend/src/controllers/tagSuggestions.ts new file mode 100644 index 00000000..a2b925a5 --- /dev/null +++ b/backend/src/controllers/tagSuggestions.ts @@ -0,0 +1,325 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import { Prisma } from "@prisma/client"; +import createHttpError from "http-errors"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { parsePagination, buildPaginationMeta } from "../util/pagination"; +import { requireMembership } from "../services/membershipService"; +import { + createTagSuggestion as createTagSuggestionService, + acceptTagSuggestion as acceptTagSuggestionService, + rejectTagSuggestion as rejectTagSuggestionService, +} from "../services/tagSuggestionService"; +import appEvents from "../services/eventEmitter"; + +const MAX_TAGS_PER_RECIPE = 10; + +interface CreateTagSuggestionBody { + tagName?: string; +} + +/** + * POST /api/recipes/:recipeId/tag-suggestions + * Suggerer un tag sur une recette communautaire d'autrui + */ +export const createTagSuggestion: RequestHandler< + { recipeId: string }, + unknown, + CreateTagSuggestionBody, + unknown +> = async (req, res, next) => { + const { tagName } = req.body; + const authenticatedUserId = req.session.userId; + const { recipeId } = req.params; + + try { + assertIsDefine(authenticatedUserId); + + // Validation tagName + if (!tagName || typeof tagName !== "string" || tagName.trim().length === 0) { + throw createHttpError(400, "TAG_001: Tag name is required"); + } + + const normalized = tagName.trim().toLowerCase(); + if (normalized.length < 2 || normalized.length > 50) { + throw createHttpError(400, "TAG_001: Tag name must be between 2 and 50 characters"); + } + + // Recuperer la recette + const recipe = await prisma.recipe.findFirst({ + where: { id: recipeId, deletedAt: null }, + select: { + id: true, + communityId: true, + creatorId: true, + _count: { select: { tags: true } }, + }, + }); + + if (!recipe) { + throw createHttpError(404, "RECIPE_001: Recipe not found"); + } + + // Doit etre une recette communautaire + if (!recipe.communityId) { + throw createHttpError(400, "TAG_007: Cannot suggest tags on personal recipes"); + } + + // Verifier membership + await requireMembership(authenticatedUserId, recipe.communityId); + + // Bloquer auto-suggestion + if (recipe.creatorId === authenticatedUserId) { + throw createHttpError(400, "TAG_007: Cannot suggest tags on your own recipe"); + } + + // Verifier doublon + const existing = await prisma.tagSuggestion.findUnique({ + where: { + recipeId_tagName_suggestedById: { + recipeId, + tagName: normalized, + suggestedById: authenticatedUserId, + }, + }, + }); + if (existing) { + throw createHttpError(409, "TAG_006: You already suggested this tag on this recipe"); + } + + // Verifier max tags sur la recette + if (recipe._count.tags >= MAX_TAGS_PER_RECIPE) { + throw createHttpError(400, "TAG_003: Maximum 10 tags per recipe"); + } + + // Creer la suggestion + const suggestion = await prisma.$transaction(async (tx) => { + return createTagSuggestionService(tx, recipeId, normalized, authenticatedUserId); + }); + + // Notifier le owner + appEvents.emitActivity({ + type: "TAG_SUGGESTION_CREATED", + userId: authenticatedUserId, + communityId: recipe.communityId, + recipeId, + targetUserIds: [recipe.creatorId], + metadata: { suggestionId: suggestion.id, tagName: normalized }, + }); + + res.status(201).json(suggestion); + } catch (error) { + next(error); + } +}; + +interface GetTagSuggestionsQuery { + status?: string; + limit?: string; + offset?: string; +} + +/** + * GET /api/recipes/:recipeId/tag-suggestions + * Lister les suggestions de tags sur une recette + */ +export const getTagSuggestions: RequestHandler< + { recipeId: string }, + unknown, + unknown, + GetTagSuggestionsQuery +> = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + const { recipeId } = req.params; + const statusFilter = req.query.status?.toUpperCase(); + const { limit, offset } = parsePagination(req.query); + + try { + assertIsDefine(authenticatedUserId); + + const recipe = await prisma.recipe.findFirst({ + where: { id: recipeId, deletedAt: null }, + select: { id: true, communityId: true }, + }); + + if (!recipe) { + throw createHttpError(404, "RECIPE_001: Recipe not found"); + } + + if (!recipe.communityId) { + throw createHttpError(400, "TAG_007: No tag suggestions on personal recipes"); + } + + await requireMembership(authenticatedUserId, recipe.communityId); + + const whereClause: Prisma.TagSuggestionWhereInput = { recipeId }; + + const validStatuses = ["PENDING_OWNER", "PENDING_MODERATOR", "APPROVED", "REJECTED"]; + if (statusFilter && validStatuses.includes(statusFilter)) { + whereClause.status = statusFilter as any; + } + + const [suggestions, total] = await Promise.all([ + prisma.tagSuggestion.findMany({ + where: whereClause, + select: { + id: true, + recipeId: true, + tagName: true, + status: true, + createdAt: true, + decidedAt: true, + suggestedBy: { + select: { id: true, username: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + }), + prisma.tagSuggestion.count({ where: whereClause }), + ]); + + res.status(200).json({ + data: suggestions, + pagination: buildPaginationMeta(total, limit, offset, suggestions.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/tag-suggestions/:id/accept + * Le proprietaire de la recette accepte la suggestion + */ +export const acceptTagSuggestion: RequestHandler< + { id: string }, + unknown, + unknown, + unknown +> = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + const { id } = req.params; + + try { + assertIsDefine(authenticatedUserId); + + const suggestion = await prisma.tagSuggestion.findUnique({ + where: { id }, + include: { + recipe: { + select: { id: true, communityId: true, creatorId: true, deletedAt: true }, + }, + }, + }); + + if (!suggestion) { + throw createHttpError(404, "TAG_007: Tag suggestion not found"); + } + + // Recette orpheline -> auto-reject + if (!suggestion.recipe.creatorId || suggestion.recipe.deletedAt) { + await prisma.tagSuggestion.update({ + where: { id }, + data: { status: "REJECTED", decidedAt: new Date() }, + }); + throw createHttpError(400, "TAG_007: Recipe is no longer available"); + } + + // Verifier que c'est le owner + if (suggestion.recipe.creatorId !== authenticatedUserId) { + throw createHttpError(403, "RECIPE_002: Only the recipe owner can accept suggestions"); + } + + // Verifier statut + if (suggestion.status !== "PENDING_OWNER") { + throw createHttpError(400, "TAG_007: Suggestion already decided"); + } + + const result = await acceptTagSuggestionService(id, suggestion, authenticatedUserId); + + appEvents.emitActivity({ + type: "TAG_SUGGESTION_ACCEPTED", + userId: authenticatedUserId, + communityId: suggestion.recipe.communityId, + recipeId: suggestion.recipeId, + targetUserIds: [suggestion.suggestedById], + metadata: { suggestionId: id, tagName: suggestion.tagName }, + }); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/tag-suggestions/:id/reject + * Le proprietaire de la recette rejette la suggestion + */ +export const rejectTagSuggestion: RequestHandler< + { id: string }, + unknown, + unknown, + unknown +> = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + const { id } = req.params; + + try { + assertIsDefine(authenticatedUserId); + + const suggestion = await prisma.tagSuggestion.findUnique({ + where: { id }, + include: { + recipe: { + select: { id: true, communityId: true, creatorId: true, deletedAt: true }, + }, + }, + }); + + if (!suggestion) { + throw createHttpError(404, "TAG_007: Tag suggestion not found"); + } + + // Recette orpheline -> auto-reject + if (!suggestion.recipe.creatorId || suggestion.recipe.deletedAt) { + await prisma.tagSuggestion.update({ + where: { id }, + data: { status: "REJECTED", decidedAt: new Date() }, + }); + throw createHttpError(400, "TAG_007: Recipe is no longer available"); + } + + // Verifier que c'est le owner + if (suggestion.recipe.creatorId !== authenticatedUserId) { + throw createHttpError(403, "RECIPE_002: Only the recipe owner can reject suggestions"); + } + + // Verifier statut + if (suggestion.status !== "PENDING_OWNER") { + throw createHttpError(400, "TAG_007: Suggestion already decided"); + } + + const result = await rejectTagSuggestionService( + id, + authenticatedUserId, + suggestion.recipe.communityId, + suggestion.recipeId + ); + + appEvents.emitActivity({ + type: "TAG_SUGGESTION_REJECTED", + userId: authenticatedUserId, + communityId: suggestion.recipe.communityId, + recipeId: suggestion.recipeId, + targetUserIds: [suggestion.suggestedById], + metadata: { suggestionId: id, tagName: suggestion.tagName }, + }); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 3747557e..8252540f 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -3,6 +3,7 @@ import * as RecipesController from "../controllers/recipes"; import * as RecipeVariantsController from "../controllers/recipeVariants"; import * as RecipeShareController from "../controllers/recipeShare"; import * as ProposalsController from "../controllers/proposals"; +import * as TagSuggestionsController from "../controllers/tagSuggestions"; const router = express.Router(); @@ -24,6 +25,11 @@ router.get("/:recipeId/proposals", ProposalsController.getProposals); router.post("/:recipeId/proposals", ProposalsController.createProposal); +// Tag suggestions routes on recipes +router.get("/:recipeId/tag-suggestions", TagSuggestionsController.getTagSuggestions); + +router.post("/:recipeId/tag-suggestions", TagSuggestionsController.createTagSuggestion); + // Share recipe to another community (fork) router.post("/:recipeId/share", RecipeShareController.shareRecipe); diff --git a/backend/src/routes/tagSuggestions.ts b/backend/src/routes/tagSuggestions.ts new file mode 100644 index 00000000..55667d1d --- /dev/null +++ b/backend/src/routes/tagSuggestions.ts @@ -0,0 +1,12 @@ +import express from "express"; +import * as TagSuggestionsController from "../controllers/tagSuggestions"; + +const router = express.Router(); + +// POST /api/tag-suggestions/:id/accept +router.post("/:id/accept", TagSuggestionsController.acceptTagSuggestion); + +// POST /api/tag-suggestions/:id/reject +router.post("/:id/reject", TagSuggestionsController.rejectTagSuggestion); + +export default router; diff --git a/backend/src/services/tagSuggestionService.ts b/backend/src/services/tagSuggestionService.ts new file mode 100644 index 00000000..889e67b8 --- /dev/null +++ b/backend/src/services/tagSuggestionService.ts @@ -0,0 +1,182 @@ +import { PrismaClient } from "@prisma/client"; +import prisma from "../util/db"; +import { normalizeNames } from "../util/validation"; +import { resolveTagsForRecipe } from "./tagService"; + +type TransactionClient = Omit< + PrismaClient, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends" +>; + +/** + * Cree une TagSuggestion avec status PENDING_OWNER. + */ +export async function createTagSuggestion( + tx: TransactionClient, + recipeId: string, + tagName: string, + suggestedById: string +) { + const [normalized] = normalizeNames([tagName]); + + return tx.tagSuggestion.create({ + data: { + recipeId, + tagName: normalized, + suggestedById, + status: "PENDING_OWNER", + }, + select: { + id: true, + recipeId: true, + tagName: true, + status: true, + createdAt: true, + suggestedBy: { + select: { id: true, username: true }, + }, + }, + }); +} + +interface SuggestionWithRecipe { + id: string; + tagName: string; + recipeId: string; + suggestedById: string; + recipe: { + id: string; + communityId: string | null; + creatorId: string; + }; +} + +/** + * Accepte une suggestion : resout le tag et cree le RecipeTag. + * Si tag inconnu -> PENDING_MODERATOR, sinon -> APPROVED. + */ +export async function acceptTagSuggestion( + suggestionId: string, + suggestion: SuggestionWithRecipe, + ownerId: string +) { + return prisma.$transaction(async (tx) => { + const now = new Date(); + const communityId = suggestion.recipe.communityId; + + // Chercher un tag existant (GLOBAL APPROVED ou COMMUNITY APPROVED) + let existingTag = await tx.tag.findFirst({ + where: { + name: suggestion.tagName, + scope: "GLOBAL", + status: "APPROVED", + communityId: null, + }, + }); + + if (!existingTag && communityId) { + existingTag = await tx.tag.findFirst({ + where: { + name: suggestion.tagName, + scope: "COMMUNITY", + status: "APPROVED", + communityId, + }, + }); + } + + let finalStatus: "APPROVED" | "PENDING_MODERATOR"; + + if (existingTag) { + // Tag existe -> lier directement + await tx.recipeTag.create({ + data: { recipeId: suggestion.recipeId, tagId: existingTag.id }, + }); + finalStatus = "APPROVED"; + } else { + // Tag inconnu -> resolveTagsForRecipe cree un tag PENDING + const { tagIds } = await resolveTagsForRecipe( + tx, + [suggestion.tagName], + suggestion.suggestedById, + communityId + ); + + // Creer le RecipeTag + await tx.recipeTag.create({ + data: { recipeId: suggestion.recipeId, tagId: tagIds[0] }, + }); + finalStatus = "PENDING_MODERATOR"; + } + + // Mettre a jour la suggestion + const updated = await tx.tagSuggestion.update({ + where: { id: suggestionId }, + data: { status: finalStatus, decidedAt: now }, + select: { + id: true, + recipeId: true, + tagName: true, + status: true, + createdAt: true, + decidedAt: true, + suggestedBy: { + select: { id: true, username: true }, + }, + }, + }); + + // ActivityLog + await tx.activityLog.create({ + data: { + type: "TAG_SUGGESTION_ACCEPTED", + userId: ownerId, + communityId, + recipeId: suggestion.recipeId, + metadata: { suggestionId, tagName: suggestion.tagName, finalStatus }, + }, + }); + + return updated; + }); +} + +/** + * Rejette une suggestion. + */ +export async function rejectTagSuggestion( + suggestionId: string, + ownerId: string, + communityId: string | null, + recipeId: string +) { + const now = new Date(); + + const updated = await prisma.tagSuggestion.update({ + where: { id: suggestionId }, + data: { status: "REJECTED", decidedAt: now }, + select: { + id: true, + recipeId: true, + tagName: true, + status: true, + createdAt: true, + decidedAt: true, + suggestedBy: { + select: { id: true, username: true }, + }, + }, + }); + + await prisma.activityLog.create({ + data: { + type: "TAG_SUGGESTION_REJECTED", + userId: ownerId, + communityId, + recipeId, + metadata: { suggestionId }, + }, + }); + + return updated; +} diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index f076fe40..57681f4e 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -36,11 +36,12 @@ ## 10.4 - Backend TagSuggestion -- [ ] Endpoint POST tag-suggestion (suggerer tag sur recette d'autrui) -- [ ] Endpoint accept/reject par owner -- [ ] Workflow 2 etapes : owner → moderateur (si tag inconnu) -- [ ] Auto-rejet suggestions sur recettes orphelines -- [ ] Tests +- [x] Endpoint POST tag-suggestion (suggerer tag sur recette d'autrui) +- [x] Endpoint accept/reject par owner +- [x] Workflow 2 etapes : owner → moderateur (si tag inconnu) +- [x] Auto-rejet suggestions sur recettes orphelines +- [x] Cascade moderateur approve/reject → TagSuggestion PENDING_MODERATOR +- [x] Tests (29 tests) ## 10.5 - Backend Preferences & Notifications From 7d9aefaab251d1132f6c1d05b7b39479beee4205 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 08:45:18 +0100 Subject: [PATCH 007/221] feat: user tag preferences & moderator notifications (Phase 10.5) Add 5 preference endpoints (tag visibility, notification settings), WebSocket events for tag lifecycle (pending/approved/rejected), and moderator notification filtering by preferences. 25 new tests, 485 total. --- .claude/context/API_MAP.md | 9 +- .claude/context/FILE_MAP.md | 7 +- .claude/context/PROGRESS.md | 4 +- .../integration/tagPreferences.test.ts | 471 ++++++++++++++++++ backend/src/controllers/communityRecipes.ts | 16 + backend/src/controllers/communityTags.ts | 24 + backend/src/controllers/recipeShare.ts | 50 +- backend/src/controllers/recipes.ts | 19 +- backend/src/controllers/tagPreferences.ts | 250 ++++++++++ backend/src/controllers/tagSuggestions.ts | 18 + backend/src/routes/users.ts | 10 + .../src/services/communityRecipeService.ts | 8 +- backend/src/services/notificationService.ts | 55 ++ backend/src/services/recipeService.ts | 14 +- backend/src/services/shareService.ts | 8 +- backend/src/services/tagService.ts | 7 +- docs/features/tags-rework/ROADMAP.md | 12 +- 17 files changed, 943 insertions(+), 39 deletions(-) create mode 100644 backend/src/__tests__/integration/tagPreferences.test.ts create mode 100644 backend/src/controllers/tagPreferences.ts create mode 100644 backend/src/services/notificationService.ts diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 1e36be15..958b656e 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -97,8 +97,13 @@ GET /api/users/search # search by username prefix (?q=) PATCH /api/users/me # update profile (username, email, password) GET /api/users/me/invites # received invitations (?status=) GET /api/users/me/activity # personal activity feed (paginated) +GET /api/users/me/tag-preferences # tag visibility prefs per community +PUT /api/users/me/tag-preferences/:communityId # toggle showTags (member) +GET /api/users/me/notification-preferences # moderator notification prefs +PUT /api/users/me/notification-preferences/tags # toggle global tagNotifications (moderator) +PUT /api/users/me/notification-preferences/tags/:communityId # toggle per community (moderator) ``` -Controller: `controllers/users.ts`, `controllers/invites.ts`, `controllers/activity.ts` | Route: `routes/users.ts` +Controller: `controllers/users.ts`, `controllers/invites.ts`, `controllers/activity.ts`, `controllers/tagPreferences.ts` | Route: `routes/users.ts` ## User Invitations ``` @@ -208,4 +213,4 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti | adminRateLimiter | middleware/security.ts | 30 req/min global admin | | authRateLimiter | routes config | 5/15min sur auth endpoints | -## Total: 75 endpoints (48 user + 27 admin + 1 health) +## Total: 80 endpoints (53 user + 27 admin + 1 health) diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index e4214b6f..65b7ae41 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -16,6 +16,8 @@ controllers/ ├── recipes.ts # CRUD recettes personnelles (get, create, update, delete) ├── recipeVariants.ts # getVariants (liste variantes d'une recette) ├── recipeShare.ts # shareRecipe, publishToCommunities, getRecipeCommunities +├── tagPreferences.ts # tag visibility & moderator notification prefs (5 handlers) +├── tagSuggestions.ts # create, accept, reject tag suggestions ├── tags.ts # autocomplete tags (scope-aware) ├── ingredients.ts # autocomplete ingredients └── users.ts # search users, update profile @@ -29,9 +31,10 @@ routes/ ├── invites.ts # /api/invites/:id/accept|reject ├── proposals.ts # /api/proposals/:id, /api/proposals/:id/accept|reject ├── recipes.ts # /api/recipes/* (incl. /api/recipes/:id/proposals) +├── tagSuggestions.ts # /api/tag-suggestions/* ├── tags.ts # /api/tags ├── ingredients.ts # /api/ingredients -└── users.ts # /api/users/search, /api/users/me, /api/users/me/invites +└── users.ts # /api/users/* (incl. tag-preferences, notification-preferences) ``` ### Middleware @@ -76,6 +79,8 @@ services/ ├── shareService.ts # forkRecipe, publishRecipe, getRecipeFamilyCommunities ├── membershipService.ts # requireRecipeAccess, requireRecipeOwnership ├── orphanHandling.ts # Gestion recettes orphelines (auto-reject proposals) +├── notificationService.ts # getModeratorIdsForTagNotification (filtre par prefs) +├── tagSuggestionService.ts # create, accept, reject tag suggestions ├── eventEmitter.ts # AppEventEmitter singleton (emit activity events) └── socketServer.ts # Socket.IO server init, auth middleware, room management ``` diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 3ee82f71..04cf5613 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.4 termine, prochaine etape 10.5 (Preferences & Notifications) +- **Sous-etape en cours** : 10.5 termine, prochaine etape 10.6 (Frontend Tags refactoring) - **Branche** : `TagsRework` -- **Tests** : 733 (273 frontend + 460 backend) +- **Tests** : 758 (273 frontend + 485 backend) ## Prochains chantiers (a specifier) diff --git a/backend/src/__tests__/integration/tagPreferences.test.ts b/backend/src/__tests__/integration/tagPreferences.test.ts new file mode 100644 index 00000000..135fe25c --- /dev/null +++ b/backend/src/__tests__/integration/tagPreferences.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +const uniqueSuffix = () => + `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + +describe("Tag Preferences API", () => { + let moderator: { id: string }; + let moderatorCookie: string; + let member: { id: string }; + let memberCookie: string; + let community: { id: string }; + let community2: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Create moderator (creates community) + const modSignup = await request(app).post("/api/auth/signup").send({ + username: `tpmod_${suffix}`, + email: `tpmod_${suffix}@example.com`, + password: "Test123!Password", + }); + moderatorCookie = extractSessionCookie(modSignup)!; + moderator = (await testPrisma.user.findFirst({ + where: { email: `tpmod_${suffix}@example.com` }, + }))!; + + // Create community + const createRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `TagPref Community ${suffix}` }); + community = createRes.body; + + // Create second community + const createRes2 = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `TagPref Community2 ${suffix}` }); + community2 = createRes2.body; + + // Create member + const memberSignup = await request(app).post("/api/auth/signup").send({ + username: `tpmem_${suffix}`, + email: `tpmem_${suffix}@example.com`, + password: "Test123!Password", + }); + memberCookie = extractSessionCookie(memberSignup)!; + member = (await testPrisma.user.findFirst({ + where: { email: `tpmem_${suffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId: community.id, role: "MEMBER" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId: community2.id, role: "MEMBER" }, + }); + }); + + // ===================================== + // GET /api/users/me/tag-preferences + // ===================================== + describe("GET /api/users/me/tag-preferences", () => { + it("should return tag preferences for all communities (default showTags=true)", async () => { + const res = await request(app) + .get("/api/users/me/tag-preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Array); + expect(res.body.data.length).toBe(2); + expect(res.body.data[0]).toHaveProperty("communityId"); + expect(res.body.data[0]).toHaveProperty("communityName"); + expect(res.body.data[0]).toHaveProperty("showTags"); + // Default is true + res.body.data.forEach((pref: { showTags: boolean }) => { + expect(pref.showTags).toBe(true); + }); + }); + + it("should reflect updated preferences", async () => { + // Set one to false + await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: false }); + + const res = await request(app) + .get("/api/users/me/tag-preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + const pref = res.body.data.find( + (p: { communityId: string }) => p.communityId === community.id + ); + expect(pref.showTags).toBe(false); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).get("/api/users/me/tag-preferences"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // PUT /api/users/me/tag-preferences/:communityId + // ===================================== + describe("PUT /api/users/me/tag-preferences/:communityId", () => { + it("should toggle showTags to false", async () => { + const res = await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: false }); + + expect(res.status).toBe(200); + expect(res.body.communityId).toBe(community.id); + expect(res.body.showTags).toBe(false); + }); + + it("should toggle showTags back to true", async () => { + await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: false }); + + const res = await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: true }); + + expect(res.status).toBe(200); + expect(res.body.showTags).toBe(true); + }); + + it("should return 400 if showTags is not a boolean", async () => { + const res = await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: "yes" }); + + expect(res.status).toBe(400); + }); + + it("should return 403 if not a member", async () => { + const suffix = uniqueSuffix(); + const outsiderSignup = await request(app).post("/api/auth/signup").send({ + username: `tpout_${suffix}`, + email: `tpout_${suffix}@example.com`, + password: "Test123!Password", + }); + const outsiderCookie = extractSessionCookie(outsiderSignup)!; + + const res = await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", outsiderCookie) + .send({ showTags: false }); + + expect(res.status).toBe(403); + }); + + it("should create preference on first toggle (upsert)", async () => { + // No preference exists yet, first PUT should create it + const res = await request(app) + .put(`/api/users/me/tag-preferences/${community.id}`) + .set("Cookie", memberCookie) + .send({ showTags: false }); + + expect(res.status).toBe(200); + expect(res.body.showTags).toBe(false); + + // Verify in DB + const pref = await testPrisma.userCommunityTagPreference.findUnique({ + where: { + userId_communityId: { userId: member.id, communityId: community.id }, + }, + }); + expect(pref).toBeTruthy(); + expect(pref!.showTags).toBe(false); + }); + }); + + // ===================================== + // GET /api/users/me/notification-preferences + // ===================================== + describe("GET /api/users/me/notification-preferences", () => { + it("should return notification preferences for moderator", async () => { + const res = await request(app) + .get("/api/users/me/notification-preferences") + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.global).toHaveProperty("tagNotifications"); + expect(res.body.global.tagNotifications).toBe(true); // default + expect(res.body.communities).toBeInstanceOf(Array); + expect(res.body.communities.length).toBe(2); // 2 communities + }); + + it("should return empty communities for non-moderator", async () => { + const res = await request(app) + .get("/api/users/me/notification-preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.communities).toEqual([]); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).get( + "/api/users/me/notification-preferences" + ); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // PUT /api/users/me/notification-preferences/tags + // ===================================== + describe("PUT /api/users/me/notification-preferences/tags", () => { + it("should toggle global tagNotifications", async () => { + const res = await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: false }); + + expect(res.status).toBe(200); + expect(res.body.tagNotifications).toBe(false); + }); + + it("should toggle back to true", async () => { + await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: false }); + + const res = await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: true }); + + expect(res.status).toBe(200); + expect(res.body.tagNotifications).toBe(true); + }); + + it("should return 400 if tagNotifications is not a boolean", async () => { + const res = await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: "yes" }); + + expect(res.status).toBe(400); + }); + + it("should return 403 for non-moderator", async () => { + const res = await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", memberCookie) + .send({ tagNotifications: false }); + + expect(res.status).toBe(403); + }); + + it("should reflect in GET after update", async () => { + await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: false }); + + const res = await request(app) + .get("/api/users/me/notification-preferences") + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.global.tagNotifications).toBe(false); + }); + }); + + // ===================================== + // PUT /api/users/me/notification-preferences/tags/:communityId + // ===================================== + describe("PUT /api/users/me/notification-preferences/tags/:communityId", () => { + it("should toggle tagNotifications per community", async () => { + const res = await request(app) + .put( + `/api/users/me/notification-preferences/tags/${community.id}` + ) + .set("Cookie", moderatorCookie) + .send({ tagNotifications: false }); + + expect(res.status).toBe(200); + expect(res.body.communityId).toBe(community.id); + expect(res.body.tagNotifications).toBe(false); + }); + + it("should return 400 if tagNotifications is not a boolean", async () => { + const res = await request(app) + .put( + `/api/users/me/notification-preferences/tags/${community.id}` + ) + .set("Cookie", moderatorCookie) + .send({ tagNotifications: 123 }); + + expect(res.status).toBe(400); + }); + + it("should return 403 for non-moderator of that community", async () => { + const res = await request(app) + .put( + `/api/users/me/notification-preferences/tags/${community.id}` + ) + .set("Cookie", memberCookie) + .send({ tagNotifications: false }); + + expect(res.status).toBe(403); + }); + + it("should return 403 for non-member", async () => { + const suffix = uniqueSuffix(); + const outsiderSignup = await request(app).post("/api/auth/signup").send({ + username: `tpout2_${suffix}`, + email: `tpout2_${suffix}@example.com`, + password: "Test123!Password", + }); + const outsiderCookie = extractSessionCookie(outsiderSignup)!; + + const res = await request(app) + .put( + `/api/users/me/notification-preferences/tags/${community.id}` + ) + .set("Cookie", outsiderCookie) + .send({ tagNotifications: false }); + + expect(res.status).toBe(403); + }); + + it("community preference should override global in GET response", async () => { + // Set global to true, community to false + await request(app) + .put("/api/users/me/notification-preferences/tags") + .set("Cookie", moderatorCookie) + .send({ tagNotifications: true }); + + await request(app) + .put( + `/api/users/me/notification-preferences/tags/${community.id}` + ) + .set("Cookie", moderatorCookie) + .send({ tagNotifications: false }); + + const res = await request(app) + .get("/api/users/me/notification-preferences") + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.global.tagNotifications).toBe(true); + + const comm = res.body.communities.find( + (c: { communityId: string }) => c.communityId === community.id + ); + expect(comm.tagNotifications).toBe(false); + + // community2 should inherit global (true) + const comm2 = res.body.communities.find( + (c: { communityId: string }) => c.communityId === community2.id + ); + expect(comm2.tagNotifications).toBe(true); + }); + }); +}); + +describe("Notification Service - getModeratorIdsForTagNotification", () => { + let moderator1: { id: string }; + let moderator2: { id: string }; + let community: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Create moderator1 + const mod1Signup = await request(app).post("/api/auth/signup").send({ + username: `nsmod1_${suffix}`, + email: `nsmod1_${suffix}@example.com`, + password: "Test123!Password", + }); + extractSessionCookie(mod1Signup); + moderator1 = (await testPrisma.user.findFirst({ + where: { email: `nsmod1_${suffix}@example.com` }, + }))!; + + // Create moderator2 + const mod2Signup = await request(app).post("/api/auth/signup").send({ + username: `nsmod2_${suffix}`, + email: `nsmod2_${suffix}@example.com`, + password: "Test123!Password", + }); + extractSessionCookie(mod2Signup); + moderator2 = (await testPrisma.user.findFirst({ + where: { email: `nsmod2_${suffix}@example.com` }, + }))!; + + // Create community with both as moderators + community = await testPrisma.community.create({ + data: { name: `NotifSvc Community ${suffix}` }, + }); + await testPrisma.userCommunity.create({ + data: { userId: moderator1.id, communityId: community.id, role: "MODERATOR" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: moderator2.id, communityId: community.id, role: "MODERATOR" }, + }); + }); + + it("should return all moderators by default (no preferences set)", async () => { + const { getModeratorIdsForTagNotification } = await import( + "../../services/notificationService" + ); + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).toContain(moderator1.id); + expect(ids).toContain(moderator2.id); + }); + + it("should exclude moderator with global notifications disabled", async () => { + await testPrisma.moderatorNotificationPreference.create({ + data: { userId: moderator1.id, communityId: null, tagNotifications: false }, + }); + + const { getModeratorIdsForTagNotification } = await import( + "../../services/notificationService" + ); + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).not.toContain(moderator1.id); + expect(ids).toContain(moderator2.id); + }); + + it("should respect community preference over global", async () => { + // Global disabled but community enabled + await testPrisma.moderatorNotificationPreference.create({ + data: { userId: moderator1.id, communityId: null, tagNotifications: false }, + }); + await testPrisma.moderatorNotificationPreference.create({ + data: { userId: moderator1.id, communityId: community.id, tagNotifications: true }, + }); + + const { getModeratorIdsForTagNotification } = await import( + "../../services/notificationService" + ); + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).toContain(moderator1.id); + expect(ids).toContain(moderator2.id); + }); + + it("should exclude moderator with community notifications disabled", async () => { + // Global enabled but community disabled + await testPrisma.moderatorNotificationPreference.create({ + data: { userId: moderator1.id, communityId: null, tagNotifications: true }, + }); + await testPrisma.moderatorNotificationPreference.create({ + data: { userId: moderator1.id, communityId: community.id, tagNotifications: false }, + }); + + const { getModeratorIdsForTagNotification } = await import( + "../../services/notificationService" + ); + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).not.toContain(moderator1.id); + expect(ids).toContain(moderator2.id); + }); +}); diff --git a/backend/src/controllers/communityRecipes.ts b/backend/src/controllers/communityRecipes.ts index 42c03d2d..6b6b9d9a 100644 --- a/backend/src/controllers/communityRecipes.ts +++ b/backend/src/controllers/communityRecipes.ts @@ -9,6 +9,7 @@ import { RECIPE_TAGS_SELECT } from "../util/prismaSelects"; import { formatTags, formatIngredients } from "../util/responseFormatters"; import { createCommunityRecipe as createCommunityRecipeService } from "../services/communityRecipeService"; import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; interface IngredientInput { name: string; @@ -77,6 +78,21 @@ export const createCommunityRecipe: RequestHandler< recipeId: result.community.id, }); + // Notifier les moderateurs si des tags PENDING ont ete crees + if (result.pendingTagIds.length > 0) { + const moderatorIds = await getModeratorIdsForTagNotification(communityId); + if (moderatorIds.length > 0) { + appEvents.emitActivity({ + type: "tag:pending", + userId: authenticatedUserId, + communityId, + recipeId: result.community.id, + targetUserIds: moderatorIds, + metadata: { pendingTagIds: result.pendingTagIds }, + }); + } + } + res.status(201).json({ personal: formatRecipe(result.personal), community: formatRecipe(result.community), diff --git a/backend/src/controllers/communityTags.ts b/backend/src/controllers/communityTags.ts index c28da492..5a2ebe81 100644 --- a/backend/src/controllers/communityTags.ts +++ b/backend/src/controllers/communityTags.ts @@ -3,6 +3,8 @@ import createHttpError from "http-errors"; import prisma from "../util/db"; import { assertIsDefine } from "../util/assertIsDefine"; import { parsePagination } from "../util/pagination"; +import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; /** * GET /api/communities/:communityId/tags @@ -302,6 +304,17 @@ export const approveCommunityTag: RequestHandler = async (req, res, next) => { }, }); + // Notifier le createur du tag + if (tag.createdById) { + appEvents.emitActivity({ + type: "tag:approved", + userId, + communityId, + targetUserIds: [tag.createdById], + metadata: { tagId, tagName: tag.name }, + }); + } + res.status(200).json({ id: updated.id, name: updated.name, @@ -361,6 +374,17 @@ export const rejectCommunityTag: RequestHandler = async (req, res, next) => { }, }); + // Notifier le createur du tag + if (tag.createdById) { + appEvents.emitActivity({ + type: "tag:rejected", + userId, + communityId, + targetUserIds: [tag.createdById], + metadata: { tagId, tagName: tag.name }, + }); + } + res.status(200).json({ message: "Tag rejected and removed" }); } catch (error) { next(error); diff --git a/backend/src/controllers/recipeShare.ts b/backend/src/controllers/recipeShare.ts index e66838a1..ba3e32ee 100644 --- a/backend/src/controllers/recipeShare.ts +++ b/backend/src/controllers/recipeShare.ts @@ -9,6 +9,7 @@ import { getRecipeFamilyCommunities, } from "../services/shareService"; import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; interface ShareRecipeBody { targetCommunityId: string; @@ -113,32 +114,32 @@ export const shareRecipe: RequestHandler< throw createHttpError(400, "SHARE_006: Recipe already shared with this community"); } - const result = await forkRecipe( + const { recipe: forkResult, pendingTagIds } = await forkRecipe( authenticatedUserId, { ...sourceRecipe, communityId: sourceRecipe.communityId }, targetCommunityId, targetCommunity.name ); - if (!result) { + if (!forkResult) { throw createHttpError(500, "Failed to share recipe"); } const responseData = { - id: result.id, - title: result.title, - content: result.content, - imageUrl: result.imageUrl, - createdAt: result.createdAt, - updatedAt: result.updatedAt, - creatorId: result.creatorId, - communityId: result.communityId, - community: result.community, - originRecipeId: result.originRecipeId, - sharedFromCommunityId: result.sharedFromCommunityId, - isVariant: result.isVariant, - tags: formatTags(result.tags), - ingredients: formatIngredients(result.ingredients), + id: forkResult.id, + title: forkResult.title, + content: forkResult.content, + imageUrl: forkResult.imageUrl, + createdAt: forkResult.createdAt, + updatedAt: forkResult.updatedAt, + creatorId: forkResult.creatorId, + communityId: forkResult.communityId, + community: forkResult.community, + originRecipeId: forkResult.originRecipeId, + sharedFromCommunityId: forkResult.sharedFromCommunityId, + isVariant: forkResult.isVariant, + tags: formatTags(forkResult.tags), + ingredients: formatIngredients(forkResult.ingredients), }; // Emit to both source and target communities @@ -152,9 +153,24 @@ export const shareRecipe: RequestHandler< type: "RECIPE_SHARED", userId: authenticatedUserId, communityId: targetCommunityId, - recipeId: result.id, + recipeId: forkResult.id, }); + // Notifier les moderateurs si des tags PENDING ont ete crees + if (pendingTagIds.length > 0) { + const moderatorIds = await getModeratorIdsForTagNotification(targetCommunityId); + if (moderatorIds.length > 0) { + appEvents.emitActivity({ + type: "tag:pending", + userId: authenticatedUserId, + communityId: targetCommunityId, + recipeId: forkResult.id, + targetUserIds: moderatorIds, + metadata: { pendingTagIds }, + }); + } + } + res.status(201).json(responseData); } catch (error) { next(error); diff --git a/backend/src/controllers/recipes.ts b/backend/src/controllers/recipes.ts index f9012f51..88bdcbee 100644 --- a/backend/src/controllers/recipes.ts +++ b/backend/src/controllers/recipes.ts @@ -9,6 +9,8 @@ import { RECIPE_TAGS_SELECT } from "../util/prismaSelects"; import { requireRecipeAccess, requireRecipeOwnership } from "../services/membershipService"; import { formatTags, formatIngredients } from "../util/responseFormatters"; import { createRecipe as createRecipeService, updateRecipe as updateRecipeService } from "../services/recipeService"; +import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; interface GetRecipesQuery { limit?: string; @@ -302,7 +304,7 @@ export const updateRecipe: RequestHandler 0 && recipe.communityId) { + const moderatorIds = await getModeratorIdsForTagNotification(recipe.communityId); + if (moderatorIds.length > 0) { + appEvents.emitActivity({ + type: "tag:pending", + userId: authenticatedUserId, + communityId: recipe.communityId, + recipeId, + targetUserIds: moderatorIds, + metadata: { pendingTagIds }, + }); + } + } + const responseData = { id: updatedRecipe.id, title: updatedRecipe.title, diff --git a/backend/src/controllers/tagPreferences.ts b/backend/src/controllers/tagPreferences.ts new file mode 100644 index 00000000..596007b4 --- /dev/null +++ b/backend/src/controllers/tagPreferences.ts @@ -0,0 +1,250 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import createHttpError from "http-errors"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { requireMembership } from "../services/membershipService"; + +// ============================================================================= +// TAG VISIBILITY PREFERENCES (UserCommunityTagPreference) +// ============================================================================= + +/** + * GET /api/users/me/tag-preferences + * Liste les preferences showTags pour toutes les communautes de l'utilisateur + */ +export const getTagPreferences: RequestHandler = async (req, res, next) => { + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + // Recuperer toutes les communautes dont l'utilisateur est membre + const memberships = await prisma.userCommunity.findMany({ + where: { userId, deletedAt: null }, + select: { + communityId: true, + community: { select: { id: true, name: true } }, + }, + }); + + // Recuperer les preferences existantes + const prefs = await prisma.userCommunityTagPreference.findMany({ + where: { userId }, + }); + + const prefMap = new Map(prefs.map((p) => [p.communityId, p.showTags])); + + // Construire la reponse : une entree par communaute (defaut showTags=true) + const data = memberships.map((m) => ({ + communityId: m.communityId, + communityName: m.community.name, + showTags: prefMap.get(m.communityId) ?? true, + })); + + res.status(200).json({ data }); + } catch (error) { + next(error); + } +}; + +/** + * PUT /api/users/me/tag-preferences/:communityId + * Active/desactive l'affichage des tags communautaires + */ +export const updateTagPreference: RequestHandler< + { communityId: string }, + unknown, + { showTags?: boolean }, + unknown +> = async (req, res, next) => { + const userId = req.session.userId; + const { communityId } = req.params; + const { showTags } = req.body; + + try { + assertIsDefine(userId); + + if (typeof showTags !== "boolean") { + throw createHttpError(400, "TAG_001: showTags must be a boolean"); + } + + // Verifier membership + await requireMembership(userId, communityId); + + const pref = await prisma.userCommunityTagPreference.upsert({ + where: { userId_communityId: { userId, communityId } }, + update: { showTags }, + create: { userId, communityId, showTags }, + }); + + res.status(200).json({ + communityId: pref.communityId, + showTags: pref.showTags, + }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// MODERATOR NOTIFICATION PREFERENCES (ModeratorNotificationPreference) +// ============================================================================= + +/** + * GET /api/users/me/notification-preferences + * Liste les preferences de notification du moderateur + */ +export const getNotificationPreferences: RequestHandler = async ( + req, + res, + next +) => { + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + // Recuperer les communautes ou l'utilisateur est moderateur + const moderatorships = await prisma.userCommunity.findMany({ + where: { userId, role: "MODERATOR", deletedAt: null }, + select: { + communityId: true, + community: { select: { id: true, name: true } }, + }, + }); + + // Recuperer toutes les prefs + const prefs = await prisma.moderatorNotificationPreference.findMany({ + where: { userId }, + }); + + // Pref globale (communityId = null) + const globalPref = prefs.find((p) => p.communityId === null); + const communityPrefs = prefs.filter((p) => p.communityId !== null); + const communityPrefMap = new Map( + communityPrefs.map((p) => [p.communityId, p.tagNotifications]) + ); + + const data = { + global: { + tagNotifications: globalPref?.tagNotifications ?? true, + }, + communities: moderatorships.map((m) => ({ + communityId: m.communityId, + communityName: m.community.name, + tagNotifications: communityPrefMap.get(m.communityId) ?? globalPref?.tagNotifications ?? true, + })), + }; + + res.status(200).json(data); + } catch (error) { + next(error); + } +}; + +/** + * PUT /api/users/me/notification-preferences/tags + * Toggle global tagNotifications + */ +export const updateGlobalNotificationPreference: RequestHandler< + unknown, + unknown, + { tagNotifications?: boolean }, + unknown +> = async (req, res, next) => { + const userId = req.session.userId; + const { tagNotifications } = req.body; + + try { + assertIsDefine(userId); + + if (typeof tagNotifications !== "boolean") { + throw createHttpError( + 400, + "TAG_001: tagNotifications must be a boolean" + ); + } + + // Verifier que l'utilisateur est moderateur d'au moins une communaute + const modCount = await prisma.userCommunity.count({ + where: { userId, role: "MODERATOR", deletedAt: null }, + }); + if (modCount === 0) { + throw createHttpError( + 403, + "TAG_005: Only moderators can manage notification preferences" + ); + } + + // Chercher la pref globale (communityId = null) + const existing = await prisma.moderatorNotificationPreference.findFirst({ + where: { userId, communityId: null }, + }); + + let pref; + if (existing) { + pref = await prisma.moderatorNotificationPreference.update({ + where: { id: existing.id }, + data: { tagNotifications }, + }); + } else { + pref = await prisma.moderatorNotificationPreference.create({ + data: { userId, communityId: null, tagNotifications }, + }); + } + + res.status(200).json({ + tagNotifications: pref.tagNotifications, + }); + } catch (error) { + next(error); + } +}; + +/** + * PUT /api/users/me/notification-preferences/tags/:communityId + * Toggle par communaute + */ +export const updateCommunityNotificationPreference: RequestHandler< + { communityId: string }, + unknown, + { tagNotifications?: boolean }, + unknown +> = async (req, res, next) => { + const userId = req.session.userId; + const { communityId } = req.params; + const { tagNotifications } = req.body; + + try { + assertIsDefine(userId); + + if (typeof tagNotifications !== "boolean") { + throw createHttpError( + 400, + "TAG_001: tagNotifications must be a boolean" + ); + } + + // Verifier que l'utilisateur est moderateur de cette communaute + const membership = await requireMembership(userId, communityId); + if (membership.role !== "MODERATOR") { + throw createHttpError( + 403, + "TAG_005: Only moderators can manage notification preferences" + ); + } + + const pref = await prisma.moderatorNotificationPreference.upsert({ + where: { userId_communityId: { userId, communityId } }, + update: { tagNotifications }, + create: { userId, communityId, tagNotifications }, + }); + + res.status(200).json({ + communityId: pref.communityId, + tagNotifications: pref.tagNotifications, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/tagSuggestions.ts b/backend/src/controllers/tagSuggestions.ts index a2b925a5..9c84b548 100644 --- a/backend/src/controllers/tagSuggestions.ts +++ b/backend/src/controllers/tagSuggestions.ts @@ -11,6 +11,7 @@ import { rejectTagSuggestion as rejectTagSuggestionService, } from "../services/tagSuggestionService"; import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; const MAX_TAGS_PER_RECIPE = 10; @@ -248,6 +249,23 @@ export const acceptTagSuggestion: RequestHandler< metadata: { suggestionId: id, tagName: suggestion.tagName }, }); + // Si la suggestion est passee en PENDING_MODERATOR, notifier les moderateurs + if (result.status === "PENDING_MODERATOR" && suggestion.recipe.communityId) { + const moderatorIds = await getModeratorIdsForTagNotification( + suggestion.recipe.communityId + ); + if (moderatorIds.length > 0) { + appEvents.emitActivity({ + type: "tag-suggestion:pending-mod", + userId: authenticatedUserId, + communityId: suggestion.recipe.communityId, + recipeId: suggestion.recipeId, + targetUserIds: moderatorIds, + metadata: { suggestionId: id, tagName: suggestion.tagName }, + }); + } + } + res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 21e3a1da..25af3ef6 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -2,6 +2,7 @@ import express from "express"; import * as InvitesController from "../controllers/invites"; import * as UsersController from "../controllers/users"; import * as ActivityController from "../controllers/activity"; +import * as TagPreferencesController from "../controllers/tagPreferences"; const router = express.Router(); @@ -17,4 +18,13 @@ router.get("/me/invites", InvitesController.getMyInvites); // Get my activity feed router.get("/me/activity", ActivityController.getMyActivity); +// Tag visibility preferences +router.get("/me/tag-preferences", TagPreferencesController.getTagPreferences); +router.put("/me/tag-preferences/:communityId", TagPreferencesController.updateTagPreference); + +// Moderator notification preferences +router.get("/me/notification-preferences", TagPreferencesController.getNotificationPreferences); +router.put("/me/notification-preferences/tags", TagPreferencesController.updateGlobalNotificationPreference); +router.put("/me/notification-preferences/tags/:communityId", TagPreferencesController.updateCommunityNotificationPreference); + export default router; diff --git a/backend/src/services/communityRecipeService.ts b/backend/src/services/communityRecipeService.ts index be2d9f08..d5e3617f 100644 --- a/backend/src/services/communityRecipeService.ts +++ b/backend/src/services/communityRecipeService.ts @@ -33,7 +33,9 @@ export async function createCommunityRecipe( communityId: string, data: CreateCommunityRecipeData ) { - return prisma.$transaction(async (tx) => { + let pendingTagIds: string[] = []; + + const result = await prisma.$transaction(async (tx) => { // 1. Creer la recette personnelle (communityId: null) const personalRecipe = await tx.recipe.create({ data: { @@ -61,7 +63,7 @@ export async function createCommunityRecipe( // IMPORTANT: traiter la recette communautaire EN PREMIER pour que les tags // inconnus deviennent COMMUNITY PENDING (et non GLOBAL APPROVED via le perso) if (data.tags.length > 0) { - await upsertTags(tx, communityRecipe.id, data.tags, userId, communityId); + pendingTagIds = await upsertTags(tx, communityRecipe.id, data.tags, userId, communityId); await upsertTags(tx, personalRecipe.id, data.tags, userId, null); } @@ -94,4 +96,6 @@ export async function createCommunityRecipe( return { personal, community }; }); + + return { ...result, pendingTagIds }; } diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts new file mode 100644 index 00000000..305645da --- /dev/null +++ b/backend/src/services/notificationService.ts @@ -0,0 +1,55 @@ +import prisma from "../util/db"; + +/** + * Retourne les IDs des moderateurs d'une communaute qui ont les notifications tags activees. + * Filtre par ModeratorNotificationPreference (global puis par communaute). + */ +export async function getModeratorIdsForTagNotification( + communityId: string +): Promise { + // Recuperer tous les moderateurs de la communaute + const moderators = await prisma.userCommunity.findMany({ + where: { + communityId, + role: "MODERATOR", + deletedAt: null, + }, + select: { userId: true }, + }); + + if (moderators.length === 0) return []; + + const moderatorIds = moderators.map((m) => m.userId); + + // Recuperer les preferences de notification de ces moderateurs + const prefs = await prisma.moderatorNotificationPreference.findMany({ + where: { + userId: { in: moderatorIds }, + OR: [{ communityId: null }, { communityId }], + }, + }); + + // Construire un map userId -> { global: boolean, community: boolean | undefined } + const prefMap = new Map< + string, + { global: boolean; community?: boolean } + >(); + + for (const pref of prefs) { + const entry = prefMap.get(pref.userId) ?? { global: true }; + if (pref.communityId === null) { + entry.global = pref.tagNotifications; + } else { + entry.community = pref.tagNotifications; + } + prefMap.set(pref.userId, entry); + } + + // Filtrer : la preference communaute surcharge la globale + return moderatorIds.filter((userId) => { + const entry = prefMap.get(userId); + if (!entry) return true; // Pas de pref = defaut (true) + if (entry.community !== undefined) return entry.community; + return entry.global; + }); +} diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index 86b25a27..c8a2af27 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -21,14 +21,16 @@ export async function upsertTags( tags: string[], userId: string, communityId: string | null -) { - const { tagIds } = await resolveTagsForRecipe(tx, tags, userId, communityId); +): Promise { + const { tagIds, pendingTagIds } = await resolveTagsForRecipe(tx, tags, userId, communityId); for (const tagId of tagIds) { await tx.recipeTag.create({ data: { recipeId, tagId }, }); } + + return pendingTagIds; } export async function upsertIngredients( @@ -129,7 +131,9 @@ export async function updateRecipe( recipe: RecipeForSync, userId: string ) { - return prisma.$transaction(async (tx) => { + let pendingTagIds: string[] = []; + + const result = await prisma.$transaction(async (tx) => { // Mettre a jour les champs de base await tx.recipe.update({ where: { id: recipeId }, @@ -143,7 +147,7 @@ export async function updateRecipe( // Remplacer tags si fournis if (data.tags !== undefined) { await tx.recipeTag.deleteMany({ where: { recipeId } }); - await upsertTags(tx, recipeId, data.tags, userId, recipe.communityId); + pendingTagIds = await upsertTags(tx, recipeId, data.tags, userId, recipe.communityId); } // Remplacer ingredients si fournis @@ -160,6 +164,8 @@ export async function updateRecipe( select: RECIPE_RESULT_SELECT, }); }); + + return { result, pendingTagIds }; } /** diff --git a/backend/src/services/shareService.ts b/backend/src/services/shareService.ts index 0d7d9577..cd32cbb6 100644 --- a/backend/src/services/shareService.ts +++ b/backend/src/services/shareService.ts @@ -38,6 +38,7 @@ export async function forkRecipe( }); // Copier les tags (scope-aware) + let forkPendingTagIds: string[] = []; if (sourceRecipe.tags.length > 0) { const sourceTags = sourceRecipe.tags.map((rt) => ({ id: rt.tag.id, @@ -45,7 +46,8 @@ export async function forkRecipe( scope: rt.tag.scope, communityId: rt.tag.communityId, })); - const tagIds = await resolveTagsForFork(tx, sourceTags, targetCommunityId, userId); + const { tagIds, pendingTagIds } = await resolveTagsForFork(tx, sourceTags, targetCommunityId, userId); + forkPendingTagIds = pendingTagIds; if (tagIds.length > 0) { await tx.recipeTag.createMany({ data: tagIds.map((tagId) => ({ @@ -101,7 +103,7 @@ export async function forkRecipe( }); // Recuperer la recette forkee avec toutes ses relations - return tx.recipe.findUnique({ + const forkedResult = await tx.recipe.findUnique({ where: { id: forkedRecipe.id }, select: { id: true, @@ -120,6 +122,8 @@ export async function forkRecipe( ingredients: RECIPE_INGREDIENTS_SELECT, }, }); + + return { recipe: forkedResult, pendingTagIds: forkPendingTagIds }; }); } diff --git a/backend/src/services/tagService.ts b/backend/src/services/tagService.ts index 938479af..10bdb99f 100644 --- a/backend/src/services/tagService.ts +++ b/backend/src/services/tagService.ts @@ -242,8 +242,9 @@ export async function resolveTagsForFork( sourceTags: SourceTag[], targetCommunityId: string, userId: string -): Promise { +): Promise<{ tagIds: string[]; pendingTagIds: string[] }> { const tagIds: string[] = []; + const pendingTagIds: string[] = []; for (const sourceTag of sourceTags) { if (sourceTag.scope === "GLOBAL") { @@ -279,6 +280,7 @@ export async function resolveTagsForFork( if (targetTag) { tagIds.push(targetTag.id); + pendingTagIds.push(targetTag.id); continue; } @@ -294,7 +296,8 @@ export async function resolveTagsForFork( }); tagIds.push(newTag.id); + pendingTagIds.push(newTag.id); } - return tagIds; + return { tagIds, pendingTagIds }; } diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 57681f4e..b04c5d28 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -45,12 +45,12 @@ ## 10.5 - Backend Preferences & Notifications -- [ ] Endpoints UserCommunityTagPreference (GET/PUT) -- [ ] Endpoints ModeratorNotificationPreference (GET/PUT) -- [ ] Notifications WebSocket : tag:pending, tag:approved, tag:rejected -- [ ] Notifications WebSocket : tag-suggestion:new, approved, rejected -- [ ] Filtrage notifications selon preferences moderateur -- [ ] Tests +- [x] Endpoints UserCommunityTagPreference (GET/PUT) +- [x] Endpoints ModeratorNotificationPreference (GET/PUT global + par communaute) +- [x] Notifications WebSocket : tag:pending, tag:approved, tag:rejected +- [x] Notifications WebSocket : tag-suggestion:pending-mod +- [x] Filtrage notifications selon preferences moderateur (notificationService) +- [x] Tests (25 tests) ## 10.6 - Frontend Tags (refactoring) From 4805441f8d66fc745b6bf477d434a1703e49f1c6 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 08:54:41 +0100 Subject: [PATCH 008/221] feat: frontend scope-aware tags with TagBadge & pending styling (Phase 10.6) Add TagBadge component for visual distinction between approved and pending tags, wire scope-aware autocomplete through communityId prop, and show pending hints when creating unknown tags in community context. --- .claude/context/FILE_MAP.md | 8 +- .claude/context/PROGRESS.md | 4 +- docs/features/tags-rework/ROADMAP.md | 12 +-- .../unit/components/form/TagSelector.test.tsx | 31 +++++++ .../components/recipes/RecipeCard.test.tsx | 28 +++++- .../unit/components/recipes/TagBadge.test.tsx | 92 +++++++++++++++++++ .../communities/CommunityRecipesList.tsx | 1 + frontend/src/components/form/TagSelector.tsx | 22 ++++- .../src/components/recipes/RecipeCard.tsx | 10 +- .../src/components/recipes/RecipeFilters.tsx | 3 + .../src/components/recipes/RecipeListRow.tsx | 10 +- frontend/src/components/recipes/TagBadge.tsx | 30 ++++++ frontend/src/models/recipe.ts | 5 + frontend/src/network/api.ts | 4 +- frontend/src/pages/RecipeDetailPage.tsx | 10 +- frontend/src/pages/RecipeFormPage.tsx | 2 +- 16 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 frontend/src/__tests__/unit/components/recipes/TagBadge.test.tsx create mode 100644 frontend/src/components/recipes/TagBadge.tsx diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index 65b7ae41..d4fe6ec7 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -208,8 +208,9 @@ components/ │ └── SharePersonalRecipeModal.tsx # Modal publier recette perso vers communautes ├── recipes/ │ ├── RecipeCard.tsx # Carte recette (grille) -│ ├── RecipeFilters.tsx # Filtres search/tags -│ └── RecipeListRow.tsx # Ligne recette (liste) +│ ├── RecipeFilters.tsx # Filtres search/tags (scope-aware via communityId) +│ ├── RecipeListRow.tsx # Ligne recette (liste) +│ └── TagBadge.tsx # Badge tag avec style pending/approved ├── form/ │ ├── TagSelector.tsx # Multi-select tags (debounce, create on-the-fly) │ ├── IngredientSelector.tsx # Selecteur ingredients @@ -320,7 +321,8 @@ __tests__/ │ └── AdminProtectedRoute.test.tsx ├── recipes/ │ ├── RecipeCard.test.tsx - │ └── RecipeFilters.test.tsx + │ ├── RecipeFilters.test.tsx + │ └── TagBadge.test.tsx ├── form/ │ ├── TagSelector.test.tsx │ └── IngredientList.test.tsx diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 04cf5613..90dab7f9 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.5 termine, prochaine etape 10.6 (Frontend Tags refactoring) +- **Sous-etape en cours** : 10.6 termine, prochaine etape 10.7 (Frontend Administration tags) - **Branche** : `TagsRework` -- **Tests** : 758 (273 frontend + 485 backend) +- **Tests** : 771 (286 frontend + 485 backend) ## Prochains chantiers (a specifier) diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index b04c5d28..55107733 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -54,12 +54,12 @@ ## 10.6 - Frontend Tags (refactoring) -- [ ] Composant TagBadge : style normal vs pending -- [ ] Autocomplete tags : scope-aware (global + communaute filtree) -- [ ] Creation recette : gestion tag inconnu → pending -- [ ] Edition recette : idem -- [ ] Affichage tags pending sur RecipeDetailPage -- [ ] Tests composants +- [x] Composant TagBadge : style normal vs pending +- [x] Autocomplete tags : scope-aware (global + communaute filtree) +- [x] Creation recette : gestion tag inconnu → pending +- [x] Edition recette : idem +- [x] Affichage tags pending sur RecipeDetailPage +- [x] Tests composants ## 10.7 - Frontend Administration tags diff --git a/frontend/src/__tests__/unit/components/form/TagSelector.test.tsx b/frontend/src/__tests__/unit/components/form/TagSelector.test.tsx index 9becc1a3..66e8ef50 100644 --- a/frontend/src/__tests__/unit/components/form/TagSelector.test.tsx +++ b/frontend/src/__tests__/unit/components/form/TagSelector.test.tsx @@ -122,4 +122,35 @@ describe('TagSelector', () => { expect(mockOnChange).toHaveBeenCalledWith(['dessert']); }); + + it('should accept communityId prop without error', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('should show pending hint when creating tag with communityId', async () => { + const user = userEvent.setup(); + render( + + ); + + const input = screen.getByRole('textbox'); + await user.type(input, 'unknowntag'); + + await waitFor(() => { + expect(screen.getByText('(will be pending)')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/unit/components/recipes/RecipeCard.test.tsx b/frontend/src/__tests__/unit/components/recipes/RecipeCard.test.tsx index e50248d6..8f1d4ce2 100644 --- a/frontend/src/__tests__/unit/components/recipes/RecipeCard.test.tsx +++ b/frontend/src/__tests__/unit/components/recipes/RecipeCard.test.tsx @@ -12,10 +12,10 @@ const mockRecipe: RecipeListItem = { createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z', tags: [ - { id: 'tag-1', name: 'dessert' }, - { id: 'tag-2', name: 'chocolate' }, - { id: 'tag-3', name: 'easy' }, - { id: 'tag-4', name: 'quick' }, + { id: 'tag-1', name: 'dessert', scope: 'GLOBAL', status: 'APPROVED' }, + { id: 'tag-2', name: 'chocolate', scope: 'GLOBAL', status: 'APPROVED' }, + { id: 'tag-3', name: 'easy', scope: 'COMMUNITY', status: 'APPROVED' }, + { id: 'tag-4', name: 'quick', scope: 'COMMUNITY', status: 'PENDING' }, ], }; @@ -125,4 +125,24 @@ describe('RecipeCard', () => { expect(screen.getByText(/Updated:/)).toBeInTheDocument(); }); + + it('should render pending tags with warning style via TagBadge', () => { + const recipeWithPending: RecipeListItem = { + ...mockRecipe, + tags: [ + { id: 'tag-1', name: 'approved-tag', scope: 'GLOBAL', status: 'APPROVED' }, + { id: 'tag-2', name: 'pending-tag', scope: 'COMMUNITY', status: 'PENDING' }, + ], + }; + render( + + ); + + const approvedBadge = screen.getByText('approved-tag'); + expect(approvedBadge.className).toContain('badge-primary'); + + const pendingBadge = screen.getByText('pending-tag', { exact: false }); + expect(pendingBadge.className).toContain('badge-warning'); + expect(pendingBadge.className).toContain('border-dashed'); + }); }); diff --git a/frontend/src/__tests__/unit/components/recipes/TagBadge.test.tsx b/frontend/src/__tests__/unit/components/recipes/TagBadge.test.tsx new file mode 100644 index 00000000..20ce55f3 --- /dev/null +++ b/frontend/src/__tests__/unit/components/recipes/TagBadge.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../../setup/testUtils'; +import TagBadge from '../../../../components/recipes/TagBadge'; +import { Tag } from '../../../../models/recipe'; + +describe('TagBadge', () => { + it('should render approved tag with primary style', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert', scope: 'GLOBAL', status: 'APPROVED' }; + render(); + + const badge = screen.getByText('dessert'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('badge-primary'); + expect(badge.className).not.toContain('badge-warning'); + }); + + it('should render tag without status as approved (primary style)', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert' }; + render(); + + const badge = screen.getByText('dessert'); + expect(badge.className).toContain('badge-primary'); + }); + + it('should render pending tag with warning outline style', () => { + const tag: Tag = { id: 'tag-1', name: 'newtag', scope: 'COMMUNITY', status: 'PENDING' }; + render(); + + const badge = screen.getByText('newtag', { exact: false }); + expect(badge.className).toContain('badge-outline'); + expect(badge.className).toContain('badge-warning'); + expect(badge.className).toContain('border-dashed'); + }); + + it('should show "(pending)" text for pending tags', () => { + const tag: Tag = { id: 'tag-1', name: 'newtag', status: 'PENDING' }; + render(); + + expect(screen.getByText('(pending)')).toBeInTheDocument(); + }); + + it('should not show "(pending)" text for approved tags', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert', status: 'APPROVED' }; + render(); + + expect(screen.queryByText('(pending)')).not.toBeInTheDocument(); + }); + + it('should render with sm size by default', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert' }; + render(); + + const badge = screen.getByText('dessert'); + expect(badge.className).toContain('badge-sm'); + }); + + it('should render with lg size when specified', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert' }; + render(); + + const badge = screen.getByText('dessert'); + expect(badge.className).toContain('badge-lg'); + }); + + it('should call onClick when clicked', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + const tag: Tag = { id: 'tag-1', name: 'dessert' }; + render(); + + await user.click(screen.getByText('dessert')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should have cursor-pointer class when onClick is provided', () => { + const tag: Tag = { id: 'tag-1', name: 'dessert' }; + render( {}} />); + + const badge = screen.getByText('dessert'); + expect(badge.className).toContain('cursor-pointer'); + }); + + it('should have title "Pending approval" for pending tags', () => { + const tag: Tag = { id: 'tag-1', name: 'newtag', status: 'PENDING' }; + render(); + + const badge = screen.getByText('newtag', { exact: false }); + expect(badge).toHaveAttribute('title', 'Pending approval'); + }); +}); diff --git a/frontend/src/components/communities/CommunityRecipesList.tsx b/frontend/src/components/communities/CommunityRecipesList.tsx index 8d68951b..a1fe96e8 100644 --- a/frontend/src/components/communities/CommunityRecipesList.tsx +++ b/frontend/src/components/communities/CommunityRecipesList.tsx @@ -123,6 +123,7 @@ const CommunityRecipesList = ({ communityId, initialTags }: CommunityRecipesList onTagsChange={setTagsFilter} onIngredientsChange={setIngredientsFilter} onReset={handleResetFilters} + communityId={communityId} /> diff --git a/frontend/src/components/form/TagSelector.tsx b/frontend/src/components/form/TagSelector.tsx index 0004ffce..c25ffb36 100644 --- a/frontend/src/components/form/TagSelector.tsx +++ b/frontend/src/components/form/TagSelector.tsx @@ -10,6 +10,7 @@ interface TagSelectorProps { onChange: (tags: string[]) => void; placeholder?: string; allowCreate?: boolean; + communityId?: string; } const TagSelector = ({ @@ -17,6 +18,7 @@ const TagSelector = ({ onChange, placeholder = "Search tags...", allowCreate = true, + communityId, }: TagSelectorProps) => { const [inputValue, setInputValue] = useState(""); const [suggestions, setSuggestions] = useState([]); @@ -28,11 +30,11 @@ const TagSelector = ({ if (!showDropdown) return; setIsLoading(true); - APIManager.searchTags(inputValue.trim(), 10) + APIManager.searchTags(inputValue.trim(), 10, communityId) .then((results) => setSuggestions(results.filter((tag) => !value.includes(tag.name)))) .catch(() => setSuggestions([])) .finally(() => setIsLoading(false)); - }, inputValue ? 300 : 0, [inputValue, value, showDropdown]); + }, inputValue ? 300 : 0, [inputValue, value, showDropdown, communityId]); useClickOutside(containerRef, useCallback(() => setShowDropdown(false), [])); @@ -119,7 +121,14 @@ const TagSelector = ({ onClick={() => addTag(suggestion.name)} className="w-full px-3 py-2 text-left hover:bg-base-200 flex justify-between items-center" > - {suggestion.name} + + {suggestion.name} + {suggestion.scope && ( + + {suggestion.scope === "GLOBAL" ? "global" : "community"} + + )} + {suggestion.recipeCount} recipe{suggestion.recipeCount !== 1 ? "s" : ""} @@ -132,7 +141,12 @@ const TagSelector = ({ className="w-full px-3 py-2 text-left hover:bg-base-200 flex items-center gap-2 border-t" > - Create "{inputValue.trim().toLowerCase()}" + + Create "{inputValue.trim().toLowerCase()}" + {communityId && ( + (will be pending) + )} + )} {suggestions.length === 0 && !showCreateOption && !isLoading && ( diff --git a/frontend/src/components/recipes/RecipeCard.tsx b/frontend/src/components/recipes/RecipeCard.tsx index 36f7e1f9..d892011d 100644 --- a/frontend/src/components/recipes/RecipeCard.tsx +++ b/frontend/src/components/recipes/RecipeCard.tsx @@ -1,6 +1,7 @@ import { FaEdit, FaTrash, FaCodeBranch, FaShare } from "react-icons/fa"; import { RecipeListItem, CommunityRecipeListItem } from "../../models/recipe"; import { useRecipeActions } from "../../hooks/useRecipeActions"; +import TagBadge from "./TagBadge"; interface RecipeCardProps { recipe: RecipeListItem | CommunityRecipeListItem; @@ -49,13 +50,12 @@ const RecipeCard = ({ recipe, onDelete, onTagClick, onShare, showCreator = false {tags.length > 0 && (
{displayedTags.map((tag) => ( - handleTagClick(e, tag.name)} - className="badge badge-primary badge-sm cursor-pointer hover:badge-secondary" - > - {tag.name} - + /> ))} {remainingTagsCount > 0 && ( +{remainingTagsCount} diff --git a/frontend/src/components/recipes/RecipeFilters.tsx b/frontend/src/components/recipes/RecipeFilters.tsx index f8394561..6530fef1 100644 --- a/frontend/src/components/recipes/RecipeFilters.tsx +++ b/frontend/src/components/recipes/RecipeFilters.tsx @@ -11,6 +11,7 @@ interface RecipeFiltersProps { onTagsChange: (tags: string[]) => void; onIngredientsChange: (ingredients: string[]) => void; onReset: () => void; + communityId?: string; } const RecipeFilters = ({ @@ -21,6 +22,7 @@ const RecipeFilters = ({ onTagsChange, onIngredientsChange, onReset, + communityId, }: RecipeFiltersProps) => { const [localSearch, setLocalSearch] = useState(search); const debounceRef = useRef(null); @@ -77,6 +79,7 @@ const RecipeFilters = ({ onChange={onTagsChange} placeholder="Select tags..." allowCreate={false} + communityId={communityId} />
diff --git a/frontend/src/components/recipes/RecipeListRow.tsx b/frontend/src/components/recipes/RecipeListRow.tsx index 2cd99ac5..bdbab3f1 100644 --- a/frontend/src/components/recipes/RecipeListRow.tsx +++ b/frontend/src/components/recipes/RecipeListRow.tsx @@ -1,6 +1,7 @@ import { FaEdit, FaTrash, FaCodeBranch, FaShare } from "react-icons/fa"; import { RecipeListItem, CommunityRecipeListItem } from "../../models/recipe"; import { useRecipeActions } from "../../hooks/useRecipeActions"; +import TagBadge from "./TagBadge"; interface RecipeListRowProps { recipe: RecipeListItem | CommunityRecipeListItem; @@ -54,13 +55,12 @@ const RecipeListRow = ({ recipe, onDelete, onTagClick, onShare, showCreator = fa {tags.length > 0 && (
{displayedTags.map((tag) => ( - handleTagClick(e, tag.name)} - className="badge badge-primary badge-sm cursor-pointer hover:badge-secondary" - > - {tag.name} - + /> ))} {remainingTagsCount > 0 && ( +{remainingTagsCount} diff --git a/frontend/src/components/recipes/TagBadge.tsx b/frontend/src/components/recipes/TagBadge.tsx new file mode 100644 index 00000000..79f81131 --- /dev/null +++ b/frontend/src/components/recipes/TagBadge.tsx @@ -0,0 +1,30 @@ +import { Tag } from "../../models/recipe"; + +interface TagBadgeProps { + tag: Tag; + size?: "sm" | "lg"; + onClick?: (e: React.MouseEvent) => void; +} + +const TagBadge = ({ tag, size = "sm", onClick }: TagBadgeProps) => { + const isPending = tag.status === "PENDING"; + + const baseClasses = `badge badge-${size}`; + const styleClasses = isPending + ? "badge-outline badge-warning border-dashed" + : "badge-primary"; + const interactionClasses = onClick ? "cursor-pointer hover:badge-secondary" : ""; + + return ( + + {tag.name} + {isPending && (pending)} + + ); +}; + +export default TagBadge; diff --git a/frontend/src/models/recipe.ts b/frontend/src/models/recipe.ts index d1d60792..1e84cd0c 100644 --- a/frontend/src/models/recipe.ts +++ b/frontend/src/models/recipe.ts @@ -1,6 +1,9 @@ export interface Tag { id: string; name: string; + scope?: "GLOBAL" | "COMMUNITY"; + status?: "APPROVED" | "PENDING"; + communityId?: string | null; } export interface RecipeIngredient { @@ -73,6 +76,8 @@ export interface TagSearchResult { id: string; name: string; recipeCount: number; + scope?: "GLOBAL" | "COMMUNITY"; + communityId?: string | null; } export interface IngredientSearchResult { diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 1e532aa7..eec4bbe5 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -218,8 +218,8 @@ export default class APIManager { // --------------- Tags --------------- - static async searchTags(search: string = "", limit: number = 20): Promise { - const qs = buildQueryString({ search: search || undefined, limit }); + static async searchTags(search: string = "", limit: number = 20, communityId?: string): Promise { + const qs = buildQueryString({ search: search || undefined, limit, communityId }); const response = await API.get(`/api/tags${qs}`).catch(handleApiError); return response.data.data; } diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index bc328520..9f06c296 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -5,6 +5,7 @@ import { FaArrowLeft, FaEdit, FaTrash, FaLightbulb, FaShare, FaCodeBranch } from import APIManager from "../network/api"; import { RecipeDetail } from "../models/recipe"; import { useAuth } from "../contexts/AuthContext"; +import TagBadge from "../components/recipes/TagBadge"; import { formatDate } from "../utils/format.Date"; import { ProposeModificationModal, ProposalsList, VariantsDropdown } from "../components/proposals"; import { ShareRecipeModal, SharePersonalRecipeModal } from "../components/share"; @@ -236,13 +237,12 @@ const RecipeDetailPage = () => { {recipe.tags.length > 0 && (
{recipe.tags.map((tag) => ( - + /> ))}
)} diff --git a/frontend/src/pages/RecipeFormPage.tsx b/frontend/src/pages/RecipeFormPage.tsx index 0d01aa69..4c5f07d2 100644 --- a/frontend/src/pages/RecipeFormPage.tsx +++ b/frontend/src/pages/RecipeFormPage.tsx @@ -171,7 +171,7 @@ const RecipeFormPage = () => { - +
From ac26de52f32e49e47c0f1dd0f8214e59ebbe8136 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 09:14:41 +0100 Subject: [PATCH 009/221] feat: frontend moderator tag management & admin scope filter (Phase 10.7) Add CommunityTagsList panel for moderators (CRUD, approve/reject pending tags), scope filter on AdminTagsPage, tag notification toasts, and 15 new tests. --- .claude/context/FILE_MAP.md | 5 +- .claude/context/PROGRESS.md | 4 +- .claude/context/TESTS.md | 9 +- docs/features/tags-rework/ROADMAP.md | 10 +- frontend/src/__tests__/setup/mswHandlers.ts | 181 ++++++++++- .../communities/CommunityTagsList.test.tsx | 184 ++++++++++++ .../unit/hooks/useNotificationToasts.test.ts | 39 +++ .../unit/pages/admin/AdminTagsPage.test.tsx | 54 ++++ .../communities/CommunityTagsList.tsx | 282 ++++++++++++++++++ frontend/src/hooks/useNotificationToasts.ts | 3 + frontend/src/models/admin.ts | 4 + frontend/src/models/tag.ts | 11 + frontend/src/network/api.ts | 39 ++- frontend/src/pages/CommunityDetailPage.tsx | 24 +- frontend/src/pages/admin/AdminTagsPage.tsx | 34 ++- 15 files changed, 858 insertions(+), 25 deletions(-) create mode 100644 frontend/src/__tests__/unit/components/communities/CommunityTagsList.test.tsx create mode 100644 frontend/src/components/communities/CommunityTagsList.tsx diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index d4fe6ec7..752e9977 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -187,8 +187,9 @@ components/ ├── communities/ │ ├── CommunityCard.tsx # Carte communaute (grille) │ ├── CommunityRecipesList.tsx # Liste recettes communaute (filtres, pagination, permissions) +│ ├── CommunityTagsList.tsx # Gestion tags communaute moderateur (CRUD, approve/reject) │ ├── MembersList.tsx # Liste membres (promote, kick, leave) -│ └── SidePanel.tsx # Volet lateral redimensionnable (members/activity/invitations) +│ └── SidePanel.tsx # Volet lateral redimensionnable (members/activity/invitations/tags) ├── invitations/ │ ├── InviteCard.tsx # Carte invitation recue (accept/reject) │ ├── InviteUserModal.tsx # Modal inviter un utilisateur @@ -313,6 +314,8 @@ __tests__/ │ ├── AdminCommunitiesPage.test.tsx │ └── AdminActivityPage.test.tsx └── components/ + ├── communities/ + │ └── CommunityTagsList.test.tsx ├── Layout/ │ ├── MainLayout.test.tsx │ └── Sidebar.test.tsx diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 90dab7f9..fad84a24 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.6 termine, prochaine etape 10.7 (Frontend Administration tags) +- **Sous-etape en cours** : 10.7 termine, prochaine etape 10.8 (Frontend TagSuggestion) - **Branche** : `TagsRework` -- **Tests** : 771 (286 frontend + 485 backend) +- **Tests** : 783 (301 frontend + 485 backend) ## Prochains chantiers (a specifier) diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index f1329a31..d0c3fe56 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -40,7 +40,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Mocks: `__tests__/setup/mswHandlers.ts` - Utils: `__tests__/setup/testUtils.tsx` -## Inventaire des tests (~704 tests) +## Inventaire des tests (~783 tests) ### Backend Integration (20 fichiers, ~380 tests) @@ -80,7 +80,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | middleware/requireSuperAdmin.test.ts | requireSuperAdmin, requireAdminSession | 6 | | middleware/security.test.ts | requireHttps, rateLimiters, helmet | 5 | -### Frontend Unit (47 fichiers, ~273 tests) +### Frontend Unit (48 fichiers, ~301 tests) | Fichier | Composant | Tests | |---------|-----------|-------| @@ -95,7 +95,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | AdminLoginPage.test.tsx | Page login admin | 8 | | AdminDashboardPage.test.tsx | Page dashboard | 4 | | AdminLayout.test.tsx | Layout admin | 3 | -| pages/admin/AdminTagsPage.test.tsx | Page tags admin | 8 | +| pages/admin/AdminTagsPage.test.tsx | Page tags admin (+ scope filter) | 12 | | pages/admin/AdminIngredientsPage.test.tsx | Page ingredients admin | 8 | | pages/admin/AdminFeaturesPage.test.tsx | Page features admin | 6 | | pages/admin/AdminCommunitiesPage.test.tsx | Page communities admin | 8 | @@ -111,6 +111,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | CommunitiesPage.test.tsx | Page liste communautes | 7 | | CommunityDetailPage.test.tsx | Page detail communaute | 11 | | InviteCard.test.tsx | Carte invitation | 5 | +| communities/CommunityTagsList.test.tsx | Tags communaute moderateur (CRUD, approve/reject) | 8 | | MembersList.test.tsx | Liste membres | 6 | | InviteUserModal.test.tsx | Modal invitation | 5 | | ActivityFeed.test.tsx | Feed activite | 8 | @@ -120,7 +121,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | hooks/useConfirm.test.tsx | Hook confirm dialog | 6 | | hooks/useSocketEvent.test.ts | Hook socket event | 5 | | hooks/useCommunityRoom.test.ts | Hook community room | 4 | -| hooks/useNotificationToasts.test.ts | Hook notification toasts | 5 | +| hooks/useNotificationToasts.test.ts | Hook notification toasts (+ tag events) | 8 | | hooks/usePaginatedList.test.ts | Hook paginated list | 6 | | utils/formatDate.test.ts | Format date utils | 5 | | utils/communityEvents.test.ts | Community events bus | 2 | diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 55107733..10f06175 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -63,11 +63,11 @@ ## 10.7 - Frontend Administration tags -- [ ] Page moderateur : liste tags communaute (APPROVED + PENDING) -- [ ] Actions moderateur : creer, renommer, supprimer tag communaute -- [ ] Actions moderateur : valider/rejeter tag pending -- [ ] Adaptation pages SuperAdmin (filtre scope) -- [ ] Tests +- [x] Page moderateur : liste tags communaute (APPROVED + PENDING) +- [x] Actions moderateur : creer, renommer, supprimer tag communaute +- [x] Actions moderateur : valider/rejeter tag pending +- [x] Adaptation pages SuperAdmin (filtre scope) +- [x] Tests ## 10.8 - Frontend TagSuggestion diff --git a/frontend/src/__tests__/setup/mswHandlers.ts b/frontend/src/__tests__/setup/mswHandlers.ts index 7b007001..eb2c41c9 100644 --- a/frontend/src/__tests__/setup/mswHandlers.ts +++ b/frontend/src/__tests__/setup/mswHandlers.ts @@ -32,9 +32,9 @@ export const mockRecipes = [mockRecipe]; // Mock admin data export const mockTags = [ - { id: 'tag-1', name: 'dessert', recipeCount: 5 }, - { id: 'tag-2', name: 'dinner', recipeCount: 3 }, - { id: 'tag-3', name: 'breakfast', recipeCount: 2 }, + { id: 'tag-1', name: 'dessert', recipeCount: 5, scope: 'GLOBAL' as const, status: 'APPROVED' as const, communityId: null, community: null }, + { id: 'tag-2', name: 'dinner', recipeCount: 3, scope: 'GLOBAL' as const, status: 'APPROVED' as const, communityId: null, community: null }, + { id: 'tag-3', name: 'breakfast', recipeCount: 2, scope: 'COMMUNITY' as const, status: 'APPROVED' as const, communityId: 'com-1', community: { name: 'Test Community' } }, ]; export const mockIngredients = [ @@ -135,6 +135,40 @@ export const mockReceivedInvites = [ }, ]; +// Mock community tags data +export const mockCommunityTags = [ + { + id: 'ctag-1', + name: 'appetizer', + scope: 'COMMUNITY' as const, + status: 'APPROVED' as const, + communityId: 'community-1', + createdBy: { id: 'test-user-id', username: 'testuser' }, + recipeCount: 3, + createdAt: new Date().toISOString(), + }, + { + id: 'ctag-2', + name: 'spicy', + scope: 'COMMUNITY' as const, + status: 'PENDING' as const, + communityId: 'community-1', + createdBy: { id: 'user-2', username: 'alice' }, + recipeCount: 1, + createdAt: new Date().toISOString(), + }, + { + id: 'ctag-3', + name: 'healthy', + scope: 'COMMUNITY' as const, + status: 'APPROVED' as const, + communityId: 'community-1', + createdBy: null, + recipeCount: 5, + createdAt: new Date().toISOString(), + }, +]; + // Mock user activity feed data export const mockUserActivityFeed = [ { @@ -555,6 +589,7 @@ export const handlers = [ const url = new URL(request.url); const search = url.searchParams.get('search'); + const scope = url.searchParams.get('scope'); let filteredTags = [...mockTags]; if (search) { @@ -562,6 +597,9 @@ export const handlers = [ t.name.toLowerCase().includes(search.toLowerCase()) ); } + if (scope) { + filteredTags = filteredTags.filter(t => t.scope === scope); + } return HttpResponse.json({ tags: filteredTags }); }), @@ -1223,6 +1261,143 @@ export const handlers = [ }); }), + // ===================================== + // Community Tags (moderator) + // ===================================== + + http.get(`${API_URL}/api/communities/:communityId/tags`, ({ request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const url = new URL(request.url); + const search = url.searchParams.get('search'); + const status = url.searchParams.get('status'); + + let filtered = [...mockCommunityTags]; + if (search) { + filtered = filtered.filter(t => + t.name.toLowerCase().includes(search.toLowerCase()) + ); + } + if (status) { + filtered = filtered.filter(t => t.status === status); + } + + return HttpResponse.json({ data: filtered, total: filtered.length }); + }), + + http.post(`${API_URL}/api/communities/:communityId/tags`, async ({ request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json() as Record; + if (!body.name) { + return HttpResponse.json( + { error: 'TAG_001: Tag name is required' }, + { status: 400 } + ); + } + + return HttpResponse.json({ + id: `ctag-${Date.now()}`, + name: body.name.toLowerCase().trim(), + scope: 'COMMUNITY', + status: 'APPROVED', + communityId: 'community-1', + }, { status: 201 }); + }), + + http.patch(`${API_URL}/api/communities/:communityId/tags/:tagId`, async ({ params, request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const tag = mockCommunityTags.find(t => t.id === params.tagId); + if (!tag) { + return HttpResponse.json( + { error: 'TAG_001: Tag not found' }, + { status: 404 } + ); + } + + const body = await request.json() as Record; + return HttpResponse.json({ + ...tag, + name: body.name?.toLowerCase().trim() || tag.name, + }); + }), + + http.delete(`${API_URL}/api/communities/:communityId/tags/:tagId`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const tag = mockCommunityTags.find(t => t.id === params.tagId); + if (!tag) { + return HttpResponse.json( + { error: 'TAG_001: Tag not found' }, + { status: 404 } + ); + } + + return HttpResponse.json({ message: 'Tag deleted' }); + }), + + http.post(`${API_URL}/api/communities/:communityId/tags/:tagId/approve`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const tag = mockCommunityTags.find(t => t.id === params.tagId); + if (!tag) { + return HttpResponse.json( + { error: 'TAG_001: Tag not found' }, + { status: 404 } + ); + } + + return HttpResponse.json({ + ...tag, + status: 'APPROVED', + }); + }), + + http.post(`${API_URL}/api/communities/:communityId/tags/:tagId/reject`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const tag = mockCommunityTags.find(t => t.id === params.tagId); + if (!tag) { + return HttpResponse.json( + { error: 'TAG_001: Tag not found' }, + { status: 404 } + ); + } + + return HttpResponse.json({ message: 'Tag rejected and removed' }); + }), + // ===================================== // Recipe Communities (for share modal) // ===================================== diff --git a/frontend/src/__tests__/unit/components/communities/CommunityTagsList.test.tsx b/frontend/src/__tests__/unit/components/communities/CommunityTagsList.test.tsx new file mode 100644 index 00000000..94762e40 --- /dev/null +++ b/frontend/src/__tests__/unit/components/communities/CommunityTagsList.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AuthProvider } from '../../../../contexts/AuthContext'; +import CommunityTagsList from '../../../../components/communities/CommunityTagsList'; +import { setUserAuthenticated, resetAuthState } from '../../../setup/mswHandlers'; +import { Toaster } from 'react-hot-toast'; + +function TestApp() { + return ( + + + + + + + ); +} + +describe('CommunityTagsList', () => { + beforeEach(() => { + resetAuthState(); + setUserAuthenticated(true); + }); + + it('should display APPROVED and PENDING tags with correct styles', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('appetizer')).toBeInTheDocument(); + expect(screen.getByText('spicy')).toBeInTheDocument(); + expect(screen.getByText('healthy')).toBeInTheDocument(); + }); + + // Pending tag should show "(pending)" text + expect(screen.getByText('(pending)')).toBeInTheDocument(); + }); + + it('should filter by status when clicking filter buttons', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('appetizer')).toBeInTheDocument(); + }); + + // Click "Pending" filter + await user.click(screen.getByText('Pending')); + + await waitFor(() => { + expect(screen.getByText('spicy')).toBeInTheDocument(); + }); + + // Click "Approved" filter + await user.click(screen.getByText('Approved')); + + await waitFor(() => { + expect(screen.getByText('appetizer')).toBeInTheDocument(); + }); + }); + + it('should open create modal and create a tag', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Add Tag')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Add Tag')); + + expect(screen.getByText('Create Tag')).toBeInTheDocument(); + + // Type in the modal input + const inputs = screen.getAllByRole('textbox'); + const modalInput = inputs[inputs.length - 1]; + await user.type(modalInput, 'newtag'); + await user.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('Tag created')).toBeInTheDocument(); + }); + }); + + it('should open edit modal and save', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('appetizer')).toBeInTheDocument(); + }); + + // Click edit on the first APPROVED tag + const editButtons = screen.getAllByTitle('Edit'); + await user.click(editButtons[0]); + + expect(screen.getByText('Edit Tag')).toBeInTheDocument(); + + const inputs = screen.getAllByRole('textbox'); + const modalInput = inputs[inputs.length - 1]; + await user.clear(modalInput); + await user.type(modalInput, 'updated'); + await user.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('Tag updated')).toBeInTheDocument(); + }); + }); + + it('should delete a tag after confirmation', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('appetizer')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + // Confirmation dialog + await waitFor(() => { + expect(screen.getByText(/Delete tag/)).toBeInTheDocument(); + }); + + // Click confirm Delete button in dialog + const confirmButtons = screen.getAllByText('Delete'); + await user.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Tag deleted')).toBeInTheDocument(); + }); + }); + + it('should approve a pending tag', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('spicy')).toBeInTheDocument(); + }); + + const approveButton = screen.getByTitle('Approve'); + await user.click(approveButton); + + await waitFor(() => { + expect(screen.getByText('Tag approved')).toBeInTheDocument(); + }); + }); + + it('should reject a pending tag after confirmation', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('spicy')).toBeInTheDocument(); + }); + + const rejectButton = screen.getByTitle('Reject'); + await user.click(rejectButton); + + // Confirmation dialog + await waitFor(() => { + expect(screen.getByText(/Reject tag/)).toBeInTheDocument(); + }); + + const confirmButtons = screen.getAllByText('Reject'); + await user.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Tag rejected')).toBeInTheDocument(); + }); + }); + + it('should display creator info for pending tags', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('by alice')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts b/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts index 6358bb77..b21f0a36 100644 --- a/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts +++ b/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts @@ -70,6 +70,45 @@ describe("useNotificationToasts", () => { ); }); + it("should show toast for TAG_PENDING", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_PENDING", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Nouveau tag en attente de validation", + expect.any(Object) + ); + }); + + it("should show toast for TAG_APPROVED", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_APPROVED", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Votre tag a ete valide", + expect.any(Object) + ); + }); + + it("should show toast for TAG_REJECTED", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_REJECTED", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Votre tag a ete rejete", + expect.any(Object) + ); + }); + it("should skip unknown notification type", () => { renderHook(() => useNotificationToasts()); const handler = mockSocket.on.mock.calls.find( diff --git a/frontend/src/__tests__/unit/pages/admin/AdminTagsPage.test.tsx b/frontend/src/__tests__/unit/pages/admin/AdminTagsPage.test.tsx index ee2a7ee8..88897be6 100644 --- a/frontend/src/__tests__/unit/pages/admin/AdminTagsPage.test.tsx +++ b/frontend/src/__tests__/unit/pages/admin/AdminTagsPage.test.tsx @@ -142,4 +142,58 @@ describe('AdminTagsPage', () => { expect(screen.getByText(/Merge "dessert" into/)).toBeInTheDocument(); }); }); + + it('should display Scope and Community columns', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Scope')).toBeInTheDocument(); + // "Community" appears both as column header and in scope filter option + const communityElements = screen.getAllByText('Community'); + expect(communityElements.length).toBeGreaterThanOrEqual(2); + }); + }); + + it('should display scope badges for tags', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('dessert')).toBeInTheDocument(); + }); + + const globalBadges = screen.getAllByText('GLOBAL'); + expect(globalBadges.length).toBeGreaterThanOrEqual(2); + + expect(screen.getByText('COMMUNITY')).toBeInTheDocument(); + }); + + it('should display community name for community-scoped tags', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Test Community')).toBeInTheDocument(); + }); + }); + + it('should filter by scope when scope select changes', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('dessert')).toBeInTheDocument(); + }); + + const scopeSelect = screen.getByLabelText('Filter by scope'); + await user.selectOptions(scopeSelect, 'GLOBAL'); + + await waitFor(() => { + expect(screen.getByText('dessert')).toBeInTheDocument(); + expect(screen.getByText('dinner')).toBeInTheDocument(); + }); + + // breakfast is COMMUNITY scope, should not be visible with GLOBAL filter + await waitFor(() => { + expect(screen.queryByText('breakfast')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/communities/CommunityTagsList.tsx b/frontend/src/components/communities/CommunityTagsList.tsx new file mode 100644 index 00000000..60bf8637 --- /dev/null +++ b/frontend/src/components/communities/CommunityTagsList.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState, useCallback } from "react"; +import { FaCheck, FaTimes, FaEdit, FaTrash } from "react-icons/fa"; +import toast from "react-hot-toast"; +import { CommunityTag } from "../../models/tag"; +import APIManager from "../../network/api"; +import { useConfirm } from "../../hooks/useConfirm"; +import TagBadge from "../recipes/TagBadge"; + +interface CommunityTagsListProps { + communityId: string; +} + +type StatusFilter = "ALL" | "APPROVED" | "PENDING"; + +const CommunityTagsList = ({ communityId }: CommunityTagsListProps) => { + const [tags, setTags] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("ALL"); + const [modalOpen, setModalOpen] = useState(false); + const [editingTag, setEditingTag] = useState(null); + const [tagName, setTagName] = useState(""); + const [saving, setSaving] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + const { confirm, ConfirmDialog } = useConfirm(); + + const loadTags = useCallback(async () => { + try { + const params: { status?: string; search?: string } = {}; + if (statusFilter !== "ALL") params.status = statusFilter; + if (search) params.search = search; + const result = await APIManager.getCommunityTags(communityId, params); + setTags(result.data); + } catch { + toast.error("Failed to load tags"); + } finally { + setIsLoading(false); + } + }, [communityId, statusFilter, search]); + + useEffect(() => { + setIsLoading(true); + loadTags(); + }, [loadTags]); + + function openCreate() { + setEditingTag(null); + setTagName(""); + setModalOpen(true); + } + + function openEdit(tag: CommunityTag) { + setEditingTag(tag); + setTagName(tag.name); + setModalOpen(true); + } + + async function handleSave() { + if (!tagName.trim()) return; + setSaving(true); + try { + if (editingTag) { + await APIManager.updateCommunityTag(communityId, editingTag.id, tagName.trim()); + toast.success("Tag updated"); + } else { + await APIManager.createCommunityTag(communityId, tagName.trim()); + toast.success("Tag created"); + } + setModalOpen(false); + loadTags(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to save tag"); + } finally { + setSaving(false); + } + } + + async function handleDelete(tag: CommunityTag) { + const confirmed = await confirm({ + title: "Delete Tag", + message: `Delete tag "${tag.name}"? This will remove it from ${tag.recipeCount} recipe(s).`, + confirmLabel: "Delete", + }); + if (!confirmed) return; + + try { + setActionLoading(tag.id); + await APIManager.deleteCommunityTag(communityId, tag.id); + toast.success("Tag deleted"); + loadTags(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to delete tag"); + } finally { + setActionLoading(null); + } + } + + async function handleApprove(tag: CommunityTag) { + try { + setActionLoading(tag.id); + await APIManager.approveCommunityTag(communityId, tag.id); + toast.success("Tag approved"); + loadTags(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to approve tag"); + } finally { + setActionLoading(null); + } + } + + async function handleReject(tag: CommunityTag) { + const confirmed = await confirm({ + title: "Reject Tag", + message: `Reject tag "${tag.name}"? It will be permanently removed.`, + confirmLabel: "Reject", + confirmClass: "btn btn-error", + }); + if (!confirmed) return; + + try { + setActionLoading(tag.id); + await APIManager.rejectCommunityTag(communityId, tag.id); + toast.success("Tag rejected"); + loadTags(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to reject tag"); + } finally { + setActionLoading(null); + } + } + + const filterButtons: { label: string; value: StatusFilter }[] = [ + { label: "All", value: "ALL" }, + { label: "Approved", value: "APPROVED" }, + { label: "Pending", value: "PENDING" }, + ]; + + return ( +
+ {/* Status filter */} +
+ {filterButtons.map((btn) => ( + + ))} +
+ + {/* Search + Add */} +
+ setSearch(e.target.value)} + /> + +
+ + {/* Tags list */} + {isLoading ? ( +
+ +
+ ) : tags.length === 0 ? ( +

No tags found

+ ) : ( +
+ {tags.map((tag) => { + const isLoading = actionLoading === tag.id; + return ( +
+
+ + + {tag.recipeCount} recipe{tag.recipeCount !== 1 ? "s" : ""} + +
+
+ {tag.status === "PENDING" ? ( + <> + {tag.createdBy && ( + + by {tag.createdBy.username} + + )} + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+ )} + + {/* Create/Edit Modal */} + {modalOpen && ( +
+
+

{editingTag ? "Edit Tag" : "Create Tag"}

+
+ + setTagName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + /> +
+
+ + +
+
+
setModalOpen(false)} /> +
+ )} + + {ConfirmDialog} +
+ ); +}; + +export default CommunityTagsList; diff --git a/frontend/src/hooks/useNotificationToasts.ts b/frontend/src/hooks/useNotificationToasts.ts index dd5c0e74..c297ba35 100644 --- a/frontend/src/hooks/useNotificationToasts.ts +++ b/frontend/src/hooks/useNotificationToasts.ts @@ -17,6 +17,9 @@ const notificationMessages: Record = { PROPOSAL_REJECTED: "Votre proposition a ete refusee", USER_PROMOTED: "Vous avez ete promu moderateur", USER_KICKED: "Vous avez ete retire de la communaute", + TAG_PENDING: "Nouveau tag en attente de validation", + TAG_APPROVED: "Votre tag a ete valide", + TAG_REJECTED: "Votre tag a ete rejete", }; export function useNotificationToasts() { diff --git a/frontend/src/models/admin.ts b/frontend/src/models/admin.ts index ec99e784..dd662e92 100644 --- a/frontend/src/models/admin.ts +++ b/frontend/src/models/admin.ts @@ -22,6 +22,10 @@ export interface AdminTag { id: string; name: string; recipeCount: number; + scope?: "GLOBAL" | "COMMUNITY"; + status?: "APPROVED" | "PENDING"; + communityId?: string | null; + community?: { name: string } | null; } export interface AdminIngredient { diff --git a/frontend/src/models/tag.ts b/frontend/src/models/tag.ts index 43f3544e..5d8da1bb 100644 --- a/frontend/src/models/tag.ts +++ b/frontend/src/models/tag.ts @@ -2,3 +2,14 @@ export interface Recipe { id: string, label: string, } + +export interface CommunityTag { + id: string; + name: string; + scope: "GLOBAL" | "COMMUNITY"; + status: "APPROVED" | "PENDING"; + communityId: string | null; + createdBy: { id: string; username: string } | null; + recipeCount: number; + createdAt: string; +} diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index eec4bbe5..79a06f62 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -3,6 +3,7 @@ import { RecipeDetail, RecipesResponse, CommunityRecipesResponse, TagSearchResul import { ActivityResponse } from "../models/activity"; import { User } from "../models/user"; import { AdminLoginResponse, AdminTotpResponse, AdminUser, DashboardStats, AdminTag, AdminIngredient, AdminFeature, AdminCommunity, AdminCommunityDetail, AdminActivityResponse } from "../models/admin"; +import { CommunityTag } from "../models/tag"; import { CommunityListItem, CommunityDetail, CommunityMember, CommunityInvite, ReceivedInvite } from "../models/community"; import { ConflictError, UnauthorizedError } from "../errors/http_errors"; @@ -406,10 +407,44 @@ export default class APIManager { } + // --------------- Community Tags (moderator) --------------- + + static async getCommunityTags(communityId: string, params?: { status?: string; search?: string }): Promise<{ data: CommunityTag[]; total: number }> { + const qs = buildQueryString({ status: params?.status, search: params?.search }); + const response = await API.get(`/api/communities/${communityId}/tags${qs}`).catch(handleApiError); + return response.data; + } + + static async createCommunityTag(communityId: string, name: string): Promise { + const response = await API.post(`/api/communities/${communityId}/tags`, JSON.stringify({ name })) + .catch(handleApiErrorWith({ 409: ConflictError })); + return response.data; + } + + static async updateCommunityTag(communityId: string, tagId: string, name: string): Promise { + const response = await API.patch(`/api/communities/${communityId}/tags/${tagId}`, JSON.stringify({ name })) + .catch(handleApiErrorWith({ 409: ConflictError })); + return response.data; + } + + static async deleteCommunityTag(communityId: string, tagId: string): Promise { + await API.delete(`/api/communities/${communityId}/tags/${tagId}`).catch(handleApiError); + } + + static async approveCommunityTag(communityId: string, tagId: string): Promise { + const response = await API.post(`/api/communities/${communityId}/tags/${tagId}/approve`).catch(handleApiError); + return response.data; + } + + static async rejectCommunityTag(communityId: string, tagId: string): Promise { + await API.post(`/api/communities/${communityId}/tags/${tagId}/reject`).catch(handleApiError); + } + + // --------------- Admin Tags --------------- - static async getAdminTags(search?: string): Promise { - const qs = buildQueryString({ search }); + static async getAdminTags(search?: string, scope?: string): Promise { + const qs = buildQueryString({ search, scope }); const response = await API.get(`/api/admin/tags${qs}`).catch(handleApiError); return response.data.tags; } diff --git a/frontend/src/pages/CommunityDetailPage.tsx b/frontend/src/pages/CommunityDetailPage.tsx index 2797a9af..d772897a 100644 --- a/frontend/src/pages/CommunityDetailPage.tsx +++ b/frontend/src/pages/CommunityDetailPage.tsx @@ -1,18 +1,19 @@ import { useEffect, useState, useCallback } from "react"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import toast from "react-hot-toast"; -import { FaArrowLeft, FaEdit, FaUsers, FaHistory, FaEnvelope } from "react-icons/fa"; +import { FaArrowLeft, FaEdit, FaUsers, FaHistory, FaEnvelope, FaTags } from "react-icons/fa"; import { CommunityDetail, CommunityMember } from "../models/community"; import APIManager from "../network/api"; import MembersList from "../components/communities/MembersList"; import CommunityRecipesList from "../components/communities/CommunityRecipesList"; import CommunityEditForm from "../components/communities/CommunityEditForm"; import SentInvitesList from "../components/invitations/SentInvitesList"; +import CommunityTagsList from "../components/communities/CommunityTagsList"; import { ActivityFeed } from "../components/activity"; import SidePanel from "../components/communities/SidePanel"; import { communityEvents } from "../utils/communityEvents"; -type PanelContent = "members" | "activity" | "invitations" | "edit"; +type PanelContent = "members" | "activity" | "invitations" | "edit" | "tags"; const PANEL_WIDTH_KEY = "communityPanelWidth"; const DEFAULT_PANEL_WIDTH = 350; @@ -113,7 +114,8 @@ const CommunityDetailPage = () => { panelContent === "members" ? "Members" : panelContent === "activity" ? "Activity" : panelContent === "invitations" ? "Invitations" : - panelContent === "edit" ? "Edit Community" : ""; + panelContent === "edit" ? "Edit Community" : + panelContent === "tags" ? "Tags" : ""; return (
@@ -174,6 +176,19 @@ const CommunityDetailPage = () => {
)} + {/* Tags button (moderators only) */} + {isModerator && ( +
+ +
+ )} + {/* Edit button (moderators only) */} {isModerator && (
@@ -220,6 +235,9 @@ const CommunityDetailPage = () => { {panelContent === "invitations" && isModerator && ( )} + {panelContent === "tags" && isModerator && ( + + )} {panelContent === "edit" && isModerator && ( ([]); const [isLoading, setIsLoading] = useState(true); const [search, setSearch] = useState(""); + const [scopeFilter, setScopeFilter] = useState("ALL"); const [modalOpen, setModalOpen] = useState(false); const [editingTag, setEditingTag] = useState(null); const [tagName, setTagName] = useState(""); @@ -18,14 +21,15 @@ function AdminTagsPage() { const loadTags = useCallback(async () => { try { - const data = await APIManager.getAdminTags(search || undefined); + const scope = scopeFilter !== "ALL" ? scopeFilter : undefined; + const data = await APIManager.getAdminTags(search || undefined, scope); setTags(data); } catch { toast.error("Failed to load tags"); } finally { setIsLoading(false); } - }, [search]); + }, [search, scopeFilter]); useEffect(() => { setIsLoading(true); @@ -107,8 +111,8 @@ function AdminTagsPage() {
- {/* Search */} -
+ {/* Filters */} +
setSearch(e.target.value)} /> +
{/* Table */} @@ -130,6 +144,8 @@ function AdminTagsPage() { Name + Scope + Community Recipes Actions @@ -139,6 +155,14 @@ function AdminTagsPage() { tags.map((tag) => ( {tag.name} + + + {tag.scope || "GLOBAL"} + + + + {tag.community?.name || "-"} + {tag.recipeCount}
@@ -151,7 +175,7 @@ function AdminTagsPage() { )) ) : ( - No tags found + No tags found )} From 54e0f81d8f8041e5be93548ca5842312e88a44de Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 09:30:26 +0100 Subject: [PATCH 010/221] feat: frontend tag suggestion modal, owner list & notifications (Phase 10.8) Connect frontend to tag suggestion backend endpoints: suggest tag button for non-owners, owner accept/reject list, and 4 new WebSocket notification types. Includes 15 new tests (316 total frontend). --- .claude/context/FILE_MAP.md | 9 +- .claude/context/PROGRESS.md | 4 +- .claude/context/TESTS.md | 8 +- docs/features/tags-rework/ROADMAP.md | 8 +- frontend/src/__tests__/setup/mswHandlers.ts | 131 +++++++++++++++ .../recipes/SuggestTagModal.test.tsx | 75 +++++++++ .../recipes/TagSuggestionsList.test.tsx | 98 ++++++++++++ .../unit/hooks/useNotificationToasts.test.ts | 52 ++++++ .../components/recipes/SuggestTagModal.tsx | 100 ++++++++++++ .../components/recipes/TagSuggestionsList.tsx | 149 ++++++++++++++++++ frontend/src/hooks/useNotificationToasts.ts | 4 + frontend/src/models/tagSuggestion.ts | 25 +++ frontend/src/network/api.ts | 26 +++ frontend/src/pages/RecipeDetailPage.tsx | 57 +++++-- 14 files changed, 725 insertions(+), 21 deletions(-) create mode 100644 frontend/src/__tests__/unit/components/recipes/SuggestTagModal.test.tsx create mode 100644 frontend/src/__tests__/unit/components/recipes/TagSuggestionsList.test.tsx create mode 100644 frontend/src/components/recipes/SuggestTagModal.tsx create mode 100644 frontend/src/components/recipes/TagSuggestionsList.tsx create mode 100644 frontend/src/models/tagSuggestion.ts diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index 752e9977..6d000f7c 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -211,7 +211,9 @@ components/ │ ├── RecipeCard.tsx # Carte recette (grille) │ ├── RecipeFilters.tsx # Filtres search/tags (scope-aware via communityId) │ ├── RecipeListRow.tsx # Ligne recette (liste) -│ └── TagBadge.tsx # Badge tag avec style pending/approved +│ ├── SuggestTagModal.tsx # Modal suggestion de tag sur recette d'autrui +│ ├── TagBadge.tsx # Badge tag avec style pending/approved +│ └── TagSuggestionsList.tsx # Liste suggestions de tags (owner view, accept/reject) ├── form/ │ ├── TagSelector.tsx # Multi-select tags (debounce, create on-the-fly) │ ├── IngredientSelector.tsx # Selecteur ingredients @@ -245,6 +247,7 @@ models/ ├── user.ts # User types ├── recipe.ts # Recipe, Tag, Ingredient types ├── tag.ts # Tag types +├── tagSuggestion.ts # TagSuggestion types ├── community.ts # Community, Member, Invite types └── admin.ts # AdminUser types ``` @@ -325,7 +328,9 @@ __tests__/ ├── recipes/ │ ├── RecipeCard.test.tsx │ ├── RecipeFilters.test.tsx - │ └── TagBadge.test.tsx + │ ├── SuggestTagModal.test.tsx + │ ├── TagBadge.test.tsx + │ └── TagSuggestionsList.test.tsx ├── form/ │ ├── TagSelector.test.tsx │ └── IngredientList.test.tsx diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index fad84a24..db2a5562 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.7 termine, prochaine etape 10.8 (Frontend TagSuggestion) +- **Sous-etape en cours** : 10.8 termine, prochaine etape 10.9 (Frontend Preferences) - **Branche** : `TagsRework` -- **Tests** : 783 (301 frontend + 485 backend) +- **Tests** : 798 (316 frontend + 485 backend) ## Prochains chantiers (a specifier) diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index d0c3fe56..5c30a9d5 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -40,7 +40,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Mocks: `__tests__/setup/mswHandlers.ts` - Utils: `__tests__/setup/testUtils.tsx` -## Inventaire des tests (~783 tests) +## Inventaire des tests (~798 tests) ### Backend Integration (20 fichiers, ~380 tests) @@ -80,7 +80,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | middleware/requireSuperAdmin.test.ts | requireSuperAdmin, requireAdminSession | 6 | | middleware/security.test.ts | requireHttps, rateLimiters, helmet | 5 | -### Frontend Unit (48 fichiers, ~301 tests) +### Frontend Unit (50 fichiers, ~316 tests) | Fichier | Composant | Tests | |---------|-----------|-------| @@ -116,12 +116,14 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | InviteUserModal.test.tsx | Modal invitation | 5 | | ActivityFeed.test.tsx | Feed activite | 8 | | ShareRecipeModal.test.tsx | Modal partage recette | 7 | +| recipes/SuggestTagModal.test.tsx | Modal suggestion tag | 5 | +| recipes/TagSuggestionsList.test.tsx | Liste suggestions tags owner | 5 | | hooks/useClickOutside.test.ts | Hook click outside | 4 | | hooks/useDebouncedEffect.test.ts | Hook debounce | 5 | | hooks/useConfirm.test.tsx | Hook confirm dialog | 6 | | hooks/useSocketEvent.test.ts | Hook socket event | 5 | | hooks/useCommunityRoom.test.ts | Hook community room | 4 | -| hooks/useNotificationToasts.test.ts | Hook notification toasts (+ tag events) | 8 | +| hooks/useNotificationToasts.test.ts | Hook notification toasts (+ tag events + suggestions) | 12 | | hooks/usePaginatedList.test.ts | Hook paginated list | 6 | | utils/formatDate.test.ts | Format date utils | 5 | | utils/communityEvents.test.ts | Community events bus | 2 | diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 10f06175..8e7295da 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -71,10 +71,10 @@ ## 10.8 - Frontend TagSuggestion -- [ ] Bouton "Suggerer un tag" sur recette d'autrui -- [ ] Vue owner : liste suggestions recues, accept/reject -- [ ] Notifications temps reel tag suggestions -- [ ] Tests +- [x] Bouton "Suggerer un tag" sur recette d'autrui +- [x] Vue owner : liste suggestions recues, accept/reject +- [x] Notifications temps reel tag suggestions +- [x] Tests ## 10.9 - Frontend Preferences diff --git a/frontend/src/__tests__/setup/mswHandlers.ts b/frontend/src/__tests__/setup/mswHandlers.ts index eb2c41c9..2a9f690f 100644 --- a/frontend/src/__tests__/setup/mswHandlers.ts +++ b/frontend/src/__tests__/setup/mswHandlers.ts @@ -135,6 +135,30 @@ export const mockReceivedInvites = [ }, ]; +// Mock tag suggestions data +export const mockTagSuggestions = [ + { + id: 'suggestion-1', + tagName: 'vegan', + status: 'PENDING_OWNER' as const, + createdAt: new Date().toISOString(), + decidedAt: null, + recipeId: 'test-recipe-id', + suggestedById: 'user-2', + suggestedBy: { id: 'user-2', username: 'alice' }, + }, + { + id: 'suggestion-2', + tagName: 'gluten-free', + status: 'PENDING_OWNER' as const, + createdAt: new Date().toISOString(), + decidedAt: null, + recipeId: 'test-recipe-id', + suggestedById: 'user-3', + suggestedBy: { id: 'user-3', username: 'bob' }, + }, +]; + // Mock community tags data export const mockCommunityTags = [ { @@ -1412,6 +1436,113 @@ export const handlers = [ return HttpResponse.json({ data: [] }); }), + // ===================================== + // Tag Suggestions + // ===================================== + + http.get(`${API_URL}/api/recipes/:recipeId/tag-suggestions`, ({ request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const url = new URL(request.url); + const status = url.searchParams.get('status'); + + let filtered = [...mockTagSuggestions]; + if (status) { + filtered = filtered.filter(s => s.status === status); + } + + return HttpResponse.json({ + data: filtered, + pagination: { total: filtered.length, limit: 20, offset: 0, hasMore: false }, + }); + }), + + http.post(`${API_URL}/api/recipes/:recipeId/tag-suggestions`, async ({ params, request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json() as Record; + if (!body.tagName) { + return HttpResponse.json( + { error: 'TAG_001: Tag name is required' }, + { status: 400 } + ); + } + + if (body.tagName === 'duplicate') { + return HttpResponse.json( + { error: 'TAG_005: Tag already suggested' }, + { status: 409 } + ); + } + + return HttpResponse.json({ + id: `suggestion-${Date.now()}`, + tagName: body.tagName, + status: 'PENDING_OWNER', + createdAt: new Date().toISOString(), + decidedAt: null, + recipeId: params.recipeId, + suggestedById: mockUser.id, + suggestedBy: { id: mockUser.id, username: mockUser.username }, + }, { status: 201 }); + }), + + http.post(`${API_URL}/api/tag-suggestions/:id/accept`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const suggestion = mockTagSuggestions.find(s => s.id === params.id); + if (!suggestion) { + return HttpResponse.json( + { error: 'TAG_006: Suggestion not found' }, + { status: 404 } + ); + } + + return HttpResponse.json({ + ...suggestion, + status: 'ACCEPTED', + decidedAt: new Date().toISOString(), + }); + }), + + http.post(`${API_URL}/api/tag-suggestions/:id/reject`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const suggestion = mockTagSuggestions.find(s => s.id === params.id); + if (!suggestion) { + return HttpResponse.json( + { error: 'TAG_006: Suggestion not found' }, + { status: 404 } + ); + } + + return HttpResponse.json({ + ...suggestion, + status: 'REJECTED', + decidedAt: new Date().toISOString(), + }); + }), + // ===================================== // Recipe Share // ===================================== diff --git a/frontend/src/__tests__/unit/components/recipes/SuggestTagModal.test.tsx b/frontend/src/__tests__/unit/components/recipes/SuggestTagModal.test.tsx new file mode 100644 index 00000000..e7181d6b --- /dev/null +++ b/frontend/src/__tests__/unit/components/recipes/SuggestTagModal.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../../setup/testUtils'; +import SuggestTagModal from '../../../../components/recipes/SuggestTagModal'; +import { setUserAuthenticated } from '../../../setup/mswHandlers'; + +describe('SuggestTagModal', () => { + const defaultProps = { + recipeId: 'test-recipe-id', + onClose: vi.fn(), + onSuggestionSubmitted: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + setUserAuthenticated(true); + }); + + it('should render modal with input and buttons', () => { + render(); + + expect(screen.getByText('Suggest a tag')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter tag name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /suggest tag/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('should disable submit when input is empty', () => { + render(); + + expect(screen.getByRole('button', { name: /suggest tag/i })).toBeDisabled(); + }); + + it('should enable submit when input has value', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Enter tag name'), 'vegan'); + expect(screen.getByRole('button', { name: /suggest tag/i })).toBeEnabled(); + }); + + it('should submit suggestion and call onSuggestionSubmitted', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Enter tag name'), 'vegan'); + await user.click(screen.getByRole('button', { name: /suggest tag/i })); + + await waitFor(() => { + expect(defaultProps.onSuggestionSubmitted).toHaveBeenCalledTimes(1); + }); + }); + + it('should show error on 409 conflict', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Enter tag name'), 'duplicate'); + await user.click(screen.getByRole('button', { name: /suggest tag/i })); + + await waitFor(() => { + expect(screen.getByText('Ce tag a deja ete suggere sur cette recette')).toBeInTheDocument(); + }); + expect(defaultProps.onSuggestionSubmitted).not.toHaveBeenCalled(); + }); + + it('should call onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/unit/components/recipes/TagSuggestionsList.test.tsx b/frontend/src/__tests__/unit/components/recipes/TagSuggestionsList.test.tsx new file mode 100644 index 00000000..21930866 --- /dev/null +++ b/frontend/src/__tests__/unit/components/recipes/TagSuggestionsList.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../../setup/testUtils'; +import TagSuggestionsList from '../../../../components/recipes/TagSuggestionsList'; +import { setUserAuthenticated } from '../../../setup/mswHandlers'; + +describe('TagSuggestionsList', () => { + const defaultProps = { + recipeId: 'test-recipe-id', + onSuggestionDecided: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + setUserAuthenticated(true); + }); + + it('should render suggestions list with pending suggestions', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Suggestions (2)')).toBeInTheDocument(); + }); + + expect(screen.getByText('vegan')).toBeInTheDocument(); + expect(screen.getByText('gluten-free')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + }); + + it('should show accept and reject buttons for each suggestion', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Suggestions (2)')).toBeInTheDocument(); + }); + + const acceptButtons = screen.getAllByRole('button', { name: /accept/i }); + const rejectButtons = screen.getAllByRole('button', { name: /reject/i }); + expect(acceptButtons).toHaveLength(2); + expect(rejectButtons).toHaveLength(2); + }); + + it('should call onSuggestionDecided when accepting', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Suggestions (2)')).toBeInTheDocument(); + }); + + const acceptButtons = screen.getAllByRole('button', { name: /accept/i }); + await user.click(acceptButtons[0]); + + await waitFor(() => { + expect(defaultProps.onSuggestionDecided).toHaveBeenCalledTimes(1); + }); + }); + + it('should call onSuggestionDecided when rejecting', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Suggestions (2)')).toBeInTheDocument(); + }); + + const rejectButtons = screen.getAllByRole('button', { name: /reject/i }); + await user.click(rejectButtons[0]); + + await waitFor(() => { + expect(defaultProps.onSuggestionDecided).toHaveBeenCalledTimes(1); + }); + }); + + it('should render nothing when no suggestions', async () => { + const { server } = await import('../../../setup/mswServer'); + const { http, HttpResponse } = await import('msw'); + + server.use( + http.get('http://localhost:3001/api/recipes/:recipeId/tag-suggestions', () => { + return HttpResponse.json({ + data: [], + pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, + }); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Tag Suggestions/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts b/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts index b21f0a36..1f1be214 100644 --- a/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts +++ b/frontend/src/__tests__/unit/hooks/useNotificationToasts.test.ts @@ -109,6 +109,58 @@ describe("useNotificationToasts", () => { ); }); + it("should show toast for TAG_SUGGESTION_CREATED", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_SUGGESTION_CREATED", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Nouvelle suggestion de tag sur votre recette", + expect.any(Object) + ); + }); + + it("should show toast for TAG_SUGGESTION_ACCEPTED", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_SUGGESTION_ACCEPTED", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Votre suggestion de tag a ete acceptee", + expect.any(Object) + ); + }); + + it("should show toast for TAG_SUGGESTION_REJECTED", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "TAG_SUGGESTION_REJECTED", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Votre suggestion de tag a ete refusee", + expect.any(Object) + ); + }); + + it("should show toast for tag-suggestion:pending-mod", () => { + renderHook(() => useNotificationToasts()); + const handler = mockSocket.on.mock.calls.find( + (call) => call[0] === "notification" + )?.[1]; + + handler({ type: "tag-suggestion:pending-mod", userId: "u1", communityId: "c1" }); + expect(mockToast).toHaveBeenCalledWith( + "Un tag suggere attend votre validation", + expect.any(Object) + ); + }); + it("should skip unknown notification type", () => { renderHook(() => useNotificationToasts()); const handler = mockSocket.on.mock.calls.find( diff --git a/frontend/src/components/recipes/SuggestTagModal.tsx b/frontend/src/components/recipes/SuggestTagModal.tsx new file mode 100644 index 00000000..258c8086 --- /dev/null +++ b/frontend/src/components/recipes/SuggestTagModal.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { FaPaperPlane } from "react-icons/fa"; +import Modal from "../Modal"; +import APIManager from "../../network/api"; +import { ConflictError } from "../../errors/http_errors"; + +interface SuggestTagModalProps { + recipeId: string; + onClose: () => void; + onSuggestionSubmitted: () => void; +} + +const SuggestTagModal = ({ + recipeId, + onClose, + onSuggestionSubmitted, +}: SuggestTagModalProps) => { + const [tagName, setTagName] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isValid = tagName.trim().length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isValid) return; + + try { + setIsSubmitting(true); + setError(null); + await APIManager.createTagSuggestion(recipeId, tagName.trim()); + onSuggestionSubmitted(); + } catch (err) { + if (err instanceof ConflictError) { + setError("Ce tag a deja ete suggere sur cette recette"); + } else { + setError(err instanceof Error ? err.message : "Failed to suggest tag"); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + +

Suggest a tag

+

+ Suggest a tag to add to this recipe. The owner will review your suggestion. +

+ +
+
+ + setTagName(e.target.value)} + placeholder="Enter tag name" + className="input input-bordered w-full" + disabled={isSubmitting} + autoFocus + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +}; + +export default SuggestTagModal; diff --git a/frontend/src/components/recipes/TagSuggestionsList.tsx b/frontend/src/components/recipes/TagSuggestionsList.tsx new file mode 100644 index 00000000..c6ae2621 --- /dev/null +++ b/frontend/src/components/recipes/TagSuggestionsList.tsx @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback } from "react"; +import toast from "react-hot-toast"; +import { FaCheck, FaTimes, FaUser, FaTag } from "react-icons/fa"; +import APIManager from "../../network/api"; +import { TagSuggestion } from "../../models/tagSuggestion"; +import { formatDate } from "../../utils/format.Date"; + +interface TagSuggestionsListProps { + recipeId: string; + refreshSignal?: number; + onSuggestionDecided: () => void; +} + +const TagSuggestionsList = ({ recipeId, refreshSignal, onSuggestionDecided }: TagSuggestionsListProps) => { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [processingId, setProcessingId] = useState(null); + + const loadSuggestions = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const response = await APIManager.getTagSuggestions(recipeId, "PENDING_OWNER"); + setSuggestions(response.data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load tag suggestions"); + } finally { + setIsLoading(false); + } + }, [recipeId]); + + useEffect(() => { + loadSuggestions(); + }, [loadSuggestions, refreshSignal]); + + const handleAccept = async (suggestionId: string) => { + try { + setProcessingId(suggestionId); + setError(null); + await APIManager.acceptTagSuggestion(suggestionId); + toast.success("Tag suggestion accepted"); + onSuggestionDecided(); + loadSuggestions(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to accept suggestion"); + } finally { + setProcessingId(null); + } + }; + + const handleReject = async (suggestionId: string) => { + try { + setProcessingId(suggestionId); + setError(null); + await APIManager.rejectTagSuggestion(suggestionId); + toast.success("Tag suggestion rejected"); + onSuggestionDecided(); + loadSuggestions(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to reject suggestion"); + } finally { + setProcessingId(null); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (suggestions.length === 0) { + return null; + } + + return ( +
+

+ + Tag Suggestions ({suggestions.length}) +

+ + {error && ( +
+ {error} +
+ )} + +
+ {suggestions.map((suggestion) => ( +
+
+
+
+
+ + {suggestion.suggestedBy.username} + - + {formatDate(suggestion.createdAt)} +
+

+ {suggestion.tagName} +

+
+ +
+ + +
+
+
+
+ ))} +
+
+ ); +}; + +export default TagSuggestionsList; diff --git a/frontend/src/hooks/useNotificationToasts.ts b/frontend/src/hooks/useNotificationToasts.ts index c297ba35..d71fc31c 100644 --- a/frontend/src/hooks/useNotificationToasts.ts +++ b/frontend/src/hooks/useNotificationToasts.ts @@ -20,6 +20,10 @@ const notificationMessages: Record = { TAG_PENDING: "Nouveau tag en attente de validation", TAG_APPROVED: "Votre tag a ete valide", TAG_REJECTED: "Votre tag a ete rejete", + TAG_SUGGESTION_CREATED: "Nouvelle suggestion de tag sur votre recette", + TAG_SUGGESTION_ACCEPTED: "Votre suggestion de tag a ete acceptee", + TAG_SUGGESTION_REJECTED: "Votre suggestion de tag a ete refusee", + "tag-suggestion:pending-mod": "Un tag suggere attend votre validation", }; export function useNotificationToasts() { diff --git a/frontend/src/models/tagSuggestion.ts b/frontend/src/models/tagSuggestion.ts new file mode 100644 index 00000000..a93e7ec0 --- /dev/null +++ b/frontend/src/models/tagSuggestion.ts @@ -0,0 +1,25 @@ +export type TagSuggestionStatus = "PENDING_OWNER" | "ACCEPTED" | "REJECTED" | "PENDING_MODERATOR"; + +export interface TagSuggestion { + id: string; + tagName: string; + status: TagSuggestionStatus; + createdAt: string; + decidedAt: string | null; + recipeId: string; + suggestedById: string; + suggestedBy: { + id: string; + username: string; + }; +} + +export interface TagSuggestionsResponse { + data: TagSuggestion[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 79a06f62..94e68247 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -4,6 +4,7 @@ import { ActivityResponse } from "../models/activity"; import { User } from "../models/user"; import { AdminLoginResponse, AdminTotpResponse, AdminUser, DashboardStats, AdminTag, AdminIngredient, AdminFeature, AdminCommunity, AdminCommunityDetail, AdminActivityResponse } from "../models/admin"; import { CommunityTag } from "../models/tag"; +import { TagSuggestion, TagSuggestionsResponse } from "../models/tagSuggestion"; import { CommunityListItem, CommunityDetail, CommunityMember, CommunityInvite, ReceivedInvite } from "../models/community"; import { ConflictError, UnauthorizedError } from "../errors/http_errors"; @@ -217,6 +218,31 @@ export default class APIManager { } + // --------------- Tag Suggestions --------------- + + static async getTagSuggestions(recipeId: string, status?: string): Promise { + const params = status ? `?status=${status}` : ""; + const response = await API.get(`/api/recipes/${recipeId}/tag-suggestions${params}`).catch(handleApiError); + return response.data; + } + + static async createTagSuggestion(recipeId: string, tagName: string): Promise { + const response = await API.post(`/api/recipes/${recipeId}/tag-suggestions`, JSON.stringify({ tagName })) + .catch(handleApiErrorWith({ 409: ConflictError })); + return response.data; + } + + static async acceptTagSuggestion(suggestionId: string): Promise { + const response = await API.post(`/api/tag-suggestions/${suggestionId}/accept`).catch(handleApiError); + return response.data; + } + + static async rejectTagSuggestion(suggestionId: string): Promise { + const response = await API.post(`/api/tag-suggestions/${suggestionId}/reject`).catch(handleApiError); + return response.data; + } + + // --------------- Tags --------------- static async searchTags(search: string = "", limit: number = 20, communityId?: string): Promise { diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 9f06c296..a3458654 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; -import { FaArrowLeft, FaEdit, FaTrash, FaLightbulb, FaShare, FaCodeBranch } from "react-icons/fa"; +import { FaArrowLeft, FaEdit, FaTrash, FaLightbulb, FaShare, FaCodeBranch, FaTag } from "react-icons/fa"; import APIManager from "../network/api"; import { RecipeDetail } from "../models/recipe"; import { useAuth } from "../contexts/AuthContext"; @@ -9,6 +9,8 @@ import TagBadge from "../components/recipes/TagBadge"; import { formatDate } from "../utils/format.Date"; import { ProposeModificationModal, ProposalsList, VariantsDropdown } from "../components/proposals"; import { ShareRecipeModal, SharePersonalRecipeModal } from "../components/share"; +import SuggestTagModal from "../components/recipes/SuggestTagModal"; +import TagSuggestionsList from "../components/recipes/TagSuggestionsList"; const RecipeDetailPage = () => { const { id } = useParams<{ id: string }>(); @@ -18,8 +20,9 @@ const RecipeDetailPage = () => { const [recipe, setRecipe] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [openModal, setOpenModal] = useState<"propose" | "share" | "publish" | null>(null); + const [openModal, setOpenModal] = useState<"propose" | "share" | "publish" | "suggest-tag" | null>(null); const [proposalsRefresh, setProposalsRefresh] = useState(0); + const [suggestionsRefresh, setSuggestionsRefresh] = useState(0); useEffect(() => { async function loadRecipe() { @@ -86,6 +89,17 @@ const RecipeDetailPage = () => { setProposalsRefresh((k) => k + 1); }; + const handleSuggestionSubmitted = () => { + setOpenModal(null); + setSuggestionsRefresh((k) => k + 1); + toast.success("Tag suggestion submitted"); + }; + + const handleSuggestionDecided = () => { + loadRecipeData(); + setSuggestionsRefresh((k) => k + 1); + }; + const handleRecipeShared = (newRecipeId: string) => { setOpenModal(null); toast.success("Recipe shared successfully"); @@ -200,14 +214,24 @@ const RecipeDetailPage = () => { )} {canPropose && ( - + <> + + + )} {isOwner && ( <> @@ -278,11 +302,24 @@ const RecipeDetailPage = () => { refreshSignal={proposalsRefresh} onProposalDecided={handleProposalDecided} /> + )}
+ {openModal === "suggest-tag" && ( + setOpenModal(null)} + onSuggestionSubmitted={handleSuggestionSubmitted} + /> + )} + {openModal === "propose" && ( Date: Tue, 17 Feb 2026 09:38:12 +0100 Subject: [PATCH 011/221] feat: frontend tag & notification preferences on profile page (Phase 10.9) Add TagPreferencesSection and NotificationPreferencesSection components to the profile page, with optimistic toggle updates, moderator-only visibility for notifications, and 10 new tests. --- .claude/context/FILE_MAP.md | 7 + .claude/context/PROGRESS.md | 4 +- .claude/context/TESTS.md | 6 +- docs/features/tags-rework/ROADMAP.md | 6 +- frontend/src/__tests__/setup/mswHandlers.ts | 80 +++++++++++ .../NotificationPreferencesSection.test.tsx | 106 +++++++++++++++ .../profile/TagPreferencesSection.test.tsx | 119 +++++++++++++++++ .../NotificationPreferencesSection.tsx | 125 ++++++++++++++++++ .../profile/TagPreferencesSection.tsx | 83 ++++++++++++ frontend/src/models/preferences.ts | 14 ++ frontend/src/network/api.ts | 29 ++++ frontend/src/pages/ProfilePage.tsx | 8 ++ 12 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 frontend/src/__tests__/unit/components/profile/NotificationPreferencesSection.test.tsx create mode 100644 frontend/src/__tests__/unit/components/profile/TagPreferencesSection.test.tsx create mode 100644 frontend/src/components/profile/NotificationPreferencesSection.tsx create mode 100644 frontend/src/components/profile/TagPreferencesSection.tsx create mode 100644 frontend/src/models/preferences.ts diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index 6d000f7c..301897b9 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -214,6 +214,9 @@ components/ │ ├── SuggestTagModal.tsx # Modal suggestion de tag sur recette d'autrui │ ├── TagBadge.tsx # Badge tag avec style pending/approved │ └── TagSuggestionsList.tsx # Liste suggestions de tags (owner view, accept/reject) +├── profile/ +│ ├── TagPreferencesSection.tsx # Toggle tag visibility per community +│ └── NotificationPreferencesSection.tsx # Toggle tag notifications (moderator) ├── form/ │ ├── TagSelector.tsx # Multi-select tags (debounce, create on-the-fly) │ ├── IngredientSelector.tsx # Selecteur ingredients @@ -249,6 +252,7 @@ models/ ├── tag.ts # Tag types ├── tagSuggestion.ts # TagSuggestion types ├── community.ts # Community, Member, Invite types +├── preferences.ts # TagPreference, NotificationPreferences types └── admin.ts # AdminUser types ``` @@ -317,6 +321,9 @@ __tests__/ │ ├── AdminCommunitiesPage.test.tsx │ └── AdminActivityPage.test.tsx └── components/ + ├── profile/ + │ ├── TagPreferencesSection.test.tsx + │ └── NotificationPreferencesSection.test.tsx ├── communities/ │ └── CommunityTagsList.test.tsx ├── Layout/ diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index db2a5562..dd7dce66 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -8,9 +8,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.8 termine, prochaine etape 10.9 (Frontend Preferences) +- **Sous-etape en cours** : 10.9 termine - Phase 10 complete - **Branche** : `TagsRework` -- **Tests** : 798 (316 frontend + 485 backend) +- **Tests** : 808 (326 frontend + 485 backend) ## Prochains chantiers (a specifier) diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index 5c30a9d5..52aa6eef 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -40,7 +40,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Mocks: `__tests__/setup/mswHandlers.ts` - Utils: `__tests__/setup/testUtils.tsx` -## Inventaire des tests (~798 tests) +## Inventaire des tests (~808 tests) ### Backend Integration (20 fichiers, ~380 tests) @@ -80,7 +80,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | middleware/requireSuperAdmin.test.ts | requireSuperAdmin, requireAdminSession | 6 | | middleware/security.test.ts | requireHttps, rateLimiters, helmet | 5 | -### Frontend Unit (50 fichiers, ~316 tests) +### Frontend Unit (52 fichiers, ~326 tests) | Fichier | Composant | Tests | |---------|-----------|-------| @@ -118,6 +118,8 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | ShareRecipeModal.test.tsx | Modal partage recette | 7 | | recipes/SuggestTagModal.test.tsx | Modal suggestion tag | 5 | | recipes/TagSuggestionsList.test.tsx | Liste suggestions tags owner | 5 | +| profile/TagPreferencesSection.test.tsx | Toggle tag visibility per community | 5 | +| profile/NotificationPreferencesSection.test.tsx | Toggle tag notifications (moderator) | 5 | | hooks/useClickOutside.test.ts | Hook click outside | 4 | | hooks/useDebouncedEffect.test.ts | Hook debounce | 5 | | hooks/useConfirm.test.tsx | Hook confirm dialog | 6 | diff --git a/docs/features/tags-rework/ROADMAP.md b/docs/features/tags-rework/ROADMAP.md index 8e7295da..51537112 100644 --- a/docs/features/tags-rework/ROADMAP.md +++ b/docs/features/tags-rework/ROADMAP.md @@ -78,6 +78,6 @@ ## 10.9 - Frontend Preferences -- [ ] Page profil : toggle visibilite tags par communaute -- [ ] Page profil moderateur : toggle notifications tags par communaute + global -- [ ] Tests +- [x] Page profil : toggle visibilite tags par communaute +- [x] Page profil moderateur : toggle notifications tags par communaute + global +- [x] Tests diff --git a/frontend/src/__tests__/setup/mswHandlers.ts b/frontend/src/__tests__/setup/mswHandlers.ts index 2a9f690f..59271501 100644 --- a/frontend/src/__tests__/setup/mswHandlers.ts +++ b/frontend/src/__tests__/setup/mswHandlers.ts @@ -1543,6 +1543,86 @@ export const handlers = [ }); }), + // ===================================== + // User Tag Preferences + // ===================================== + + http.get(`${API_URL}/api/users/me/tag-preferences`, () => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + return HttpResponse.json({ + data: [ + { communityId: 'community-1', communityName: 'Baking Club', showTags: true }, + { communityId: 'community-2', communityName: 'Vegan Recipes', showTags: false }, + ], + }); + }), + + http.put(`${API_URL}/api/users/me/tag-preferences/:communityId`, async ({ params, request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json() as Record; + return HttpResponse.json({ + communityId: params.communityId, + showTags: body.showTags, + }); + }), + + // ===================================== + // User Notification Preferences + // ===================================== + + http.get(`${API_URL}/api/users/me/notification-preferences`, () => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + return HttpResponse.json({ + global: { tagNotifications: true }, + communities: [ + { communityId: 'community-1', communityName: 'Baking Club', tagNotifications: true }, + ], + }); + }), + + http.put(`${API_URL}/api/users/me/notification-preferences/tags`, async ({ request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json() as Record; + return HttpResponse.json({ tagNotifications: body.tagNotifications }); + }), + + http.put(`${API_URL}/api/users/me/notification-preferences/tags/:communityId`, async ({ params, request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json() as Record; + return HttpResponse.json({ + communityId: params.communityId, + tagNotifications: body.tagNotifications, + }); + }), + // ===================================== // Recipe Share // ===================================== diff --git a/frontend/src/__tests__/unit/components/profile/NotificationPreferencesSection.test.tsx b/frontend/src/__tests__/unit/components/profile/NotificationPreferencesSection.test.tsx new file mode 100644 index 00000000..2fc4f34c --- /dev/null +++ b/frontend/src/__tests__/unit/components/profile/NotificationPreferencesSection.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AuthProvider } from '../../../../contexts/AuthContext'; +import NotificationPreferencesSection from '../../../../components/profile/NotificationPreferencesSection'; +import { setUserAuthenticated, resetAuthState } from '../../../setup/mswHandlers'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../setup/mswServer'; + +function TestApp() { + return ( + + + + + + ); +} + +describe('NotificationPreferencesSection', () => { + beforeEach(() => { + resetAuthState(); + setUserAuthenticated(true); + }); + + it('should display global toggle and community toggles for moderator', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Notifications')).toBeInTheDocument(); + }); + + expect(screen.getByLabelText('Global tag notifications')).toBeChecked(); + expect(screen.getByText('Baking Club')).toBeInTheDocument(); + expect(screen.getByLabelText('Tag notifications for Baking Club')).toBeChecked(); + }); + + it('should toggle global notification preference', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Tag Notifications')).toBeInTheDocument(); + }); + + const globalToggle = screen.getByLabelText('Global tag notifications'); + expect(globalToggle).toBeChecked(); + + await user.click(globalToggle); + + expect(globalToggle).not.toBeChecked(); + }); + + it('should toggle community notification preference', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Baking Club')).toBeInTheDocument(); + }); + + const communityToggle = screen.getByLabelText('Tag notifications for Baking Club'); + expect(communityToggle).toBeChecked(); + + await user.click(communityToggle); + + expect(communityToggle).not.toBeChecked(); + }); + + it('should hide section when not a moderator (empty communities)', async () => { + server.use( + http.get('http://localhost:3001/api/users/me/notification-preferences', () => { + return HttpResponse.json({ + global: { tagNotifications: false }, + communities: [], + }); + }), + ); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('.loading')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('Tag Notifications')).not.toBeInTheDocument(); + }); + + it('should hide section on 403 error (non-moderator)', async () => { + server.use( + http.get('http://localhost:3001/api/users/me/notification-preferences', () => { + return HttpResponse.json({ error: 'Forbidden' }, { status: 403 }); + }), + ); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('.loading')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('Tag Notifications')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/unit/components/profile/TagPreferencesSection.test.tsx b/frontend/src/__tests__/unit/components/profile/TagPreferencesSection.test.tsx new file mode 100644 index 00000000..a4eef29c --- /dev/null +++ b/frontend/src/__tests__/unit/components/profile/TagPreferencesSection.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AuthProvider } from '../../../../contexts/AuthContext'; +import TagPreferencesSection from '../../../../components/profile/TagPreferencesSection'; +import { setUserAuthenticated, resetAuthState } from '../../../setup/mswHandlers'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../setup/mswServer'; + +function TestApp() { + return ( + + + + + + ); +} + +describe('TagPreferencesSection', () => { + beforeEach(() => { + resetAuthState(); + setUserAuthenticated(true); + }); + + it('should display communities with toggles', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Baking Club')).toBeInTheDocument(); + expect(screen.getByText('Vegan Recipes')).toBeInTheDocument(); + }); + + expect(screen.getByText('Tag Visibility')).toBeInTheDocument(); + + const bakingToggle = screen.getByLabelText('Show tags from Baking Club'); + const veganToggle = screen.getByLabelText('Show tags from Vegan Recipes'); + + expect(bakingToggle).toBeChecked(); + expect(veganToggle).not.toBeChecked(); + }); + + it('should toggle a preference value', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Vegan Recipes')).toBeInTheDocument(); + }); + + const veganToggle = screen.getByLabelText('Show tags from Vegan Recipes'); + expect(veganToggle).not.toBeChecked(); + + await user.click(veganToggle); + + // Optimistic update should change it immediately + expect(veganToggle).toBeChecked(); + }); + + it('should hide section when no communities', async () => { + server.use( + http.get('http://localhost:3001/api/users/me/tag-preferences', () => { + return HttpResponse.json({ data: [] }); + }), + ); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('.loading')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('Tag Visibility')).not.toBeInTheDocument(); + }); + + it('should hide section on error', async () => { + server.use( + http.get('http://localhost:3001/api/users/me/tag-preferences', () => { + return HttpResponse.json({ error: 'Server error' }, { status: 500 }); + }), + ); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('.loading')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('Tag Visibility')).not.toBeInTheDocument(); + }); + + it('should revert toggle on API error', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Baking Club')).toBeInTheDocument(); + }); + + // Override PUT to fail + server.use( + http.put('http://localhost:3001/api/users/me/tag-preferences/:communityId', () => { + return HttpResponse.json({ error: 'Failed' }, { status: 500 }); + }), + ); + + const bakingToggle = screen.getByLabelText('Show tags from Baking Club'); + expect(bakingToggle).toBeChecked(); + + await user.click(bakingToggle); + + // Should revert back to checked after API error + await waitFor(() => { + expect(bakingToggle).toBeChecked(); + }); + }); +}); diff --git a/frontend/src/components/profile/NotificationPreferencesSection.tsx b/frontend/src/components/profile/NotificationPreferencesSection.tsx new file mode 100644 index 00000000..d57faff3 --- /dev/null +++ b/frontend/src/components/profile/NotificationPreferencesSection.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from "react"; +import { NotificationPreferences } from "../../models/preferences"; +import APIManager from "../../network/api"; + +const NotificationPreferencesSection = () => { + const [prefs, setPrefs] = useState(null); + const [loading, setLoading] = useState(true); + const [visible, setVisible] = useState(true); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + const res = await APIManager.getNotificationPreferences(); + if (!cancelled) { + // Hide section if no communities (not a moderator) + if (res.communities.length === 0) { + setVisible(false); + } else { + setPrefs(res); + } + } + } catch { + // 403 or any error → hide section + if (!cancelled) setVisible(false); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + return () => { cancelled = true; }; + }, []); + + const handleGlobalToggle = async () => { + if (!prefs) return; + const newValue = !prefs.global.tagNotifications; + + // Optimistic update + setPrefs({ ...prefs, global: { tagNotifications: newValue } }); + + try { + await APIManager.updateGlobalNotificationPreference(newValue); + } catch { + setPrefs(prev => prev ? { ...prev, global: { tagNotifications: !newValue } } : prev); + } + }; + + const handleCommunityToggle = async (communityId: string, currentValue: boolean) => { + if (!prefs) return; + const newValue = !currentValue; + + // Optimistic update + setPrefs({ + ...prefs, + communities: prefs.communities.map(c => + c.communityId === communityId ? { ...c, tagNotifications: newValue } : c + ), + }); + + try { + await APIManager.updateCommunityNotificationPreference(communityId, newValue); + } catch { + setPrefs(prev => prev ? { + ...prev, + communities: prev.communities.map(c => + c.communityId === communityId ? { ...c, tagNotifications: currentValue } : c + ), + } : prev); + } + }; + + if (!visible || loading) { + if (loading && visible) { + return ( +
+
+ +
+
+ ); + } + return null; + } + + if (!prefs || prefs.communities.length === 0) return null; + + return ( +
+

Tag Notifications

+

+ Manage notifications for pending tag approvals in communities you moderate. +

+ +
+
+ Receive tag notifications (global) + +
+ + {prefs.communities.map(comm => ( +
+ {comm.communityName} + handleCommunityToggle(comm.communityId, comm.tagNotifications)} + aria-label={`Tag notifications for ${comm.communityName}`} + /> +
+ ))} +
+
+ ); +}; + +export default NotificationPreferencesSection; diff --git a/frontend/src/components/profile/TagPreferencesSection.tsx b/frontend/src/components/profile/TagPreferencesSection.tsx new file mode 100644 index 00000000..92e06c73 --- /dev/null +++ b/frontend/src/components/profile/TagPreferencesSection.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect } from "react"; +import { TagPreference } from "../../models/preferences"; +import APIManager from "../../network/api"; + +const TagPreferencesSection = () => { + const [preferences, setPreferences] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + const res = await APIManager.getTagPreferences(); + if (!cancelled) setPreferences(res.data); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load preferences"); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + return () => { cancelled = true; }; + }, []); + + const handleToggle = async (communityId: string, currentValue: boolean) => { + const newValue = !currentValue; + + // Optimistic update + setPreferences(prev => + prev.map(p => p.communityId === communityId ? { ...p, showTags: newValue } : p) + ); + + try { + await APIManager.updateTagPreference(communityId, newValue); + } catch { + // Revert on error + setPreferences(prev => + prev.map(p => p.communityId === communityId ? { ...p, showTags: currentValue } : p) + ); + } + }; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + if (error || preferences.length === 0) return null; + + return ( +
+

Tag Visibility

+

+ Control which community tags are visible in your personal recipe catalog. +

+ +
+ {preferences.map(pref => ( +
+ {pref.communityName} + handleToggle(pref.communityId, pref.showTags)} + aria-label={`Show tags from ${pref.communityName}`} + /> +
+ ))} +
+
+ ); +}; + +export default TagPreferencesSection; diff --git a/frontend/src/models/preferences.ts b/frontend/src/models/preferences.ts new file mode 100644 index 00000000..e76fc725 --- /dev/null +++ b/frontend/src/models/preferences.ts @@ -0,0 +1,14 @@ +export interface TagPreference { + communityId: string; + communityName: string; + showTags: boolean; +} + +export interface NotificationPreferences { + global: { tagNotifications: boolean }; + communities: { + communityId: string; + communityName: string; + tagNotifications: boolean; + }[]; +} diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 94e68247..9e316826 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -5,6 +5,7 @@ import { User } from "../models/user"; import { AdminLoginResponse, AdminTotpResponse, AdminUser, DashboardStats, AdminTag, AdminIngredient, AdminFeature, AdminCommunity, AdminCommunityDetail, AdminActivityResponse } from "../models/admin"; import { CommunityTag } from "../models/tag"; import { TagSuggestion, TagSuggestionsResponse } from "../models/tagSuggestion"; +import { TagPreference, NotificationPreferences } from "../models/preferences"; import { CommunityListItem, CommunityDetail, CommunityMember, CommunityInvite, ReceivedInvite } from "../models/community"; import { ConflictError, UnauthorizedError } from "../errors/http_errors"; @@ -348,6 +349,34 @@ export default class APIManager { } + // --------------- User Preferences --------------- + + static async getTagPreferences(): Promise<{ data: TagPreference[] }> { + const response = await API.get("/api/users/me/tag-preferences").catch(handleApiError); + return response.data; + } + + static async updateTagPreference(communityId: string, showTags: boolean): Promise<{ communityId: string; showTags: boolean }> { + const response = await API.put(`/api/users/me/tag-preferences/${communityId}`, JSON.stringify({ showTags })).catch(handleApiError); + return response.data; + } + + static async getNotificationPreferences(): Promise { + const response = await API.get("/api/users/me/notification-preferences").catch(handleApiError); + return response.data; + } + + static async updateGlobalNotificationPreference(tagNotifications: boolean): Promise<{ tagNotifications: boolean }> { + const response = await API.put("/api/users/me/notification-preferences/tags", JSON.stringify({ tagNotifications })).catch(handleApiError); + return response.data; + } + + static async updateCommunityNotificationPreference(communityId: string, tagNotifications: boolean): Promise<{ communityId: string; tagNotifications: boolean }> { + const response = await API.put(`/api/users/me/notification-preferences/tags/${communityId}`, JSON.stringify({ tagNotifications })).catch(handleApiError); + return response.data; + } + + // --------------- Invitations (community admin) --------------- static async getCommunityInvites(communityId: string, status?: string): Promise<{ data: CommunityInvite[] }> { diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index c1d0fae9..c90abc7d 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from "react"; import { FaSave } from "react-icons/fa"; import { useAuth } from "../contexts/AuthContext"; import APIManager from "../network/api"; +import TagPreferencesSection from "../components/profile/TagPreferencesSection"; +import NotificationPreferencesSection from "../components/profile/NotificationPreferencesSection"; const ProfilePage = () => { const { user, refreshUser } = useAuth(); @@ -211,6 +213,12 @@ const ProfilePage = () => {
+ + {/* Tag Preferences */} + + + {/* Notification Preferences */} +
); }; From b415405364bdf2296e5a54fe2c48cecda1c1263c Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 09:45:17 +0100 Subject: [PATCH 012/221] Update docs with manual tests --- docs/features/tags-rework/MANUAL_TESTS.md | 468 ++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/features/tags-rework/MANUAL_TESTS.md diff --git a/docs/features/tags-rework/MANUAL_TESTS.md b/docs/features/tags-rework/MANUAL_TESTS.md new file mode 100644 index 00000000..ee1399b3 --- /dev/null +++ b/docs/features/tags-rework/MANUAL_TESTS.md @@ -0,0 +1,468 @@ +# Protocoles de tests manuels - Rework Tags (Phase 10) + +> **Feature** : Tags 3 niveaux (Global / Communaute / Pending) + validation moderateurs + suggestions +> **Spec** : `SPEC_TAGS_REWORK.md` +> **Branche** : `TagsRework` + +--- + +## Pre-requis + +### Environnement + +```bash +npm run docker:up:build +``` + +### Comptes necessaires + +| Compte | Role | Comment l'obtenir | +|--------|------|-------------------| +| **SuperAdmin** | Administrateur plateforme | `npx ts-node scripts/createAdmin.ts` (dans container backend) | +| **UserA** | MODERATOR dans CommunauteAlpha | Creer communaute (auto-MODERATOR) | +| **UserB** | MEMBER dans CommunauteAlpha | Invite par UserA | +| **UserC** | MODERATOR dans CommunauteBeta | Creer CommunauteBeta | +| **UserD** | MEMBER dans CommunauteBeta, pas dans Alpha | Invite par UserC | + +### Donnees de base + +1. SuperAdmin cree 3 tags globaux : `dessert`, `rapide`, `vegetarien` +2. UserA cree CommunauteAlpha, invite UserB +3. UserC cree CommunauteBeta, invite UserD +4. Quelques recettes existent dans chaque communaute + +--- + +## T1 - Tags globaux (SuperAdmin) + +### T1.1 - Creation tag global + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Se connecter au panel admin | Dashboard admin visible | +| 2 | Aller dans "Tags" | Liste des tags avec filtre scope | +| 3 | Filtre scope = "Global" | Seuls les tags GLOBAL affiches | +| 4 | Creer un tag "italien" | Tag cree, scope=GLOBAL, status=APPROVED, visible dans la liste | +| 5 | Creer un tag "ITALIEN" (majuscules) | Erreur : nom deja utilise (normalisation lowercase) | +| 6 | Creer un tag " pizza " (espaces) | Tag "pizza" cree (trim applique) | + +### T1.2 - Renommage et suppression tag global + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Renommer "pizza" en "pizzeria" | Tag renomme, nom mis a jour partout | +| 2 | Renommer "pizzeria" en "italien" | Erreur : nom deja utilise | +| 3 | Supprimer "pizzeria" | Tag supprime, retire de toutes les recettes associees | + +### T1.3 - Fusion de tags globaux + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Creer tags "gateau" et "patisserie" | Les 2 tags existent | +| 2 | Associer "gateau" a 2 recettes | RecipeTags crees | +| 3 | Fusionner "gateau" dans "patisserie" | "gateau" supprime, ses RecipeTags pointent vers "patisserie" | + +### T1.4 - Filtre scope dans panel admin + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Filtre "All" | Tags globaux + communaute visibles | +| 2 | Filtre "Global" | Seuls les tags scope=GLOBAL | +| 3 | Filtre "Community" | Seuls les tags scope=COMMUNITY (avec nom communaute) | +| 4 | Recherche "dess" | Tags dont le nom contient "dess" (ex: dessert) | + +--- + +## T2 - Tags communaute (Moderateur) + +### T2.1 - Creation tag communaute par moderateur + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Connecte en tant que UserA | Dashboard visible | +| 2 | Aller dans CommunauteAlpha > Tags (side panel) | Liste tags communaute (APPROVED + PENDING) | +| 3 | Creer tag "fait-maison" | Tag cree : scope=COMMUNITY, communityId=Alpha, status=APPROVED | +| 4 | Le tag apparait dans la liste | Status "Approved", nom affiche | +| 5 | Creer tag "dessert" (meme nom qu'un global) | Erreur : un tag global avec ce nom existe deja | +| 6 | Creer tag "fait-maison" a nouveau | Erreur : nom deja utilise dans cette communaute | + +### T2.2 - Renommage et suppression tag communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Renommer "fait-maison" en "artisanal" | Tag renomme | +| 2 | Supprimer "artisanal" | Tag supprime, hard delete RecipeTags associes | +| 3 | Verifier recettes qui avaient ce tag | Le tag n'apparait plus sur les recettes | + +### T2.3 - Isolation entre communautes + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA cree tag "special" dans Alpha | Tag cree dans Alpha | +| 2 | UserC cree tag "special" dans Beta | Tag cree dans Beta (pas de conflit) | +| 3 | Verifier que Alpha ne voit pas le tag de Beta | Le tag "special" de Beta n'apparait pas dans Alpha | + +--- + +## T3 - Tags pending (creation par membre) + +### T3.1 - Creation de tag inconnu sur recette communautaire + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Connecte en tant que UserB (MEMBER, Alpha) | Dashboard visible | +| 2 | Creer recette dans CommunauteAlpha | Formulaire recette communautaire | +| 3 | Ajouter tags : "dessert" (global), "epice" (inconnu) | Formulaire accepte les 2 tags | +| 4 | Sauvegarder la recette | Recette creee avec les 2 tags | +| 5 | Verifier tag "dessert" sur la recette | Style normal (badge couleur standard) | +| 6 | Verifier tag "epice" sur la recette | Style pending (badge different : gris, contour pointille ou label "pending") | +| 7 | Verifier en DB : tag "epice" | scope=COMMUNITY, communityId=Alpha, status=PENDING, createdById=UserB | + +### T3.2 - Reutilisation d'un tag pending + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB cree une 2eme recette dans Alpha avec tag "epice" | Pas de nouveau tag cree, reutilise le pending existant | +| 2 | UserA (moderateur) cree recette dans Alpha avec tag "epice" | Idem, reutilise le pending existant | +| 3 | Verifier en DB | Un seul tag "epice" PENDING, 3 RecipeTags pointent vers lui | + +### T3.3 - Notification moderateur sur tag pending + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB cree recette avec nouveau tag inconnu "fusion" | Tag pending cree | +| 2 | UserA (connecte, moderateur Alpha) | Recoit notification WebSocket `tag:pending` | +| 3 | Notification visible (toast ou indicateur) | Contenu : nom du tag, communaute | + +### T3.4 - Validation de tag pending par moderateur + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA va dans Alpha > Tags | Tag "epice" visible avec status=PENDING | +| 2 | Cliquer "Approve" sur "epice" | Tag passe en status=APPROVED | +| 3 | Verifier toutes les recettes ayant "epice" | Style normal (plus de badge pending) | +| 4 | UserB recoit notification `tag:approved` | Toast "Votre tag epice a ete valide" | + +### T3.5 - Rejet de tag pending par moderateur + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA va dans Alpha > Tags | Tag "fusion" visible avec status=PENDING | +| 2 | Cliquer "Reject" sur "fusion" | Tag hard delete | +| 3 | Verifier les recettes qui avaient "fusion" | Tag completement disparu (RecipeTags supprimes en cascade) | +| 4 | UserB recoit notification `tag:rejected` | Toast "Votre tag fusion a ete rejete" | + +--- + +## T4 - Autocomplete scope-aware + +### T4.1 - Autocomplete dans une communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB edite recette dans CommunauteAlpha | Formulaire edition | +| 2 | Champ tags : taper "des" | Suggestions : "dessert" (global), pas de tags d'autres communautes | +| 3 | Taper "fait" (si tag communaute Alpha "fait-maison" existe) | Suggestion : "fait-maison" (communaute Alpha) | +| 4 | Taper "spec" (si tag "special" existe aussi dans Beta) | Suggestion : "special" (Alpha uniquement, pas Beta) | +| 5 | Tags PENDING ne sont pas proposes dans l'autocomplete | Seuls APPROVED (global + communaute) | + +### T4.2 - Autocomplete recette personnelle + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB cree recette personnelle (hors communaute) | Formulaire creation | +| 2 | Champ tags : taper "des" | Suggestions : "dessert" (global) | +| 3 | Tags COMMUNITY des communautes de UserB sont proposes | Si showTags=true pour cette communaute | +| 4 | Tags COMMUNITY de communautes dont UserB n'est pas membre | Non proposes | + +--- + +## T5 - Tag Suggestions (recette d'autrui) + +### T5.1 - Suggestion d'un tag existant + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA possede une recette dans Alpha | Recette visible par UserB | +| 2 | UserB ouvre la recette de UserA | Bouton "Suggest a tag" visible | +| 3 | UserB suggere le tag "rapide" (global, existe) | TagSuggestion creee : status=PENDING_OWNER | +| 4 | UserA recoit notification `tag-suggestion:new` | Toast visible | +| 5 | UserA ouvre sa recette > section suggestions | Suggestion "rapide" par UserB visible | +| 6 | UserA clique "Accept" | Tag "rapide" ajoute a la recette directement (existe deja) | +| 7 | TagSuggestion passe en APPROVED | Statut mis a jour | +| 8 | UserB recoit notification `tag-suggestion:approved` | Toast visible | + +### T5.2 - Suggestion d'un tag inconnu (workflow 2 etapes) + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB suggere "bio" sur recette de UserA (tag inconnu) | TagSuggestion : status=PENDING_OWNER | +| 2 | UserA accepte la suggestion | Tag "bio" cree en PENDING dans Alpha | +| 3 | TagSuggestion passe en PENDING_MODERATOR | En attente du moderateur | +| 4 | RecipeTag cree (tag pending, style different sur la recette) | Badge pending visible | +| 5 | Moderateurs Alpha recoivent notification `tag-suggestion:pending-mod` | Toast notification | +| 6 | UserA (moderateur) approuve le tag "bio" via Tags panel | Tag passe en APPROVED | +| 7 | TagSuggestion passe en APPROVED | Cascade terminee | +| 8 | Tag "bio" affiche en style normal sur la recette | Badge standard | + +### T5.3 - Rejet de suggestion par owner + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB suggere "micro-ondes" sur recette de UserA | TagSuggestion : status=PENDING_OWNER | +| 2 | UserA clique "Reject" sur la suggestion | TagSuggestion : status=REJECTED | +| 3 | Aucun tag cree | Pas de tag "micro-ondes" dans la DB | +| 4 | UserB recoit notification `tag-suggestion:rejected` | Toast visible | + +### T5.4 - Rejet de tag pending apres suggestion acceptee + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB suggere "exotique" (inconnu), UserA accepte | Tag "exotique" PENDING + TagSuggestion PENDING_MODERATOR | +| 2 | UserA (moderateur) rejette le tag "exotique" | Tag hard delete + RecipeTags supprimes | +| 3 | TagSuggestion passe en REJECTED | Cascade rejet moderateur | + +### T5.5 - Cas limites suggestions + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA essaie de suggerer un tag sur sa propre recette | Erreur TAG_007 : auto-suggestion interdite | +| 2 | UserB suggere "rapide" une 2eme fois (meme recette) | Erreur TAG_006 : suggestion deja existante | +| 3 | UserD (pas membre Alpha) essaie de suggerer sur recette Alpha | Erreur 403 : pas membre de la communaute | + +--- + +## T6 - Fork inter-communaute et tags + +### T6.1 - Fork avec tags globaux uniquement + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA cree recette dans Alpha avec tags "dessert", "rapide" (globaux) | Recette creee | +| 2 | UserA partage (fork) vers Beta (UserA doit etre membre Beta) | Fork cree dans Beta | +| 3 | Verifier recette forkee dans Beta | Tags "dessert", "rapide" presents (globaux, copie directe) | + +### T6.2 - Fork avec tag communaute connu dans la cible + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Creer tag "special" dans Alpha ET Beta (meme nom) | Les 2 tags existent | +| 2 | Recette dans Alpha avec tag "special" (Alpha) | Recette taguee | +| 3 | Fork vers Beta | Tag "special" de Beta utilise directement (pas de pending) | + +### T6.3 - Fork avec tag communaute inconnu dans la cible + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA cree tag "unique-alpha" dans Alpha | Tag communaute Alpha | +| 2 | Recette Alpha avec tag "unique-alpha" | Recette taguee | +| 3 | Fork vers Beta | Tag "unique-alpha" cree en PENDING dans Beta | +| 4 | Verifier recette forkee dans Beta | Tag "unique-alpha" affiche en style pending | +| 5 | Moderateurs Beta recoivent notification `tag:pending` | Notification recue | +| 6 | Moderateur Beta approuve | Tag passe en APPROVED dans Beta | + +--- + +## T7 - Affichage et styles tags + +### T7.1 - TagBadge styles + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Recette avec tag global APPROVED | Badge couleur standard (primary) | +| 2 | Recette avec tag communaute APPROVED | Badge couleur standard (primary) | +| 3 | Recette avec tag PENDING | Badge style different (gris, contour pointille, ou label "pending") | +| 4 | Recette dans la liste (RecipeCard) | Tags affiches avec bons styles | +| 5 | Recette en detail (RecipeDetailPage) | Tags affiches avec bons styles | + +### T7.2 - Liste recettes avec filtres tags + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Page recettes communaute Alpha | Filtre tags disponible | +| 2 | Filtrer par tag "dessert" | Seules recettes avec tag "dessert" affichees | +| 3 | L'autocomplete du filtre propose global + communaute Alpha | Pas les tags d'autres communautes | + +--- + +## T8 - Administration tags communaute (Moderateur) + +### T8.1 - Acces au panneau tags + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA (MODERATOR) ouvre CommunauteAlpha | Side panel avec onglet "Tags" visible | +| 2 | UserB (MEMBER) ouvre CommunauteAlpha | Onglet "Tags" non visible (ou lecture seule) | + +### T8.2 - CRUD tags communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Lister les tags | Tags APPROVED et PENDING affiches, avec status | +| 2 | Creer tag "saison" | Cree en APPROVED, visible dans la liste | +| 3 | Renommer "saison" en "saisonnier" | Nom mis a jour | +| 4 | Supprimer "saisonnier" | Tag supprime (hard delete), confirme par dialog | +| 5 | Filtrer par status "Pending" | Seuls les tags PENDING | +| 6 | Filtrer par status "Approved" | Seuls les tags APPROVED | +| 7 | Recherche par nom | Tags filtres par nom | + +### T8.3 - Approve / Reject tags pending + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Tag pending "epice" dans la liste | Boutons Approve et Reject visibles | +| 2 | Approve "epice" | Status passe a APPROVED, notification au createur | +| 3 | Tag pending "fusion" dans la liste | Boutons visibles | +| 4 | Reject "fusion" | Tag supprime de la liste, notification au createur | + +--- + +## T9 - Preferences utilisateur + +### T9.1 - Visibilite tags communautaires (profil) + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB ouvre la page Profil | Section "Tag Visibility" visible | +| 2 | Liste des communautes avec toggles | Alpha : toggle ON (defaut), Beta : non listee (pas membre) | +| 3 | Desactiver toggle pour Alpha | Toggle passe a OFF | +| 4 | Creer recette personnelle, ouvrir autocomplete tags | Tags communaute Alpha ne sont plus proposes | +| 5 | Reactiver toggle pour Alpha | Toggle passe a ON | +| 6 | Autocomplete tags perso | Tags communaute Alpha a nouveau proposes | + +### T9.2 - Preferences notifications moderateur + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA (MODERATOR Alpha) ouvre la page Profil | Section "Tag Notifications" visible | +| 2 | Toggle global : ON (defaut) | Checked | +| 3 | Toggle communaute Alpha : ON (defaut) | Checked | +| 4 | Desactiver toggle global | Passe a OFF | +| 5 | UserB cree tag inconnu dans Alpha | UserA ne recoit PAS de notification tag:pending | +| 6 | Reactiver toggle global, desactiver toggle Alpha | | +| 7 | UserB cree un autre tag inconnu dans Alpha | UserA ne recoit PAS de notification (preference communaute OFF) | +| 8 | Reactiver toggle Alpha | | +| 9 | UserB cree un autre tag inconnu | UserA recoit la notification | + +### T9.3 - Section masquee pour non-moderateur + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB (MEMBER, pas moderateur) ouvre page Profil | Section "Tag Notifications" absente | +| 2 | Section "Tag Visibility" presente si UserB est membre d'au moins 1 communaute | Visible avec toggles | + +### T9.4 - Aucune communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Nouvel utilisateur sans communaute ouvre Profil | Section "Tag Visibility" absente | +| 2 | Section "Tag Notifications" absente | Non moderateur | + +--- + +## T10 - Notifications WebSocket temps reel + +### T10.1 - Tableau recapitulatif des evenements + +| Evenement | Declencheur | Destinataire | Verification | +|-----------|-------------|--------------|--------------| +| `tag:pending` | Membre cree tag inconnu sur recette comm. | Moderateurs (si notifs activees) | Toast / indicateur | +| `tag:approved` | Moderateur approuve tag pending | Createur du tag | Toast confirmation | +| `tag:rejected` | Moderateur rejette tag pending | Createur du tag | Toast info | +| `tag-suggestion:new` | Membre suggere tag sur recette d'autrui | Proprietaire recette | Toast | +| `tag-suggestion:approved` | Owner accepte suggestion | Auteur suggestion | Toast | +| `tag-suggestion:rejected` | Owner rejette suggestion | Auteur suggestion | Toast | +| `tag-suggestion:pending-mod` | Owner accepte suggestion mais tag inconnu | Moderateurs | Toast | + +### T10.2 - Test multi-onglets + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Ouvrir 2 onglets : UserA et UserB | Les 2 connectes via WebSocket | +| 2 | UserB cree tag inconnu dans Alpha | UserA (onglet) recoit toast notification en temps reel | +| 3 | UserA approuve le tag | UserB (onglet) recoit toast confirmation en temps reel | + +--- + +## T11 - Recettes orphelines et tags + +### T11.1 - Createur quitte la communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB a cree recettes dans Alpha avec tags (approved + pending) | Recettes et tags existent | +| 2 | UserB quitte CommunauteAlpha | Recettes deviennent orphelines | +| 3 | Verifier tags APPROVED sur recettes orphelines | Restent en place | +| 4 | Verifier tags PENDING sur recettes orphelines | Restent en place (moderateur peut toujours valider/rejeter) | +| 5 | TagSuggestions PENDING_OWNER sur recettes de UserB | Auto-rejetees (plus de proprietaire) | + +--- + +## T12 - Limites et validation + +### T12.1 - Limites tags + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Creer recette avec 10 tags | OK, maximum atteint | +| 2 | Essayer d'ajouter un 11eme tag | Erreur TAG_003 : limite de tags atteinte | +| 3 | Moderateur cree 100 tags dans une communaute | OK, maximum communaute atteint | +| 4 | Essayer de creer un 101eme tag | Erreur TAG_003 : limite de tags communaute atteinte | + +### T12.2 - Validation nom + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | Creer tag avec 1 caractere | Erreur : minimum 2 caracteres | +| 2 | Creer tag avec 51 caracteres | Erreur : maximum 50 caracteres | +| 3 | Creer tag " PASTA " | Tag cree en "pasta" (trim + lowercase) | + +--- + +## T13 - Permissions et securite + +### T13.1 - MEMBER ne peut pas administrer les tags + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB (MEMBER) essaie d'approuver un tag pending (via API directe) | 403 TAG_004 | +| 2 | UserB essaie de creer un tag communaute (via API directe) | 403 TAG_004 | +| 3 | UserB essaie de supprimer un tag (via API directe) | 403 TAG_004 | + +### T13.2 - Moderateur ne peut pas toucher aux tags globaux + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA (MODERATOR) essaie de renommer tag global "dessert" (via API) | 403 TAG_005 | +| 2 | UserA essaie de supprimer tag global (via API) | 403 TAG_005 | + +### T13.3 - Moderateur ne peut agir que sur sa communaute + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserA (MODERATOR Alpha) essaie d'administrer tags de Beta (via API) | 403 : pas membre / pas moderateur | + +### T13.4 - Notification preferences (non-moderateur) + +| # | Action | Resultat attendu | +|---|--------|------------------| +| 1 | UserB (MEMBER) essaie PUT notification-preferences/tags (via API) | 403 : non moderateur | +| 2 | GET notification-preferences retourne communities vide pour non-moderateur | `{ global: ..., communities: [] }` | + +--- + +## Checklist de validation finale + +- [ ] T1 - Tags globaux (SuperAdmin) : CRUD, fusion, filtre scope +- [ ] T2 - Tags communaute (Moderateur) : CRUD, isolation inter-communautes +- [ ] T3 - Tags pending : creation, reutilisation, notification, approve, reject +- [ ] T4 - Autocomplete scope-aware : communaute et catalogue perso +- [ ] T5 - Tag suggestions : existant, inconnu, rejet owner, rejet moderateur, cas limites +- [ ] T6 - Fork inter-communaute : global, connu, inconnu +- [ ] T7 - Affichage et styles : TagBadge, filtres +- [ ] T8 - Administration tags communaute : panneau moderateur +- [ ] T9 - Preferences : visibilite tags, notifications moderateur +- [ ] T10 - Notifications WebSocket temps reel +- [ ] T11 - Recettes orphelines et tags +- [ ] T12 - Limites et validation +- [ ] T13 - Permissions et securite From 891f30dbe355fe6eb350028b13b02cb3cde03910 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Tue, 17 Feb 2026 14:34:20 +0100 Subject: [PATCH 013/221] docs: add ingredients rework spec and roadmap (Phase 11) Spec complete covering unit system, hybrid ingredient governance (PENDING/APPROVED), proposal ingredients, admin moderation workflows, and smart unit pre-selection. Roadmap split into 9 phases (11.1-11.9). --- docs/0 - brainstorming futur.md | 9 +- docs/features/ingredients-rework/ROADMAP.md | 91 +++ .../SPEC_INGREDIENTS_REWORK.md | 611 ++++++++++++++++++ 3 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 docs/features/ingredients-rework/ROADMAP.md create mode 100644 docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md diff --git a/docs/0 - brainstorming futur.md b/docs/0 - brainstorming futur.md index 77b8a040..3a322517 100644 --- a/docs/0 - brainstorming futur.md +++ b/docs/0 - brainstorming futur.md @@ -6,17 +6,16 @@ Tout doit être cohérent avec l'application et son fonctionnement actuel. Ce so Tout doit être clair et maitrisé, pensé pour être maintenable et évoluer dans le temps. Toute la logique business doit être validé et sans zone d'ombre restante avant d'écrire du code. -# Rework du système de tags - -avoir une liste par défaut globale à la création d'une communauté, et permettre à chaque communauté de créer ses propres tags complémentaires ??? --> vu que les recettes sont associé à un user, comment faire de manière logique ? - # Rework du système d'ingrédients Problèmes similaires au système de tags : -> vu que les recettes sont associé à un user, comment faire de manière logique ? proposer une modification de recette doit aussi prendre en compte les ingrédients (je crois que ce n'est pas le cas) +# Le système de notifications doit être amélioré + +persistance, fonctionne y compris offline (je me connecte je dois voir les notifs reçues lorsque j'étais offline) + # Rework des pages recettes (v2) nombre de personne pour les ingrédients, diff --git a/docs/features/ingredients-rework/ROADMAP.md b/docs/features/ingredients-rework/ROADMAP.md new file mode 100644 index 00000000..279eeead --- /dev/null +++ b/docs/features/ingredients-rework/ROADMAP.md @@ -0,0 +1,91 @@ +# Roadmap : Rework Ingredients (Phase 11) + +> **Spec** : `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` +> **Branche** : `IngredientsRework` + +--- + +## 11.1 - Schema & Migration + +- [ ] Migration Prisma : creer table Unit (id, name, abbreviation, category, sortOrder) +- [ ] Migration Prisma : enrichir Ingredient (status, defaultUnitId, createdById, createdAt, updatedAt) +- [ ] Migration Prisma : modifier RecipeIngredient (quantity String? → Float?, ajout unitId) +- [ ] Migration Prisma : creer table ProposalIngredient +- [ ] Migration Prisma : relation ProposalIngredient sur RecipeUpdateProposal +- [ ] Nouveaux enums : UnitCategory, IngredientStatus +- [ ] Nouveaux types AdminActionType : INGREDIENT_APPROVED, INGREDIENT_REJECTED, UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED +- [ ] Seed : unites standard (18 unites, 5 categories) +- [ ] Seed : mettre a jour les ingredients existants (status=APPROVED) +- [ ] Tests migration + +## 11.2 - Backend Units (CRUD admin + lecture user) + +- [ ] Endpoint user : GET /api/units (liste groupee par categorie) +- [ ] Endpoint admin : GET /api/admin/units +- [ ] Endpoint admin : POST /api/admin/units (creation + validation unicite) +- [ ] Endpoint admin : PATCH /api/admin/units/:id (modification) +- [ ] Endpoint admin : DELETE /api/admin/units/:id (avec protection si utilisee) +- [ ] Audit log : UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED +- [ ] Tests unitaires + integration + +## 11.3 - Backend Ingredients (gouvernance + moderation) + +- [ ] Refactoring recipeService : upsertIngredients → gestion quantity Float + unitId + creation PENDING +- [ ] Endpoint GET /api/ingredients : enrichir avec status, filtrer APPROVED + PENDING +- [ ] Endpoint GET /api/ingredients/:id/suggested-unit (pre-selection intelligente) +- [ ] Adaptation endpoints admin existants : filtre par status, enrichir reponses +- [ ] Endpoint admin : POST /api/admin/ingredients/:id/approve (avec rename optionnel) +- [ ] Endpoint admin : POST /api/admin/ingredients/:id/reject (avec raison obligatoire) +- [ ] Enrichir merge admin : gerer ProposalIngredient en plus de RecipeIngredient +- [ ] Enrichir PATCH admin : gestion du defaultUnitId +- [ ] Audit log : INGREDIENT_APPROVED, INGREDIENT_REJECTED +- [ ] Protection suppression unite : verifier usage dans RecipeIngredient + ProposalIngredient +- [ ] Tests unitaires + integration + +## 11.4 - Backend Proposals + Ingredients + +- [ ] Adapter creation de proposal : stocker ProposalIngredient (ingredients proposes) +- [ ] Adapter acceptation de proposal : remplacer RecipeIngredient par ProposalIngredient +- [ ] Adapter rejet de proposal : cascade delete ProposalIngredient +- [ ] Creation ingredient PENDING depuis une proposal (meme flow que recette) +- [ ] Tests unitaires + integration + +## 11.5 - Backend Notifications + +- [ ] Notification WebSocket : INGREDIENT_APPROVED (au createur) +- [ ] Notification WebSocket : INGREDIENT_MODIFIED (au createur, avec newName) +- [ ] Notification WebSocket : INGREDIENT_MERGED (au createur, avec targetName) +- [ ] Notification WebSocket : INGREDIENT_REJECTED (au createur, avec reason) +- [ ] Tests + +## 11.6 - Frontend Units & Ingredients (refactoring formulaire) + +- [ ] Composant UnitSelector : dropdown groupee par categorie +- [ ] Refactoring IngredientList : autocomplete + champ quantite numerique + UnitSelector +- [ ] Pre-selection unite intelligente (appel suggested-unit au choix d'ingredient) +- [ ] Badge "nouveau" sur ingredients PENDING dans l'autocomplete +- [ ] Endpoint frontend API : GET /api/units, GET /api/ingredients/:id/suggested-unit +- [ ] Types TypeScript : Unit, UnitCategory, IngredientStatus, ProposalIngredient +- [ ] Tests composants + +## 11.7 - Frontend Administration + +- [ ] Page admin units : liste, creation, modification, suppression +- [ ] Page admin ingredients : filtre par status (APPROVED / PENDING / tous) +- [ ] Actions admin ingredients : approuver, approuver + modifier, merger, rejeter (avec raison) +- [ ] Affichage defaultUnit sur chaque ingredient, modification inline +- [ ] Notifications toast : INGREDIENT_APPROVED, INGREDIENT_MODIFIED, INGREDIENT_MERGED, INGREDIENT_REJECTED +- [ ] Tests + +## 11.8 - Frontend Proposals + Ingredients + +- [ ] Formulaire de proposal : inclure la section ingredients (meme composant que creation recette) +- [ ] Vue detail proposal : afficher les ingredients proposes vs actuels (diff) +- [ ] Acceptation proposal : feedback visuel ingredients mis a jour +- [ ] Tests + +## 11.9 - Documentation & Tests manuels + +- [ ] Mettre a jour docs/0 - brainstorming futur.md (cocher le point) +- [ ] Mettre a jour .claude/context/ (DB_MODELS, API_MAP, FILE_MAP, TESTS, PROGRESS) +- [ ] Tests manuels end-to-end (scenarios cles documentes) diff --git a/docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md b/docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md new file mode 100644 index 00000000..ec39446d --- /dev/null +++ b/docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md @@ -0,0 +1,611 @@ +# Specification : Rework du systeme d'Ingredients + +> **Statut** : SPEC VALIDEE - En attente d'implementation +> **Date** : 2026-02-17 +> **Prerequis** : MVP complet (phases 0-8), Tags Rework (phase 10) +> **Roadmap** : `ROADMAP.md` (meme dossier) + +--- + +## 1. Vue d'ensemble + +Le systeme actuel d'ingredients est minimaliste : un `name` unique, une `quantity` texte libre, aucune unite structuree, creation implicite a la volee sans controle qualite. Ce rework introduit **un systeme d'unites structure**, une **gouvernance hybride** (creation immediate + moderation admin), et l'**integration des ingredients dans les propositions de modification de recettes**. + +### 1.1 Objectifs + +| Objectif | Description | +|----------|-------------| +| **Unites structurees** | Quantites numeriques + unites de mesure standardisees (g, cl, l...) | +| **Qualite des donnees** | Moderation admin pour eviter doublons, typos, ingredients fantaisistes | +| **UX fluide** | Creation immediate en PENDING, aucun blocage pour l'utilisateur | +| **Unite favorite** | Pre-selection intelligente de l'unite la plus pertinente par ingredient | +| **Proposals completes** | Les propositions de modification incluent les ingredients | +| **Base pour le futur** | Quantites numeriques = prerequis pour le scaling par portions (Rework recettes v2) | + +### 1.2 Perimetre + +- **Inclus** : Unites, ingredients (gouvernance + moderation), RecipeIngredient, ProposalIngredient, notifications, admin CRUD +- **Exclus** : Conversion d'unites, categories d'ingredients (legumes, epices...), donnees nutritionnelles, allergenes, substitutions + +### 1.3 Scope + +Tout est **global a la plateforme**. Les ingredients et unites n'ont aucune portee communautaire. Seuls les **SuperAdmin** gerent la moderation. Les moderateurs de communaute n'ont aucun role dans ce systeme. + +--- + +## 2. Schema de donnees + +### 2.1 Nouveau modele : Unit + +Table de reference pour les unites de mesure. + +``` +Unit + id String @id @default(uuid()) + name String @unique -- "gramme", "centilitre", "piece"... + abbreviation String @unique -- "g", "cl", "pc"... + category UnitCategory -- WEIGHT | VOLUME | SPOON | COUNT | QUALITATIVE + sortOrder Int @default(0) -- Tri dans les dropdowns + + // Relations + recipeIngredients RecipeIngredient[] + proposalIngredients ProposalIngredient[] + defaultIngredients Ingredient[] -- Ingredients ayant cette unite par defaut + + @@index([category, sortOrder]) +``` + +### 2.2 Enum : UnitCategory + +``` +UnitCategory: WEIGHT | VOLUME | SPOON | COUNT | QUALITATIVE +``` + +### 2.3 Modele modifie : Ingredient + +``` +Ingredient (modifie) + id String @id @default(uuid()) + name String @unique -- Normalise en lowercase + status IngredientStatus @default(APPROVED) + defaultUnitId String? -- NOUVEAU - FK vers Unit + createdById String? -- NOUVEAU - FK vers User (null = admin) + createdAt DateTime @default(now()) -- NOUVEAU + updatedAt DateTime @updatedAt -- NOUVEAU + + // Relations + defaultUnit Unit? @relation(fields: [defaultUnitId], references: [id]) + createdBy User? @relation(fields: [createdById], references: [id]) + recipes RecipeIngredient[] + proposals ProposalIngredient[] + + @@index([name]) + @@index([status]) +``` + +### 2.4 Nouvel enum : IngredientStatus + +``` +IngredientStatus: APPROVED | PENDING +``` + +- `APPROVED` : Ingredient valide (cree par admin ou valide par admin) +- `PENDING` : Cree par un utilisateur, en attente de review admin + +### 2.5 Modele modifie : RecipeIngredient + +``` +RecipeIngredient (modifie) + id String @id @default(uuid()) + recipeId String + ingredientId String + quantity Float? -- MODIFIE : etait String?, devient Float? + unitId String? -- NOUVEAU - FK vers Unit + order Int @default(0) + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + unit Unit? @relation(fields: [unitId], references: [id]) + + @@unique([recipeId, ingredientId]) + @@index([recipeId]) +``` + +### 2.6 Nouveau modele : ProposalIngredient + +Table pivot pour les ingredients dans les propositions de modification de recettes. + +``` +ProposalIngredient + id String @id @default(uuid()) + proposalId String + ingredientId String + quantity Float? + unitId String? + order Int @default(0) + + proposal RecipeUpdateProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + unit Unit? @relation(fields: [unitId], references: [id]) + + @@unique([proposalId, ingredientId]) + @@index([proposalId]) +``` + +### 2.7 Modele modifie : RecipeUpdateProposal + +Ajout de la relation vers ProposalIngredient : + +``` +RecipeUpdateProposal (modifie) + ... champs existants (proposedTitle, proposedContent, status, etc.) ... + proposedIngredients ProposalIngredient[] -- NOUVEAU +``` + +--- + +## 3. Systeme d'unites + +### 3.1 Seed initial + +Les unites sont seedees au deploiement. Le SuperAdmin peut en ajouter/modifier/supprimer ensuite. + +| Categorie | name | abbreviation | sortOrder | +|-----------|------|-------------|-----------| +| **WEIGHT** | gramme | g | 1 | +| **WEIGHT** | kilogramme | kg | 2 | +| **VOLUME** | millilitre | ml | 1 | +| **VOLUME** | centilitre | cl | 2 | +| **VOLUME** | decilitre | dl | 3 | +| **VOLUME** | litre | l | 4 | +| **SPOON** | cuillere a cafe | cac | 1 | +| **SPOON** | cuillere a soupe | cas | 2 | +| **COUNT** | piece | pc | 1 | +| **COUNT** | tranche | tr | 2 | +| **COUNT** | gousse | gse | 3 | +| **COUNT** | botte | bte | 4 | +| **COUNT** | feuille | fle | 5 | +| **COUNT** | brin | brn | 6 | +| **QUALITATIVE** | pincee | pincee | 1 | +| **QUALITATIVE** | a gout | a gout | 2 | +| **QUALITATIVE** | selon besoin | selon besoin | 3 | + +### 3.2 Regles + +- Les noms et abbreviations sont **uniques** +- Les unites sont **globales** (pas de scope communaute) +- Tri dans les dropdowns : par categorie puis par `sortOrder` +- Les unites QUALITATIVE peuvent avoir `quantity = null` (ex: "a gout") ou un nombre (ex: "2 pincees") + +--- + +## 4. Gouvernance des ingredients + +### 4.1 Principe : creation hybride + +L'utilisateur peut creer un ingredient a la volee lors de l'ajout a une recette. L'ingredient est utilisable immediatement mais en attente de review admin. + +| Createur | Status initial | Utilisable immediatement | Review necessaire | +|----------|---------------|-------------------------|-------------------| +| **SuperAdmin** | APPROVED | Oui | Non | +| **Utilisateur** (via recette) | PENDING | **Oui** | Oui (admin async) | + +### 4.2 Flow utilisateur : ajout d'ingredient a une recette + +``` +1. L'utilisateur tape dans l'autocomplete +2. Recherche parmi les ingredients existants (APPROVED + PENDING) +3. CAS A : Ingredient trouve → selection directe +4. CAS B : Ingredient non trouve → option "Suggerer : [nom]" + a. Clic → Ingredient cree en PENDING, createdById = userId + b. L'ingredient est immediatement associe a la recette + c. L'ingredient PENDING apparait dans la file de review admin +5. L'utilisateur choisit la quantite (champ numerique libre) +6. L'utilisateur choisit l'unite (dropdown avec pre-selection intelligente) +``` + +### 4.3 Reutilisation d'un ingredient PENDING + +Si un ingredient PENDING existe deja (cree par un autre user) : +- L'autocomplete le propose normalement +- L'utilisateur peut l'utiliser sans re-creer +- Pas de doublon + +### 4.4 Affichage des ingredients PENDING + +| Contexte | Style | +|----------|-------| +| Dans une recette (vue lecture) | Style normal (l'ingredient est un ingredient, pending ou pas) | +| Dans l'autocomplete | Badge "nouveau" ou icone distincte pour les PENDING | +| Dans le panneau admin | Badge "en attente" avec nombre de recettes utilisant l'ingredient | + +> **Difference avec les tags** : un tag PENDING a un style visuel different sur la recette car la "categorisation" est sujette a validation. Un ingredient PENDING est un fait ("poire" c'est "poire"), donc il s'affiche normalement sur la recette. + +--- + +## 5. Moderation admin (SuperAdmin) + +### 5.1 File de review + +Le panneau admin affiche les ingredients PENDING avec : +- Nom de l'ingredient +- Createur (username) +- Nombre de recettes utilisant cet ingredient +- Date de creation + +### 5.2 Actions de moderation + +| Action | Effet sur l'ingredient | Effet sur les recettes | Notification | +|--------|----------------------|----------------------|-------------| +| **Approuver** | `status` → `APPROVED` | Aucun changement | Optionnelle au createur | +| **Approuver + modifier** | Rename + `status` → `APPROVED` | Suivent automatiquement (FK) | Createur informe du renommage | +| **Merger** | Supprime le PENDING, rattache les RecipeIngredient au target | Les recettes pointent vers l'ingredient cible | Createur informe du merge | +| **Rejeter** | Hard delete de l'ingredient | Cascade : suppression des RecipeIngredient | **Obligatoire** : createur notifie avec raison + demande de correction | + +### 5.3 Rejet : detail du flow + +``` +1. Admin rejette l'ingredient avec une raison obligatoire (champ texte) +2. Hard delete de l'Ingredient → cascade supprime les RecipeIngredient associes +3. Notification WebSocket au createur : + - type: "INGREDIENT_REJECTED" + - metadata: { ingredientName, reason } +4. Les recettes concernees perdent cet ingredient +5. L'utilisateur doit corriger sa recette (choisir un autre ingredient ou en proposer un nouveau) +``` + +### 5.4 Merge : detail du flow + +``` +1. Admin choisit un ingredient cible (APPROVED existant) +2. Pour chaque recette utilisant l'ingredient source : + a. Si la recette n'a PAS l'ingredient cible → mettre a jour le RecipeIngredient (ingredientId → targetId) + b. Si la recette a DEJA l'ingredient cible → supprimer le RecipeIngredient source (doublon) +3. Meme logique pour les ProposalIngredient +4. Hard delete de l'ingredient source +5. AdminActivityLog : INGREDIENT_MERGED +``` + +--- + +## 6. Unite favorite / pre-selection + +### 6.1 Mecanisme de pre-selection + +Quand l'utilisateur selectionne un ingredient dans le formulaire de recette, l'unite est pre-selectionnee dans le dropdown selon cette logique : + +``` +1. SI ingredient.defaultUnitId existe (defini par admin) + → Pre-selectionner cette unite +2. SINON, SI des RecipeIngredient existent pour cet ingredient avec une unite + → Calculer l'unite la plus utilisee (COUNT par unitId) + → Pre-selectionner cette unite +3. SINON (ingredient tout neuf, aucune donnee) + → Aucune pre-selection, dropdown vide +``` + +### 6.2 Endpoint pour recuperer l'unite suggeree + +``` +GET /api/ingredients/:id/suggested-unit +``` + +**Reponse :** +```json +{ + "suggestedUnitId": "uuid-or-null", + "source": "default" | "popular" | null +} +``` + +- `source: "default"` → vient du `defaultUnitId` de l'ingredient +- `source: "popular"` → calculee depuis les recettes existantes +- `source: null` → aucune suggestion disponible + +### 6.3 Default unit admin + +Le SuperAdmin peut definir/modifier le `defaultUnitId` d'un ingredient via le panneau admin. Ce default a priorite sur le calcul dynamique. + +--- + +## 7. Propositions de modification (Proposals) et ingredients + +### 7.1 Principe + +Une proposition de modification de recette (`RecipeUpdateProposal`) inclut desormais les ingredients proposes via la table pivot `ProposalIngredient`. + +### 7.2 Creation d'une proposal + +``` +1. L'utilisateur propose une modification de recette +2. Il peut modifier : titre, contenu, ET ingredients +3. Les ingredients proposes sont stockes dans ProposalIngredient + (meme structure que RecipeIngredient : ingredientId, quantity, unitId, order) +4. Si l'utilisateur propose un nouvel ingredient inconnu → creation en PENDING (meme flow que 4.2) +``` + +### 7.3 Acceptation d'une proposal + +``` +1. Le proprietaire de la recette accepte la proposal +2. proposedTitle → recipe.title (si modifie) +3. proposedContent → recipe.content (si modifie) +4. ProposalIngredient → remplace les RecipeIngredient de la recette + a. Suppression de tous les RecipeIngredient existants + b. Creation des nouveaux RecipeIngredient depuis ProposalIngredient +5. Proposal.status = ACCEPTED +``` + +### 7.4 Rejet d'une proposal + +``` +1. Le proprietaire rejette la proposal +2. ProposalIngredient cascade-delete (via Proposal) +3. Les ingredients PENDING crees pour cette proposal restent en base + (ils peuvent etre utilises par d'autres recettes ou etre review par admin) +``` + +--- + +## 8. API Endpoints + +### 8.1 User API : Ingredients + +``` +GET /api/ingredients?search=X&limit=20 +``` +Recherche d'ingredients (APPROVED + PENDING). Reponse enrichie avec recipeCount. + +``` +GET /api/ingredients/:id/suggested-unit +``` +Retourne l'unite suggeree pour un ingredient (voir section 6.2). + +### 8.2 User API : Units + +``` +GET /api/units +``` +Liste toutes les unites, groupees par categorie, triees par sortOrder. + +### 8.3 Admin API : Units (NOUVEAU) + +``` +GET /api/admin/units → Liste toutes les unites +POST /api/admin/units → Creer une unite +PATCH /api/admin/units/:id → Modifier une unite +DELETE /api/admin/units/:id → Supprimer une unite +``` + +**Contraintes de suppression :** une unite ne peut pas etre supprimee si elle est utilisee dans des RecipeIngredient ou ProposalIngredient. L'admin doit d'abord migrer les recettes vers une autre unite. + +### 8.4 Admin API : Ingredients (existant, etendu) + +``` +GET /api/admin/ingredients?search=X&status=PENDING +``` +Filtre par status ajoute. Reponse enrichie avec `status`, `createdBy`, `defaultUnit`. + +``` +POST /api/admin/ingredients +``` +Creation d'un ingredient APPROVED (inchange). + +``` +PATCH /api/admin/ingredients/:id +``` +Modification du nom et/ou du `defaultUnitId`. Peut aussi changer le status (approuver un PENDING). + +``` +DELETE /api/admin/ingredients/:id +``` +Suppression (inchange, cascade sur RecipeIngredient). + +``` +POST /api/admin/ingredients/:id/merge +``` +Merge (logique enrichie pour gerer ProposalIngredient, voir section 5.4). + +``` +POST /api/admin/ingredients/:id/approve +``` +**NOUVEAU** - Approuver un ingredient PENDING. Optionnel : renommer en meme temps. + +```json +{ + "newName": "poire" // optionnel, pour corriger typo/nom +} +``` + +``` +POST /api/admin/ingredients/:id/reject +``` +**NOUVEAU** - Rejeter un ingredient PENDING. + +```json +{ + "reason": "Ingredient trop vague, precisez le type" // obligatoire +} +``` + +--- + +## 9. Notifications (WebSocket) + +Integration dans le systeme existant via `appEvents.emitActivity()`. + +### 9.1 Evenements emis + +| Evenement | Destinataire | Declencheur | +|-----------|-------------|-------------| +| `INGREDIENT_APPROVED` | Createur de l'ingredient | Admin approuve un ingredient PENDING | +| `INGREDIENT_MODIFIED` | Createur de l'ingredient | Admin approuve avec modification (rename) | +| `INGREDIENT_MERGED` | Createur de l'ingredient | Admin merge un ingredient PENDING | +| `INGREDIENT_REJECTED` | Createur de l'ingredient | Admin rejette un ingredient PENDING | + +### 9.2 Payload des notifications + +```typescript +appEvents.emitActivity({ + type: "INGREDIENT_REJECTED", // ou APPROVED, MODIFIED, MERGED + userId: adminId, + communityId: null, // global, pas de communaute + targetUserIds: [ingredient.createdById], + metadata: { + ingredientName: "poire", + reason: "...", // uniquement pour REJECTED + newName: "...", // uniquement pour MODIFIED + targetName: "...", // uniquement pour MERGED + }, +}); +``` + +### 9.3 Messages toast (frontend) + +| Type | Message | +|------|---------| +| `INGREDIENT_APPROVED` | "Votre ingredient '[name]' a ete valide" | +| `INGREDIENT_MODIFIED` | "Votre ingredient a ete valide sous le nom '[newName]'" | +| `INGREDIENT_MERGED` | "Votre ingredient '[name]' a ete fusionne avec '[targetName]'" | +| `INGREDIENT_REJECTED` | "Votre ingredient '[name]' a ete rejete : [reason]" | + +--- + +## 10. Validation + +### 10.1 Ingredient + +```typescript +{ + name: string, min: 2, max: 80, normalise (trim + lowercase) +} +``` + +### 10.2 Unit + +```typescript +{ + name: string, min: 1, max: 50, unique + abbreviation: string, min: 1, max: 20, unique + category: UnitCategory (enum) + sortOrder: int, default 0 +} +``` + +### 10.3 RecipeIngredient / ProposalIngredient + +```typescript +{ + ingredientId: UUID, required (FK vers Ingredient existant ou PENDING) + quantity: float, optional, > 0 si present + unitId: UUID, optional (FK vers Unit) + order: int, >= 0 +} +``` + +### 10.4 Limites + +- Max **50 ingredients** par recette (eviter les abus) +- Pas de limite sur le nombre total d'ingredients (gere par admin) +- Pas de limite sur le nombre d'unites (gere par admin) + +--- + +## 11. Migration de l'existant + +### 11.1 Strategie + +Comme seules des donnees de seed existent en production, la migration est simple : + +``` +1. Creer la table Unit et seeder les unites standard (section 3.1) +2. Ajouter les champs sur Ingredient : status (default APPROVED), defaultUnitId, createdById, createdAt, updatedAt +3. Modifier RecipeIngredient : quantity de String? a Float? (reset a null), ajouter unitId +4. Creer la table ProposalIngredient +5. Ajouter la relation ProposalIngredient sur RecipeUpdateProposal +6. Mettre a jour le seed des ingredients existants (status=APPROVED) +``` + +### 11.2 Donnees existantes + +- Les ingredients seeds existants deviennent `status=APPROVED`, `createdById=null` +- Les `quantity` existantes (String) sont resetees a `null` (pas de parsing necessaire) +- Les RecipeIngredient existants ont `unitId=null` apres migration + +--- + +## 12. Codes d'erreur (NOUVEAUX) + +| Code | Message | Contexte | +|------|---------|----------| +| `INGREDIENT_001` | Ingredient non trouve | ID invalide ou supprime | +| `INGREDIENT_002` | Nom d'ingredient deja utilise | Unicite violee | +| `INGREDIENT_003` | Limite d'ingredients atteinte | >50 sur une recette | +| `INGREDIENT_004` | Unite non trouvee | unitId invalide | +| `INGREDIENT_005` | Unite en cours d'utilisation | Suppression d'une unite utilisee | +| `INGREDIENT_006` | Raison de rejet obligatoire | Admin rejette sans raison | +| `INGREDIENT_007` | Quantite invalide | Quantite <= 0 | +| `INGREDIENT_008` | Ingredient deja approuve | Tentative d'approuver un APPROVED | + +--- + +## 13. Audit (AdminActivityLog) + +### 13.1 Actions tracees + +| Type (existant) | Usage | +|-----------------|-------| +| `INGREDIENT_CREATED` | Admin cree un ingredient | +| `INGREDIENT_UPDATED` | Admin modifie nom ou defaultUnitId | +| `INGREDIENT_DELETED` | Admin supprime un ingredient | +| `INGREDIENT_MERGED` | Admin merge deux ingredients | + +| Type (nouveau) | Usage | +|----------------|-------| +| `INGREDIENT_APPROVED` | Admin approuve un PENDING (metadata: ingredientName, newName si modifie) | +| `INGREDIENT_REJECTED` | Admin rejette un PENDING (metadata: ingredientName, reason) | +| `UNIT_CREATED` | Admin cree une unite | +| `UNIT_UPDATED` | Admin modifie une unite | +| `UNIT_DELETED` | Admin supprime une unite | + +--- + +## 14. Synchronisation (rappel) + +Les ingredients sont synchronises lors du partage de recettes entre communautes (fork). Comme les ingredients sont globaux, il n'y a pas de probleme de portee : l'ingredient "poire" est le meme partout. + +Lors d'un fork : +- Les RecipeIngredient sont copies tels quels (ingredientId, quantity, unitId, order) +- Aucune creation de doublon, aucune validation supplementaire + +--- + +## 15. Impact sur les fonctionnalites existantes + +### 15.1 Creation/edition de recette + +- Le formulaire d'ingredients evolue : autocomplete + champ quantite numerique + dropdown unite +- L'upsert d'ingredients (`upsertIngredients`) est mis a jour pour gerer `quantity: Float`, `unitId`, et la creation en PENDING + +### 15.2 Filtrage de recettes par ingredients + +- Inchange : filtre par nom d'ingredient (query param `ingredients=poire,lait`) +- Les ingredients PENDING sont inclus dans le filtre (ils sont sur des recettes) + +### 15.3 Panneau admin ingredients + +- Evolue : ajout du filtre par status, actions approve/reject, affichage defaultUnit +- Ajout de la gestion des unites (nouveau panneau ou section) + +### 15.4 Partage / Fork de recettes + +- Les RecipeIngredient sont copies avec quantity + unitId (au lieu de quantity String) + +### 15.5 Recettes orphelines + +``` +SI une recette devient orpheline (createur parti) : + - Les ingredients restent sur la recette (APPROVED et PENDING) + - Les ingredients PENDING restent dans la file admin + - Le createdById de l'ingredient pointe vers un user soft-deleted (pas de cascade) +``` From a5841ff08c3554d44cda99b973b5b065b70db100 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Wed, 18 Feb 2026 08:30:53 +0100 Subject: [PATCH 014/221] feat: ingredients rework schema, migration & units CRUD (Phase 11.1-11.2) Phase 11.1 - Schema & Migration: - Add Unit model, UnitCategory/IngredientStatus enums, ProposalIngredient model - Enrich Ingredient (status, defaultUnitId, createdById, timestamps) - Change RecipeIngredient.quantity from String to Float, add unitId FK - Add AdminActionType values for ingredients/units moderation - Seed 17 standard units (5 categories) - Update all backend/frontend code for quantity type change (string -> number) Phase 11.2 - Backend Units: - Admin CRUD: GET/POST/PATCH/DELETE /api/admin/units with audit logging - User endpoint: GET /api/units (grouped by category) - Delete protection: blocked if unit is used in recipes/proposals/defaults - 25 new integration tests (510 backend total, 326 frontend) --- .claude/context/API_MAP.md | 17 +- .claude/context/DB_MODELS.md | 24 +- .claude/context/PROGRESS.md | 16 +- .../migration.sql | 79 ++++ .../migration.sql | 6 + backend/prisma/schema.prisma | 83 +++- backend/prisma/seed.js | 34 +- .../integration/adminIngredients.test.ts | 2 +- .../__tests__/integration/adminUnits.test.ts | 380 ++++++++++++++++++ .../integration/communityRecipes.test.ts | 6 +- .../__tests__/integration/ingredients.test.ts | 8 +- .../src/__tests__/integration/recipes.test.ts | 6 +- .../src/__tests__/integration/share.test.ts | 10 +- backend/src/__tests__/setup/globalSetup.ts | 2 + backend/src/__tests__/setup/testHelpers.ts | 36 +- .../__tests__/unit/responseFormatters.test.ts | 8 +- .../src/admin/controllers/unitsController.ts | 260 ++++++++++++ backend/src/admin/routes/unitsRoutes.ts | 18 + backend/src/app.ts | 4 + backend/src/controllers/communityRecipes.ts | 2 +- backend/src/controllers/recipes.ts | 2 +- backend/src/controllers/units.ts | 39 ++ backend/src/routes/units.ts | 8 + backend/src/services/recipeService.ts | 4 +- backend/src/services/shareService.ts | 4 +- backend/src/util/responseFormatters.ts | 2 +- docs/features/ingredients-rework/ROADMAP.md | 34 +- frontend/src/models/recipe.ts | 2 +- frontend/src/network/api.ts | 2 +- frontend/src/pages/RecipeFormPage.tsx | 14 +- 30 files changed, 1038 insertions(+), 74 deletions(-) create mode 100644 backend/prisma/migrations/20260218120000_ingredients_rework_schema/migration.sql create mode 100644 backend/prisma/migrations/20260218160000_add_ingredient_activity_types/migration.sql create mode 100644 backend/src/__tests__/integration/adminUnits.test.ts create mode 100644 backend/src/admin/controllers/unitsController.ts create mode 100644 backend/src/admin/routes/unitsRoutes.ts create mode 100644 backend/src/controllers/units.ts create mode 100644 backend/src/routes/units.ts diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 958b656e..e7bb8fb9 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -42,6 +42,12 @@ GET /api/ingredients/ # autocomplete (search, recipeCount) ``` Controller: `controllers/ingredients.ts` | Route: `routes/ingredients.ts` +## Units (/api/units) - requireAuth +``` +GET /api/units/ # list grouped by category (UnitCategory → Unit[]) +``` +Controller: `controllers/units.ts` | Route: `routes/units.ts` + ## Communities (/api/communities) - requireAuth ``` GET /api/communities/ # list user's communities @@ -172,6 +178,15 @@ POST /api/admin/ingredients/:id/merge # merge into another ``` Controller: `admin/controllers/ingredientsController.ts` | Route: `admin/routes/ingredientsRoutes.ts` +## Admin Units (/api/admin/units) - requireSuperAdmin +``` +GET /api/admin/units/ # list all (?search=, ?category=) +POST /api/admin/units/ # create (name, abbreviation, category, sortOrder?) +PATCH /api/admin/units/:id # update (name?, abbreviation?, category?, sortOrder?) +DELETE /api/admin/units/:id # delete (blocked if in use) +``` +Controller: `admin/controllers/unitsController.ts` | Route: `admin/routes/unitsRoutes.ts` + ## Admin Communities (/api/admin/communities) - requireSuperAdmin ``` GET /api/admin/communities/ # list all @@ -213,4 +228,4 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti | adminRateLimiter | middleware/security.ts | 30 req/min global admin | | authRateLimiter | routes config | 5/15min sur auth endpoints | -## Total: 80 endpoints (53 user + 27 admin + 1 health) +## Total: 86 endpoints (54 user + 31 admin + 1 health) diff --git a/.claude/context/DB_MODELS.md b/.claude/context/DB_MODELS.md index a93ca511..fae03cd6 100644 --- a/.claude/context/DB_MODELS.md +++ b/.claude/context/DB_MODELS.md @@ -3,7 +3,7 @@ Source: `backend/prisma/schema.prisma` DB: PostgreSQL | ORM: Prisma -## Models (24 total) +## Models (27 total) ### Sessions (isolees) | Model | Champs cles | Notes | @@ -27,15 +27,17 @@ DB: PostgreSQL | ORM: Prisma | UserCommunity | userId, communityId, role(MEMBER/MODERATOR), joinedAt, deletedAt? | Soft delete, @@unique(userId,communityId) | | CommunityInvite | communityId, inviterId, inviteeId, status(PENDING/ACCEPTED/REJECTED/CANCELLED), respondedAt? | Index composite(communityId,inviteeId,status) | -### Recipes (6 models) +### Recipes (8 models) | Model | Champs cles | Notes | |-------|-------------|-------| | Recipe | id, title, content, imageUrl?, isVariant, creatorId, communityId?, originRecipeId?, sharedFromCommunityId?, deletedAt? | Soft delete. communityId=null → perso | -| RecipeUpdateProposal | recipeId, proposerId, proposedTitle, proposedContent, status(PENDING/ACCEPTED/REJECTED), deletedAt? | Soft delete | +| RecipeUpdateProposal | recipeId, proposerId, proposedTitle, proposedContent, status(PENDING/ACCEPTED/REJECTED), deletedAt?, proposedIngredients[] | Soft delete | | Tag | id, name, scope(GLOBAL/COMMUNITY), status(APPROVED/PENDING), communityId?, createdById?, createdAt, updatedAt | @@unique(name,communityId) + partial unique index global. Index name, communityId+status | | RecipeTag | recipeId, tagId | PK composite, **Cascade** delete | -| Ingredient | id, name(unique) | Index name | -| RecipeIngredient | recipeId, ingredientId, quantity?, order | **Cascade** delete, @@unique(recipeId,ingredientId) | +| Unit | id, name(unique), abbreviation(unique), category(UnitCategory), sortOrder | Index (category,sortOrder). Phase 11 | +| Ingredient | id, name(unique), status(IngredientStatus), defaultUnitId?, createdById?, createdAt, updatedAt | Index name, status. FK Unit + User. Phase 11 enriched | +| RecipeIngredient | recipeId, ingredientId, quantity(Float?), unitId?, order | **Cascade** delete, @@unique(recipeId,ingredientId). FK Unit | +| ProposalIngredient | proposalId, ingredientId, quantity(Float?), unitId?, order | **Cascade** on proposal+ingredient, @@unique(proposalId,ingredientId). Phase 11 | ### Tags (3 models - Phase 10) | Model | Champs cles | Notes | @@ -67,8 +69,13 @@ TagScope: GLOBAL | COMMUNITY TagStatus: APPROVED | PENDING TagSuggestionStatus: PENDING_OWNER | PENDING_MODERATOR | APPROVED | REJECTED +UnitCategory: WEIGHT | VOLUME | SPOON | COUNT | QUALITATIVE +IngredientStatus: APPROVED | PENDING + AdminActionType: TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_MERGED | INGREDIENT_CREATED | INGREDIENT_UPDATED | INGREDIENT_DELETED | INGREDIENT_MERGED | + INGREDIENT_APPROVED | INGREDIENT_REJECTED | + UNIT_CREATED | UNIT_UPDATED | UNIT_DELETED | COMMUNITY_RENAMED | COMMUNITY_DELETED | FEATURE_CREATED | FEATURE_UPDATED | FEATURE_GRANTED | FEATURE_REVOKED | ADMIN_LOGIN | ADMIN_LOGOUT | ADMIN_TOTP_SETUP @@ -86,6 +93,7 @@ ActivityType: RECIPE_CREATED | RECIPE_UPDATED | RECIPE_DELETED | RECIPE_SHARED | User <-N:N-> Community (via UserCommunity avec role) User <-1:N-> Recipe (creatorId) User <-1:N-> Tag (createdById, relation "TagCreator") +User <-1:N-> Ingredient (createdById, relation "IngredientCreator") User <-1:N-> TagSuggestion (suggestedById) User <-1:N-> UserCommunityTagPreference User <-1:N-> ModeratorNotificationPreference @@ -93,6 +101,10 @@ Recipe <-N:N-> Tag (via RecipeTag, cascade) Recipe <-N:N-> Ingredient (via RecipeIngredient, cascade) Recipe <-self-> Recipe (originRecipeId -> variantes/forks) Recipe <-1:N-> TagSuggestion (cascade on delete) +RecipeUpdateProposal <-1:N-> ProposalIngredient (cascade on delete) +Ingredient -> Unit? (defaultUnitId) +RecipeIngredient -> Unit? (unitId) +ProposalIngredient -> Unit? (unitId) Community <-1:N-> CommunityInvite Community <-1:N-> Tag (communityId) Community <-1:N-> UserCommunityTagPreference (cascade) @@ -107,5 +119,5 @@ AdminUser <-1:N-> AdminActivityLog | Type | Modeles | Methode | |------|---------|---------| | Soft delete (deletedAt) | User, Community, UserCommunity, Recipe, RecipeUpdateProposal, CommunityInvite | Applicatif (where deletedAt: null) | -| Hard delete (Cascade) | RecipeTag, RecipeIngredient, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, ModeratorNotificationPreference | DB cascade | +| Hard delete (Cascade) | RecipeTag, RecipeIngredient, ProposalIngredient, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, ModeratorNotificationPreference | DB cascade | | Soft revoke | CommunityFeature | revokedAt timestamp | diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index dd7dce66..ef6b7b12 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -4,20 +4,20 @@ Phases 0 a 9.3 terminees. -## Phase en cours : 10 - Rework Tags +## Phase 10 - Rework Tags : COMPLETE - **Spec** : `docs/features/tags-rework/SPEC_TAGS_REWORK.md` - **Roadmap** : `docs/features/tags-rework/ROADMAP.md` -- **Sous-etape en cours** : 10.9 termine - Phase 10 complete -- **Branche** : `TagsRework` +- **Branche** : `TagsRework` (merged) - **Tests** : 808 (326 frontend + 485 backend) -## Prochains chantiers (a specifier) +## Phase en cours : 11 - Rework Ingredients -- Rework systeme d'ingredients -- Rework pages recettes v2 (etapes, temps, portions) -- Systeme d'upload de photos (Cloudflare R2) -- Audit refactorisation complete back + front +- **Spec** : `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` +- **Roadmap** : `docs/features/ingredients-rework/ROADMAP.md` +- **Sous-etape en cours** : 11.2 termine - passer a 11.3 +- **Branche** : `IngredientsRework` +- **Tests** : 510 backend + 326 frontend = 836 total ## Resume de reprise diff --git a/backend/prisma/migrations/20260218120000_ingredients_rework_schema/migration.sql b/backend/prisma/migrations/20260218120000_ingredients_rework_schema/migration.sql new file mode 100644 index 00000000..78408e28 --- /dev/null +++ b/backend/prisma/migrations/20260218120000_ingredients_rework_schema/migration.sql @@ -0,0 +1,79 @@ +-- CreateEnum +CREATE TYPE "UnitCategory" AS ENUM ('WEIGHT', 'VOLUME', 'SPOON', 'COUNT', 'QUALITATIVE'); + +-- CreateEnum +CREATE TYPE "IngredientStatus" AS ENUM ('APPROVED', 'PENDING'); + +-- CreateTable: Unit +CREATE TABLE "Unit" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "abbreviation" TEXT NOT NULL, + "category" "UnitCategory" NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "Unit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Unit_name_key" ON "Unit"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Unit_abbreviation_key" ON "Unit"("abbreviation"); + +-- CreateIndex +CREATE INDEX "Unit_category_sortOrder_idx" ON "Unit"("category", "sortOrder"); + +-- AlterTable: Enrich Ingredient model +ALTER TABLE "Ingredient" ADD COLUMN "status" "IngredientStatus" NOT NULL DEFAULT 'APPROVED'; +ALTER TABLE "Ingredient" ADD COLUMN "defaultUnitId" TEXT; +ALTER TABLE "Ingredient" ADD COLUMN "createdById" TEXT; +ALTER TABLE "Ingredient" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "Ingredient" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE INDEX "Ingredient_status_idx" ON "Ingredient"("status"); + +-- AddForeignKey +ALTER TABLE "Ingredient" ADD CONSTRAINT "Ingredient_defaultUnitId_fkey" FOREIGN KEY ("defaultUnitId") REFERENCES "Unit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ingredient" ADD CONSTRAINT "Ingredient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable: Modify RecipeIngredient (quantity String? -> Float?, add unitId) +-- Step 1: Drop old quantity column +ALTER TABLE "RecipeIngredient" DROP COLUMN "quantity"; + +-- Step 2: Add new quantity as Float and unitId +ALTER TABLE "RecipeIngredient" ADD COLUMN "quantity" DOUBLE PRECISION; +ALTER TABLE "RecipeIngredient" ADD COLUMN "unitId" TEXT; + +-- AddForeignKey +ALTER TABLE "RecipeIngredient" ADD CONSTRAINT "RecipeIngredient_unitId_fkey" FOREIGN KEY ("unitId") REFERENCES "Unit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable: ProposalIngredient +CREATE TABLE "ProposalIngredient" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "ingredientId" TEXT NOT NULL, + "quantity" DOUBLE PRECISION, + "unitId" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProposalIngredient_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProposalIngredient_proposalId_ingredientId_key" ON "ProposalIngredient"("proposalId", "ingredientId"); + +-- CreateIndex +CREATE INDEX "ProposalIngredient_proposalId_idx" ON "ProposalIngredient"("proposalId"); + +-- AddForeignKey +ALTER TABLE "ProposalIngredient" ADD CONSTRAINT "ProposalIngredient_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "RecipeUpdateProposal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProposalIngredient" ADD CONSTRAINT "ProposalIngredient_ingredientId_fkey" FOREIGN KEY ("ingredientId") REFERENCES "Ingredient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProposalIngredient" ADD CONSTRAINT "ProposalIngredient_unitId_fkey" FOREIGN KEY ("unitId") REFERENCES "Unit"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260218160000_add_ingredient_activity_types/migration.sql b/backend/prisma/migrations/20260218160000_add_ingredient_activity_types/migration.sql new file mode 100644 index 00000000..64d90025 --- /dev/null +++ b/backend/prisma/migrations/20260218160000_add_ingredient_activity_types/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "AdminActionType" ADD VALUE 'INGREDIENT_APPROVED'; +ALTER TYPE "AdminActionType" ADD VALUE 'INGREDIENT_REJECTED'; +ALTER TYPE "AdminActionType" ADD VALUE 'UNIT_CREATED'; +ALTER TYPE "AdminActionType" ADD VALUE 'UNIT_UPDATED'; +ALTER TYPE "AdminActionType" ADD VALUE 'UNIT_DELETED'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d029142a..a26a7123 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -132,6 +132,13 @@ enum AdminActionType { INGREDIENT_UPDATED INGREDIENT_DELETED INGREDIENT_MERGED + INGREDIENT_APPROVED + INGREDIENT_REJECTED + + // Units + UNIT_CREATED + UNIT_UPDATED + UNIT_DELETED // Communities COMMUNITY_RENAMED @@ -171,6 +178,7 @@ model User { invitesSent CommunityInvite[] @relation("InviteSender") invitesReceived CommunityInvite[] @relation("InviteReceiver") createdTags Tag[] @relation("TagCreator") + createdIngredients Ingredient[] @relation("IngredientCreator") tagSuggestions TagSuggestion[] tagPreferences UserCommunityTagPreference[] moderatorNotifPrefs ModeratorNotificationPreference[] @@ -343,6 +351,8 @@ model RecipeUpdateProposal { proposerId String proposer User @relation("ProposalProposer", fields: [proposerId], references: [id]) + proposedIngredients ProposalIngredient[] + @@index([recipeId]) @@index([proposerId]) @@index([status]) @@ -463,36 +473,99 @@ model ModeratorNotificationPreference { @@unique([userId, communityId]) } +// ============================================================================= +// UNIT (Unites de mesure structurees) +// ============================================================================= + +enum UnitCategory { + WEIGHT + VOLUME + SPOON + COUNT + QUALITATIVE +} + +model Unit { + id String @id @default(uuid()) + name String @unique + abbreviation String @unique + category UnitCategory + sortOrder Int @default(0) + + // Relations + recipeIngredients RecipeIngredient[] + proposalIngredients ProposalIngredient[] + defaultIngredients Ingredient[] + + @@index([category, sortOrder]) +} + // ============================================================================= // INGREDIENT & RECIPE-INGREDIENT -// Note: Ingredients crees a la volee (comme les tags) +// Note: Ingredients crees a la volee avec moderation admin // Tables pivot - Cascade OK // ============================================================================= +enum IngredientStatus { + APPROVED + PENDING +} + model Ingredient { - id String @id @default(uuid()) - name String @unique + id String @id @default(uuid()) + name String @unique + status IngredientStatus @default(APPROVED) + defaultUnitId String? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - recipes RecipeIngredient[] + defaultUnit Unit? @relation(fields: [defaultUnitId], references: [id]) + createdBy User? @relation("IngredientCreator", fields: [createdById], references: [id]) + recipes RecipeIngredient[] + proposals ProposalIngredient[] @@index([name]) + @@index([status]) } model RecipeIngredient { id String @id @default(uuid()) recipeId String ingredientId String - quantity String? + quantity Float? + unitId String? order Int @default(0) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + unit Unit? @relation(fields: [unitId], references: [id]) @@unique([recipeId, ingredientId]) @@index([recipeId]) } +// ============================================================================= +// PROPOSAL-INGREDIENT (Ingredients dans les propositions de modification) +// ============================================================================= + +model ProposalIngredient { + id String @id @default(uuid()) + proposalId String + ingredientId String + quantity Float? + unitId String? + order Int @default(0) + + proposal RecipeUpdateProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + unit Unit? @relation(fields: [unitId], references: [id]) + + @@unique([proposalId, ingredientId]) + @@index([proposalId]) +} + // ============================================================================= // ACTIVITY LOG // ============================================================================= diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index b703b5f9..6a06621c 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -4,10 +4,42 @@ import bcrypt from "bcrypt"; const prisma = new PrismaClient(); async function seed() { + // =========================================== + // Units (always upsert - idempotent) + // =========================================== + const unitData = [ + { name: "gramme", abbreviation: "g", category: "WEIGHT", sortOrder: 1 }, + { name: "kilogramme", abbreviation: "kg", category: "WEIGHT", sortOrder: 2 }, + { name: "millilitre", abbreviation: "ml", category: "VOLUME", sortOrder: 1 }, + { name: "centilitre", abbreviation: "cl", category: "VOLUME", sortOrder: 2 }, + { name: "decilitre", abbreviation: "dl", category: "VOLUME", sortOrder: 3 }, + { name: "litre", abbreviation: "l", category: "VOLUME", sortOrder: 4 }, + { name: "cuillere a cafe", abbreviation: "cac", category: "SPOON", sortOrder: 1 }, + { name: "cuillere a soupe", abbreviation: "cas", category: "SPOON", sortOrder: 2 }, + { name: "piece", abbreviation: "pc", category: "COUNT", sortOrder: 1 }, + { name: "tranche", abbreviation: "tr", category: "COUNT", sortOrder: 2 }, + { name: "gousse", abbreviation: "gse", category: "COUNT", sortOrder: 3 }, + { name: "botte", abbreviation: "bte", category: "COUNT", sortOrder: 4 }, + { name: "feuille", abbreviation: "fle", category: "COUNT", sortOrder: 5 }, + { name: "brin", abbreviation: "brn", category: "COUNT", sortOrder: 6 }, + { name: "pincee", abbreviation: "pincee", category: "QUALITATIVE", sortOrder: 1 }, + { name: "a gout", abbreviation: "a gout", category: "QUALITATIVE", sortOrder: 2 }, + { name: "selon besoin", abbreviation: "selon besoin", category: "QUALITATIVE", sortOrder: 3 }, + ]; + + for (const unit of unitData) { + await prisma.unit.upsert({ + where: { name: unit.name }, + update: { abbreviation: unit.abbreviation, category: unit.category, sortOrder: unit.sortOrder }, + create: unit, + }); + } + console.log("Units seeded:", unitData.length); + const userCount = await prisma.user.count(); if (userCount > 0) { - console.log("Database already seeded, skipping..."); + console.log("Database already seeded (users exist), skipping rest..."); return; } diff --git a/backend/src/__tests__/integration/adminIngredients.test.ts b/backend/src/__tests__/integration/adminIngredients.test.ts index 70ed50be..f7bf62e9 100644 --- a/backend/src/__tests__/integration/adminIngredients.test.ts +++ b/backend/src/__tests__/integration/adminIngredients.test.ts @@ -200,7 +200,7 @@ describe('Admin Ingredients API', () => { // Creer une recette avec l'ingredient source await createTestRecipe(user.id, { - ingredients: [{ name: 'source_ing', quantity: '100g' }], + ingredients: [{ name: 'source_ing', quantity: 100 }], }); const res = await request(app) diff --git a/backend/src/__tests__/integration/adminUnits.test.ts b/backend/src/__tests__/integration/adminUnits.test.ts new file mode 100644 index 00000000..f0d247f3 --- /dev/null +++ b/backend/src/__tests__/integration/adminUnits.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../app'; +import { + createTestAdmin, + createTestUser, + createTestUnit, + createTestIngredient, + loginAsAdmin, +} from '../setup/testHelpers'; +import { testPrisma } from '../setup/globalSetup'; + +describe('Admin Units API', () => { + let adminCookie: string; + + beforeEach(async () => { + const admin = await createTestAdmin(); + adminCookie = await loginAsAdmin(admin); + }); + + // ===================================== + // GET /api/admin/units + // ===================================== + describe('GET /api/admin/units', () => { + it('should return all units with usage counts', async () => { + await createTestUnit({ name: 'gramme_test', abbreviation: 'gt', category: 'WEIGHT', sortOrder: 1 }); + await createTestUnit({ name: 'litre_test', abbreviation: 'lt', category: 'VOLUME', sortOrder: 1 }); + + const res = await request(app) + .get('/api/admin/units') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.units).toBeDefined(); + expect(Array.isArray(res.body.units)).toBe(true); + expect(res.body.units.length).toBeGreaterThanOrEqual(2); + + const unit = res.body.units.find((u: { name: string }) => u.name === 'gramme_test'); + expect(unit).toBeDefined(); + expect(unit.abbreviation).toBe('gt'); + expect(unit.category).toBe('WEIGHT'); + expect(typeof unit.usageCount).toBe('number'); + expect(typeof unit.defaultIngredientCount).toBe('number'); + }); + + it('should filter by search query', async () => { + await createTestUnit({ name: 'millilitre_search', abbreviation: 'mls', category: 'VOLUME' }); + await createTestUnit({ name: 'gramme_search', abbreviation: 'gs', category: 'WEIGHT' }); + + const res = await request(app) + .get('/api/admin/units?search=milli') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.units.some((u: { name: string }) => u.name === 'millilitre_search')).toBe(true); + expect(res.body.units.some((u: { name: string }) => u.name === 'gramme_search')).toBe(false); + }); + + it('should filter by category', async () => { + await createTestUnit({ name: 'weight_filter', abbreviation: 'wf', category: 'WEIGHT' }); + await createTestUnit({ name: 'volume_filter', abbreviation: 'vf', category: 'VOLUME' }); + + const res = await request(app) + .get('/api/admin/units?category=WEIGHT') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.units.every((u: { category: string }) => u.category === 'WEIGHT')).toBe(true); + }); + + it('should return 401 without admin authentication', async () => { + const res = await request(app).get('/api/admin/units'); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // POST /api/admin/units + // ===================================== + describe('POST /api/admin/units', () => { + it('should create a new unit', async () => { + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ + name: 'New Unit', + abbreviation: 'nu', + category: 'COUNT', + sortOrder: 10, + }); + + expect(res.status).toBe(201); + expect(res.body.unit).toBeDefined(); + expect(res.body.unit.name).toBe('new unit'); + expect(res.body.unit.abbreviation).toBe('nu'); + expect(res.body.unit.category).toBe('COUNT'); + expect(res.body.unit.sortOrder).toBe(10); + }); + + it('should create audit log entry', async () => { + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ name: 'Audit Unit', abbreviation: 'au', category: 'WEIGHT' }); + + expect(res.status).toBe(201); + + const log = await testPrisma.adminActivityLog.findFirst({ + where: { type: 'UNIT_CREATED', targetId: res.body.unit.id }, + }); + expect(log).toBeDefined(); + expect(log!.targetType).toBe('Unit'); + }); + + it('should reject duplicate name', async () => { + await createTestUnit({ name: 'duplicate_name', abbreviation: 'dn1', category: 'WEIGHT' }); + + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ name: 'duplicate_name', abbreviation: 'dn2', category: 'WEIGHT' }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_UNIT_004'); + }); + + it('should reject duplicate abbreviation', async () => { + await createTestUnit({ name: 'dup_abbr_1', abbreviation: 'dup', category: 'WEIGHT' }); + + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ name: 'dup_abbr_2', abbreviation: 'dup', category: 'WEIGHT' }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_UNIT_005'); + }); + + it('should reject missing name', async () => { + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ abbreviation: 'x', category: 'WEIGHT' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('ADMIN_UNIT_001'); + }); + + it('should reject missing abbreviation', async () => { + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ name: 'no abbr', category: 'WEIGHT' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('ADMIN_UNIT_002'); + }); + + it('should reject invalid category', async () => { + const res = await request(app) + .post('/api/admin/units') + .set('Cookie', adminCookie) + .send({ name: 'bad cat', abbreviation: 'bc', category: 'INVALID' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('ADMIN_UNIT_003'); + }); + }); + + // ===================================== + // PATCH /api/admin/units/:id + // ===================================== + describe('PATCH /api/admin/units/:id', () => { + it('should update unit name', async () => { + const unit = await createTestUnit({ name: 'old_name', abbreviation: 'on', category: 'WEIGHT' }); + + const res = await request(app) + .patch(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie) + .send({ name: 'New Name' }); + + expect(res.status).toBe(200); + expect(res.body.unit.name).toBe('new name'); + }); + + it('should update unit abbreviation', async () => { + const unit = await createTestUnit({ name: 'abbr_update', abbreviation: 'ou', category: 'WEIGHT' }); + + const res = await request(app) + .patch(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie) + .send({ abbreviation: 'NU' }); + + expect(res.status).toBe(200); + expect(res.body.unit.abbreviation).toBe('nu'); + }); + + it('should update category and sortOrder', async () => { + const unit = await createTestUnit({ name: 'cat_update', abbreviation: 'cu', category: 'WEIGHT', sortOrder: 1 }); + + const res = await request(app) + .patch(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie) + .send({ category: 'VOLUME', sortOrder: 5 }); + + expect(res.status).toBe(200); + expect(res.body.unit.category).toBe('VOLUME'); + expect(res.body.unit.sortOrder).toBe(5); + }); + + it('should create audit log on update', async () => { + const unit = await createTestUnit({ name: 'audit_upd', abbreviation: 'aup', category: 'WEIGHT' }); + + await request(app) + .patch(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie) + .send({ name: 'Audit Updated' }); + + const log = await testPrisma.adminActivityLog.findFirst({ + where: { type: 'UNIT_UPDATED', targetId: unit.id }, + }); + expect(log).toBeDefined(); + }); + + it('should reject duplicate name on update', async () => { + await createTestUnit({ name: 'existing_unit', abbreviation: 'eu', category: 'WEIGHT' }); + const unit = await createTestUnit({ name: 'to_rename', abbreviation: 'tr', category: 'WEIGHT' }); + + const res = await request(app) + .patch(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie) + .send({ name: 'existing_unit' }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_UNIT_004'); + }); + + it('should return 404 for non-existent unit', async () => { + const res = await request(app) + .patch('/api/admin/units/00000000-0000-0000-0000-000000000000') + .set('Cookie', adminCookie) + .send({ name: 'anything' }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain('ADMIN_UNIT_006'); + }); + }); + + // ===================================== + // DELETE /api/admin/units/:id + // ===================================== + describe('DELETE /api/admin/units/:id', () => { + it('should delete an unused unit', async () => { + const unit = await createTestUnit({ name: 'to_delete', abbreviation: 'td', category: 'WEIGHT' }); + + const res = await request(app) + .delete(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('deleted'); + + const deleted = await testPrisma.unit.findUnique({ where: { id: unit.id } }); + expect(deleted).toBeNull(); + }); + + it('should create audit log on delete', async () => { + const unit = await createTestUnit({ name: 'audit_del', abbreviation: 'ad', category: 'WEIGHT' }); + + await request(app) + .delete(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie); + + const log = await testPrisma.adminActivityLog.findFirst({ + where: { type: 'UNIT_DELETED', targetId: unit.id }, + }); + expect(log).toBeDefined(); + }); + + it('should prevent deleting a unit used in recipes', async () => { + const unit = await createTestUnit({ name: 'used_unit', abbreviation: 'uu', category: 'WEIGHT' }); + const user = await createTestUser(); + const ingredient = await createTestIngredient('used_ing'); + + // Creer une recette avec cet ingredient et cette unite + const recipe = await testPrisma.recipe.create({ + data: { title: 'Test', content: 'Test', creatorId: user.id }, + }); + await testPrisma.recipeIngredient.create({ + data: { recipeId: recipe.id, ingredientId: ingredient.id, unitId: unit.id, quantity: 100 }, + }); + + const res = await request(app) + .delete(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_UNIT_007'); + }); + + it('should prevent deleting a unit used as default', async () => { + const unit = await createTestUnit({ name: 'default_unit', abbreviation: 'du', category: 'WEIGHT' }); + await createTestIngredient('default_ing', { defaultUnitId: unit.id }); + + const res = await request(app) + .delete(`/api/admin/units/${unit.id}`) + .set('Cookie', adminCookie); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_UNIT_007'); + }); + + it('should return 404 for non-existent unit', async () => { + const res = await request(app) + .delete('/api/admin/units/00000000-0000-0000-0000-000000000000') + .set('Cookie', adminCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain('ADMIN_UNIT_006'); + }); + }); +}); + +// ===================================== +// User Units API (GET /api/units) +// ===================================== +describe('User Units API', () => { + let userCookie: string; + + beforeEach(async () => { + const user = await createTestUser(); + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: user.username, password: user.password }); + userCookie = loginRes.headers['set-cookie']?.[0]?.split(';')[0] || ''; + }); + + describe('GET /api/units', () => { + it('should return units grouped by category', async () => { + await createTestUnit({ name: 'user_gram', abbreviation: 'ug', category: 'WEIGHT', sortOrder: 1 }); + await createTestUnit({ name: 'user_litre', abbreviation: 'ul', category: 'VOLUME', sortOrder: 1 }); + + const res = await request(app) + .get('/api/units') + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(typeof res.body.data).toBe('object'); + + // Verifier la structure groupee + const hasWeight = res.body.data.WEIGHT?.some((u: { name: string }) => u.name === 'user_gram'); + const hasVolume = res.body.data.VOLUME?.some((u: { name: string }) => u.name === 'user_litre'); + expect(hasWeight).toBe(true); + expect(hasVolume).toBe(true); + }); + + it('should return unit details without usage counts', async () => { + await createTestUnit({ name: 'detail_unit', abbreviation: 'dtu', category: 'COUNT', sortOrder: 1 }); + + const res = await request(app) + .get('/api/units') + .set('Cookie', userCookie); + + expect(res.status).toBe(200); + const countUnits = res.body.data.COUNT; + const unit = countUnits?.find((u: { name: string }) => u.name === 'detail_unit'); + expect(unit).toBeDefined(); + expect(unit.abbreviation).toBe('dtu'); + expect(unit.sortOrder).toBe(1); + // Pas de usageCount expose cote user + expect(unit.usageCount).toBeUndefined(); + }); + + it('should return 401 without authentication', async () => { + const res = await request(app).get('/api/units'); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/integration/communityRecipes.test.ts b/backend/src/__tests__/integration/communityRecipes.test.ts index bedf27c6..f9640e25 100644 --- a/backend/src/__tests__/integration/communityRecipes.test.ts +++ b/backend/src/__tests__/integration/communityRecipes.test.ts @@ -109,8 +109,8 @@ describe("Community Recipes API", () => { content: "Contenu detaille", tags: ["dessert", "rapide"], ingredients: [ - { name: "sucre", quantity: "100g" }, - { name: "farine", quantity: "200g" }, + { name: "sucre", quantity: 100 }, + { name: "farine", quantity: 200 }, ], }); @@ -440,7 +440,7 @@ describe("Community Recipes API", () => { title: "Recette detail", content: "Contenu detail", tags: ["tag1"], - ingredients: [{ name: "ingredient1", quantity: "50g" }], + ingredients: [{ name: "ingredient1", quantity: 50 }], }); communityRecipeId = createRes.body.community.id; }); diff --git a/backend/src/__tests__/integration/ingredients.test.ts b/backend/src/__tests__/integration/ingredients.test.ts index c345073f..ee655792 100644 --- a/backend/src/__tests__/integration/ingredients.test.ts +++ b/backend/src/__tests__/integration/ingredients.test.ts @@ -34,14 +34,14 @@ describe('Ingredients API', () => { // Creer des recettes avec des ingredients await createTestRecipe(userId, { ingredients: [ - { name: 'flour', quantity: '200g' }, - { name: 'sugar', quantity: '100g' }, + { name: 'flour', quantity: 200 }, + { name: 'sugar', quantity: 100 }, ], }); await createTestRecipe(userId, { ingredients: [ - { name: 'flour', quantity: '300g' }, - { name: 'butter', quantity: '50g' }, + { name: 'flour', quantity: 300 }, + { name: 'butter', quantity: 50 }, ], }); diff --git a/backend/src/__tests__/integration/recipes.test.ts b/backend/src/__tests__/integration/recipes.test.ts index 168e51da..46d201dd 100644 --- a/backend/src/__tests__/integration/recipes.test.ts +++ b/backend/src/__tests__/integration/recipes.test.ts @@ -47,8 +47,8 @@ describe('Recipes API', () => { content: 'Contenu', tags: ['dessert', 'rapide'], ingredients: [ - { name: 'sucre', quantity: '100g' }, - { name: 'farine', quantity: '200g' }, + { name: 'sucre', quantity: 100 }, + { name: 'farine', quantity: 200 }, ], }); @@ -229,7 +229,7 @@ describe('Recipes API', () => { title: 'Ma recette', content: 'Mon contenu', tags: ['tag1'], - ingredients: [{ name: 'ingredient1', quantity: '100g' }], + ingredients: [{ name: 'ingredient1', quantity: 100 }], }); const res = await request(app) diff --git a/backend/src/__tests__/integration/share.test.ts b/backend/src/__tests__/integration/share.test.ts index 8d780586..a60a4ff3 100644 --- a/backend/src/__tests__/integration/share.test.ts +++ b/backend/src/__tests__/integration/share.test.ts @@ -72,7 +72,7 @@ describe("Share Recipe API", () => { title: "Recipe to Share", content: "This recipe will be shared", tags: ["sharing", "test"], - ingredients: [{ name: "ingredient1", quantity: "100g" }], + ingredients: [{ name: "ingredient1", quantity: 100 }], }); communityRecipeId = recipeRes.body.community.id; }); @@ -533,8 +533,8 @@ describe("Share Recipe API", () => { .set("Cookie", user1Cookie) .send({ ingredients: [ - { name: "new ingredient", quantity: "200g" }, - { name: "another ingredient", quantity: "50ml" }, + { name: "new ingredient", quantity: 200 }, + { name: "another ingredient", quantity: 50 }, ], }); @@ -548,7 +548,7 @@ describe("Share Recipe API", () => { }); expect(communityIngredients).toHaveLength(2); expect(communityIngredients[0].ingredient.name).toBe("new ingredient"); - expect(communityIngredients[0].quantity).toBe("200g"); + expect(communityIngredients[0].quantity).toBe(200); expect(communityIngredients[1].ingredient.name).toBe("another ingredient"); }); @@ -606,7 +606,7 @@ describe("Share Recipe API", () => { title: "Personal to Publish", content: "Content to publish", tags: ["publish"], - ingredients: [{ name: "flour", quantity: "200g" }], + ingredients: [{ name: "flour", quantity: 200 }], }); personalRecipeId = res.body.id; }); diff --git a/backend/src/__tests__/setup/globalSetup.ts b/backend/src/__tests__/setup/globalSetup.ts index 5d46bd15..ac186815 100644 --- a/backend/src/__tests__/setup/globalSetup.ts +++ b/backend/src/__tests__/setup/globalSetup.ts @@ -15,6 +15,7 @@ afterEach(async () => { // Nettoyer les donnees de test apres chaque test // Ordre important pour respecter les contraintes FK await testPrisma.$transaction([ + testPrisma.proposalIngredient.deleteMany(), testPrisma.recipeIngredient.deleteMany(), testPrisma.recipeTag.deleteMany(), testPrisma.recipeView.deleteMany(), @@ -23,6 +24,7 @@ afterEach(async () => { testPrisma.recipe.deleteMany(), testPrisma.tag.deleteMany(), testPrisma.ingredient.deleteMany(), + testPrisma.unit.deleteMany(), testPrisma.activityLog.deleteMany(), testPrisma.communityInvite.deleteMany(), testPrisma.userCommunity.deleteMany(), diff --git a/backend/src/__tests__/setup/testHelpers.ts b/backend/src/__tests__/setup/testHelpers.ts index e4ac5699..bda0fed0 100644 --- a/backend/src/__tests__/setup/testHelpers.ts +++ b/backend/src/__tests__/setup/testHelpers.ts @@ -133,7 +133,7 @@ export async function createTestRecipe( content: string; imageUrl: string | null; tags: string[]; - ingredients: Array<{ name: string; quantity?: string }>; + ingredients: Array<{ name: string; quantity?: number }>; }> ): Promise { // Creer/trouver les tags en amont (compound unique avec nullable ne supporte pas connectOrCreate) @@ -208,10 +208,42 @@ export async function createTestTag( /** * Creer un ingredient de test */ -export async function createTestIngredient(name?: string) { +export async function createTestIngredient( + name?: string, + options?: Partial<{ + status: 'APPROVED' | 'PENDING'; + defaultUnitId: string; + createdById: string; + }> +) { return testPrisma.ingredient.create({ data: { name: name ?? `ingredient_${Date.now()}`, + status: options?.status, + defaultUnitId: options?.defaultUnitId, + createdById: options?.createdById, + }, + }); +} + +/** + * Creer une unite de test + */ +export async function createTestUnit( + data?: Partial<{ + name: string; + abbreviation: string; + category: 'WEIGHT' | 'VOLUME' | 'SPOON' | 'COUNT' | 'QUALITATIVE'; + sortOrder: number; + }> +) { + const suffix = Date.now(); + return testPrisma.unit.create({ + data: { + name: data?.name ?? `unit_${suffix}`, + abbreviation: data?.abbreviation ?? `u${suffix}`, + category: data?.category ?? 'WEIGHT', + sortOrder: data?.sortOrder ?? 0, }, }); } diff --git a/backend/src/__tests__/unit/responseFormatters.test.ts b/backend/src/__tests__/unit/responseFormatters.test.ts index 2e3c66f3..58ab508b 100644 --- a/backend/src/__tests__/unit/responseFormatters.test.ts +++ b/backend/src/__tests__/unit/responseFormatters.test.ts @@ -23,21 +23,21 @@ describe("formatIngredients", () => { const raw = [ { id: "ri1", - quantity: "100g", + quantity: 100, order: 0, ingredient: { id: "i1", name: "sugar" }, }, { id: "ri2", - quantity: "200ml", + quantity: 200, order: 1, ingredient: { id: "i2", name: "milk" }, }, ]; expect(formatIngredients(raw)).toEqual([ - { id: "ri1", name: "sugar", ingredientId: "i1", quantity: "100g", order: 0 }, - { id: "ri2", name: "milk", ingredientId: "i2", quantity: "200ml", order: 1 }, + { id: "ri1", name: "sugar", ingredientId: "i1", quantity: 100, order: 0 }, + { id: "ri2", name: "milk", ingredientId: "i2", quantity: 200, order: 1 }, ]); }); diff --git a/backend/src/admin/controllers/unitsController.ts b/backend/src/admin/controllers/unitsController.ts new file mode 100644 index 00000000..46420f9c --- /dev/null +++ b/backend/src/admin/controllers/unitsController.ts @@ -0,0 +1,260 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import { Prisma } from "@prisma/client"; +import prisma from "../../util/db"; +import { assertIsDefine } from "../../util/assertIsDefine"; + +const VALID_CATEGORIES = ["WEIGHT", "VOLUME", "SPOON", "COUNT", "QUALITATIVE"]; + +/** + * GET /api/admin/units + * Liste toutes les unites avec usage count + */ +export const getAll: RequestHandler = async (req, res, next) => { + try { + const { search, category } = req.query; + + const where: Record = {}; + + if (search) { + where.OR = [ + { name: { contains: String(search), mode: "insensitive" } }, + { abbreviation: { contains: String(search), mode: "insensitive" } }, + ]; + } + + if (category && VALID_CATEGORIES.includes(String(category))) { + where.category = String(category); + } + + const units = await prisma.unit.findMany({ + where, + include: { + _count: { + select: { + recipeIngredients: true, + proposalIngredients: true, + defaultIngredients: true, + }, + }, + }, + orderBy: [{ category: "asc" }, { sortOrder: "asc" }], + }); + + res.status(200).json({ + units: units.map((u) => ({ + id: u.id, + name: u.name, + abbreviation: u.abbreviation, + category: u.category, + sortOrder: u.sortOrder, + usageCount: u._count.recipeIngredients + u._count.proposalIngredients, + defaultIngredientCount: u._count.defaultIngredients, + })), + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/admin/units + * Cree une nouvelle unite + */ +export const create: RequestHandler = async (req, res, next) => { + try { + const { name, abbreviation, category, sortOrder } = req.body; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + if (!name || typeof name !== "string" || name.trim().length === 0) { + throw createHttpError(400, "ADMIN_UNIT_001: Name is required"); + } + + if (!abbreviation || typeof abbreviation !== "string" || abbreviation.trim().length === 0) { + throw createHttpError(400, "ADMIN_UNIT_002: Abbreviation is required"); + } + + if (!category || !VALID_CATEGORIES.includes(category)) { + throw createHttpError(400, "ADMIN_UNIT_003: Valid category is required (WEIGHT, VOLUME, SPOON, COUNT, QUALITATIVE)"); + } + + const normalizedName = name.trim().toLowerCase(); + const normalizedAbbr = abbreviation.trim().toLowerCase(); + + // Verifier unicite nom + const existingName = await prisma.unit.findUnique({ where: { name: normalizedName } }); + if (existingName) { + throw createHttpError(409, "ADMIN_UNIT_004: Unit name already exists"); + } + + // Verifier unicite abbreviation + const existingAbbr = await prisma.unit.findUnique({ where: { abbreviation: normalizedAbbr } }); + if (existingAbbr) { + throw createHttpError(409, "ADMIN_UNIT_005: Abbreviation already exists"); + } + + const unit = await prisma.unit.create({ + data: { + name: normalizedName, + abbreviation: normalizedAbbr, + category, + sortOrder: typeof sortOrder === "number" ? sortOrder : 0, + }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "UNIT_CREATED", + targetType: "Unit", + targetId: unit.id, + metadata: { name: normalizedName, abbreviation: normalizedAbbr, category }, + }, + }); + + res.status(201).json({ unit }); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/admin/units/:id + * Modifie une unite + */ +export const update: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const { name, abbreviation, category, sortOrder } = req.body; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const unit = await prisma.unit.findUnique({ where: { id } }); + if (!unit) { + throw createHttpError(404, "ADMIN_UNIT_006: Unit not found"); + } + + const data: Record = {}; + const metadata: Record = {}; + + if (name !== undefined) { + if (typeof name !== "string" || name.trim().length === 0) { + throw createHttpError(400, "ADMIN_UNIT_001: Name is required"); + } + const normalizedName = name.trim().toLowerCase(); + if (normalizedName !== unit.name) { + const existing = await prisma.unit.findUnique({ where: { name: normalizedName } }); + if (existing) { + throw createHttpError(409, "ADMIN_UNIT_004: Unit name already exists"); + } + metadata.oldName = unit.name; + metadata.newName = normalizedName; + data.name = normalizedName; + } + } + + if (abbreviation !== undefined) { + if (typeof abbreviation !== "string" || abbreviation.trim().length === 0) { + throw createHttpError(400, "ADMIN_UNIT_002: Abbreviation is required"); + } + const normalizedAbbr = abbreviation.trim().toLowerCase(); + if (normalizedAbbr !== unit.abbreviation) { + const existing = await prisma.unit.findUnique({ where: { abbreviation: normalizedAbbr } }); + if (existing) { + throw createHttpError(409, "ADMIN_UNIT_005: Abbreviation already exists"); + } + metadata.oldAbbreviation = unit.abbreviation; + metadata.newAbbreviation = normalizedAbbr; + data.abbreviation = normalizedAbbr; + } + } + + if (category !== undefined) { + if (!VALID_CATEGORIES.includes(category)) { + throw createHttpError(400, "ADMIN_UNIT_003: Valid category is required (WEIGHT, VOLUME, SPOON, COUNT, QUALITATIVE)"); + } + data.category = category; + } + + if (typeof sortOrder === "number") { + data.sortOrder = sortOrder; + } + + if (Object.keys(data).length === 0) { + return res.status(200).json({ unit }); + } + + const updated = await prisma.unit.update({ + where: { id }, + data, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "UNIT_UPDATED", + targetType: "Unit", + targetId: id, + metadata, + }, + }); + + res.status(200).json({ unit: updated }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/admin/units/:id + * Supprime une unite (uniquement si non utilisee) + */ +export const remove: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const unit = await prisma.unit.findUnique({ + where: { id }, + include: { + _count: { + select: { + recipeIngredients: true, + proposalIngredients: true, + defaultIngredients: true, + }, + }, + }, + }); + + if (!unit) { + throw createHttpError(404, "ADMIN_UNIT_006: Unit not found"); + } + + const totalUsage = unit._count.recipeIngredients + unit._count.proposalIngredients + unit._count.defaultIngredients; + if (totalUsage > 0) { + throw createHttpError( + 409, + "ADMIN_UNIT_007: Cannot delete unit that is in use. Migrate recipes to another unit first." + ); + } + + await prisma.unit.delete({ where: { id } }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "UNIT_DELETED", + targetType: "Unit", + targetId: id, + metadata: { name: unit.name, abbreviation: unit.abbreviation }, + }, + }); + + res.status(200).json({ message: "Unit deleted" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/admin/routes/unitsRoutes.ts b/backend/src/admin/routes/unitsRoutes.ts new file mode 100644 index 00000000..73549e9c --- /dev/null +++ b/backend/src/admin/routes/unitsRoutes.ts @@ -0,0 +1,18 @@ +import express from "express"; +import * as unitsController from "../controllers/unitsController"; + +const router = express.Router(); + +// GET /api/admin/units - Liste toutes les unites +router.get("/", unitsController.getAll); + +// POST /api/admin/units - Cree une unite +router.post("/", unitsController.create); + +// PATCH /api/admin/units/:id - Modifie une unite +router.patch("/:id", unitsController.update); + +// DELETE /api/admin/units/:id - Supprime une unite +router.delete("/:id", unitsController.remove); + +export default router; diff --git a/backend/src/app.ts b/backend/src/app.ts index bfec825d..eb426e29 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -3,6 +3,7 @@ import authRoutes from "./routes/auth"; import recipesRoutes from "./routes/recipes"; import tagsRoutes from "./routes/tags"; import ingredientsRoutes from "./routes/ingredients"; +import unitsRoutes from "./routes/units"; import communitiesRoutes from "./routes/communities"; import invitesRoutes from "./routes/invites"; import usersRoutes from "./routes/users"; @@ -15,6 +16,7 @@ import adminCommunitiesRoutes from "./admin/routes/communitiesRoutes"; import adminFeaturesRoutes from "./admin/routes/featuresRoutes"; import adminDashboardRoutes from "./admin/routes/dashboardRoutes"; import adminActivityRoutes from "./admin/routes/activityRoutes"; +import adminUnitsRoutes from "./admin/routes/unitsRoutes"; import createHttpError, { isHttpError } from "http-errors"; import { httpLogger } from "./middleware/httpLogger"; import logger from "./util/logger"; @@ -100,6 +102,7 @@ app.use("/api/auth", userSession, authRoutes); app.use("/api/recipes", userSession, requireAuth, recipesRoutes); app.use("/api/tags", userSession, requireAuth, tagsRoutes); app.use("/api/ingredients", userSession, requireAuth, ingredientsRoutes); +app.use("/api/units", userSession, requireAuth, unitsRoutes); app.use("/api/communities", userSession, requireAuth, communitiesRoutes); app.use("/api/invites", userSession, requireAuth, invitesRoutes); app.use("/api/users", userSession, requireAuth, usersRoutes); @@ -115,6 +118,7 @@ app.use("/api/admin/communities", adminSession, requireSuperAdmin, adminCommunit app.use("/api/admin/features", adminSession, requireSuperAdmin, adminFeaturesRoutes); app.use("/api/admin/dashboard", adminSession, requireSuperAdmin, adminDashboardRoutes); app.use("/api/admin/activity", adminSession, requireSuperAdmin, adminActivityRoutes); +app.use("/api/admin/units", adminSession, requireSuperAdmin, adminUnitsRoutes); app.use((req, res, next) => { next(createHttpError(404, "Endpoint not found")); diff --git a/backend/src/controllers/communityRecipes.ts b/backend/src/controllers/communityRecipes.ts index 6b6b9d9a..1e11675f 100644 --- a/backend/src/controllers/communityRecipes.ts +++ b/backend/src/controllers/communityRecipes.ts @@ -13,7 +13,7 @@ import { getModeratorIdsForTagNotification } from "../services/notificationServi interface IngredientInput { name: string; - quantity?: string; + quantity?: number; } interface CreateCommunityRecipeBody { diff --git a/backend/src/controllers/recipes.ts b/backend/src/controllers/recipes.ts index 88bdcbee..a2f0c55b 100644 --- a/backend/src/controllers/recipes.ts +++ b/backend/src/controllers/recipes.ts @@ -206,7 +206,7 @@ export const getRecipe: RequestHandler = async (req, res, next) => { interface IngredientInput { name: string; - quantity?: string; + quantity?: number; } interface CreateRecipeBody { diff --git a/backend/src/controllers/units.ts b/backend/src/controllers/units.ts new file mode 100644 index 00000000..0fd81ac1 --- /dev/null +++ b/backend/src/controllers/units.ts @@ -0,0 +1,39 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import { assertIsDefine } from "../util/assertIsDefine"; + +/** + * GET /api/units + * Liste toutes les unites groupees par categorie, triees par sortOrder + */ +export const getUnits: RequestHandler = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const units = await prisma.unit.findMany({ + select: { + id: true, + name: true, + abbreviation: true, + category: true, + sortOrder: true, + }, + orderBy: [{ category: "asc" }, { sortOrder: "asc" }], + }); + + // Grouper par categorie + const grouped: Record = {}; + for (const unit of units) { + if (!grouped[unit.category]) { + grouped[unit.category] = []; + } + grouped[unit.category].push(unit); + } + + res.status(200).json({ data: grouped }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/routes/units.ts b/backend/src/routes/units.ts new file mode 100644 index 00000000..50b98cbc --- /dev/null +++ b/backend/src/routes/units.ts @@ -0,0 +1,8 @@ +import express from "express"; +import * as UnitsController from "../controllers/units"; + +const router = express.Router(); + +router.get("/", UnitsController.getUnits); + +export default router; diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index c8a2af27..2269ad50 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -10,7 +10,7 @@ type TransactionClient = Omit< export interface IngredientInput { name: string; - quantity?: string; + quantity?: number; } // --- Helpers partages pour tags/ingredients --- @@ -53,7 +53,7 @@ export async function upsertIngredients( data: { recipeId, ingredientId: ingredient.id, - quantity: ing.quantity?.trim() || null, + quantity: ing.quantity ?? null, order: i, }, }); diff --git a/backend/src/services/shareService.ts b/backend/src/services/shareService.ts index cd32cbb6..337fa993 100644 --- a/backend/src/services/shareService.ts +++ b/backend/src/services/shareService.ts @@ -9,7 +9,7 @@ interface SourceRecipeForShare { imageUrl: string | null; communityId: string; tags: { tagId: string; tag: { id: string; name: string; scope: string; communityId: string | null } }[]; - ingredients: { ingredientId: string; quantity: string | null; order: number }[]; + ingredients: { ingredientId: string; quantity: number | null; order: number }[]; } /** @@ -133,7 +133,7 @@ interface SourceRecipeForPublish { content: string; imageUrl: string | null; tags: { tagId: string }[]; - ingredients: { ingredientId: string; quantity: string | null; order: number }[]; + ingredients: { ingredientId: string; quantity: number | null; order: number }[]; } /** diff --git a/backend/src/util/responseFormatters.ts b/backend/src/util/responseFormatters.ts index 267dfe49..aba5c413 100644 --- a/backend/src/util/responseFormatters.ts +++ b/backend/src/util/responseFormatters.ts @@ -6,7 +6,7 @@ type RawTag = { tag: { id: string; name: string; scope: string; status: string; communityId: string | null } }; type RawIngredient = { id: string; - quantity: string | null; + quantity: number | null; order: number; ingredient: { id: string; name: string }; }; diff --git a/docs/features/ingredients-rework/ROADMAP.md b/docs/features/ingredients-rework/ROADMAP.md index 279eeead..62db6549 100644 --- a/docs/features/ingredients-rework/ROADMAP.md +++ b/docs/features/ingredients-rework/ROADMAP.md @@ -7,26 +7,26 @@ ## 11.1 - Schema & Migration -- [ ] Migration Prisma : creer table Unit (id, name, abbreviation, category, sortOrder) -- [ ] Migration Prisma : enrichir Ingredient (status, defaultUnitId, createdById, createdAt, updatedAt) -- [ ] Migration Prisma : modifier RecipeIngredient (quantity String? → Float?, ajout unitId) -- [ ] Migration Prisma : creer table ProposalIngredient -- [ ] Migration Prisma : relation ProposalIngredient sur RecipeUpdateProposal -- [ ] Nouveaux enums : UnitCategory, IngredientStatus -- [ ] Nouveaux types AdminActionType : INGREDIENT_APPROVED, INGREDIENT_REJECTED, UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED -- [ ] Seed : unites standard (18 unites, 5 categories) -- [ ] Seed : mettre a jour les ingredients existants (status=APPROVED) -- [ ] Tests migration +- [x] Migration Prisma : creer table Unit (id, name, abbreviation, category, sortOrder) +- [x] Migration Prisma : enrichir Ingredient (status, defaultUnitId, createdById, createdAt, updatedAt) +- [x] Migration Prisma : modifier RecipeIngredient (quantity String? → Float?, ajout unitId) +- [x] Migration Prisma : creer table ProposalIngredient +- [x] Migration Prisma : relation ProposalIngredient sur RecipeUpdateProposal +- [x] Nouveaux enums : UnitCategory, IngredientStatus +- [x] Nouveaux types AdminActionType : INGREDIENT_APPROVED, INGREDIENT_REJECTED, UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED +- [x] Seed : unites standard (17 unites, 5 categories) +- [x] Seed : mettre a jour les ingredients existants (status=APPROVED) +- [x] Tests migration ## 11.2 - Backend Units (CRUD admin + lecture user) -- [ ] Endpoint user : GET /api/units (liste groupee par categorie) -- [ ] Endpoint admin : GET /api/admin/units -- [ ] Endpoint admin : POST /api/admin/units (creation + validation unicite) -- [ ] Endpoint admin : PATCH /api/admin/units/:id (modification) -- [ ] Endpoint admin : DELETE /api/admin/units/:id (avec protection si utilisee) -- [ ] Audit log : UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED -- [ ] Tests unitaires + integration +- [x] Endpoint user : GET /api/units (liste groupee par categorie) +- [x] Endpoint admin : GET /api/admin/units +- [x] Endpoint admin : POST /api/admin/units (creation + validation unicite) +- [x] Endpoint admin : PATCH /api/admin/units/:id (modification) +- [x] Endpoint admin : DELETE /api/admin/units/:id (avec protection si utilisee) +- [x] Audit log : UNIT_CREATED, UNIT_UPDATED, UNIT_DELETED +- [x] Tests unitaires + integration ## 11.3 - Backend Ingredients (gouvernance + moderation) diff --git a/frontend/src/models/recipe.ts b/frontend/src/models/recipe.ts index 1e84cd0c..e88531d0 100644 --- a/frontend/src/models/recipe.ts +++ b/frontend/src/models/recipe.ts @@ -10,7 +10,7 @@ export interface RecipeIngredient { id: string; name: string; ingredientId: string; - quantity: string | null; + quantity: number | null; order: number; } diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 9e316826..4a85f9c9 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -72,7 +72,7 @@ export interface RecipeInput { content: string; imageUrl?: string; tags?: string[]; - ingredients?: { name: string; quantity?: string }[]; + ingredients?: { name: string; quantity?: number }[]; } export interface GetRecipesParams { diff --git a/frontend/src/pages/RecipeFormPage.tsx b/frontend/src/pages/RecipeFormPage.tsx index 4c5f07d2..dff8c619 100644 --- a/frontend/src/pages/RecipeFormPage.tsx +++ b/frontend/src/pages/RecipeFormPage.tsx @@ -53,7 +53,7 @@ const RecipeFormPage = () => { setIngredients( recipe.ingredients.map((ing) => ({ name: ing.name, - quantity: ing.quantity || "", + quantity: ing.quantity != null ? String(ing.quantity) : "", })) ); } catch (err) { @@ -75,10 +75,14 @@ const RecipeFormPage = () => { tags: tags, ingredients: ingredients .filter((ing) => ing.name.trim()) - .map((ing) => ({ - name: ing.name.trim(), - quantity: ing.quantity.trim() || undefined, - })), + .map((ing) => { + const qty = ing.quantity.trim(); + const parsed = qty ? parseFloat(qty) : undefined; + return { + name: ing.name.trim(), + quantity: parsed != null && !isNaN(parsed) ? parsed : undefined, + }; + }), }; if (isEditing && id) { From 7127a8c73560d3f6f1111aa9bdc44cdfbfdabcce Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Wed, 18 Feb 2026 10:12:18 +0100 Subject: [PATCH 015/221] feat: ingredient governance with PENDING status, approve/reject & suggested-unit (Phase 11.3) Refactor upsertIngredients for PENDING creation with createdById and unitId support. Add admin approve/reject endpoints, enrich admin getAll with status filter and createdBy/defaultUnit fields, handle ProposalIngredient in merge. Add user suggested-unit endpoint with smart fallback (defaultUnit -> popular -> null). --- .claude/CLAUDE.md | 2 +- .claude/context/API_MAP.md | 17 +- .claude/context/PROGRESS.md | 4 +- .claude/context/TESTS.md | 9 +- .../integration/adminIngredients.test.ts | 282 ++++++++++++++++-- .../controllers/ingredientsController.ts | 233 +++++++++++++-- backend/src/admin/routes/ingredientsRoutes.ts | 8 +- backend/src/controllers/communityRecipes.ts | 1 + backend/src/controllers/ingredients.ts | 60 ++++ backend/src/controllers/recipes.ts | 1 + backend/src/routes/ingredients.ts | 1 + .../src/services/communityRecipeService.ts | 4 +- backend/src/services/recipeService.ts | 32 +- docs/features/ingredients-rework/ROADMAP.md | 22 +- 14 files changed, 587 insertions(+), 89 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 87686e20..fe2b7491 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,7 +43,7 @@ Voir `.claude/context/PROGRESS.md` pour le detail et les liens vers spec/roadmap ## Codes erreur -AUTH_001-002 | COMMUNITY_001-006 | RECIPE_001-005 | INVITE_001-003 | ADMIN_001-012 | PROPOSAL_001-003 | SHARE_001-003 | TAG_001-007 +AUTH_001-002 | COMMUNITY_001-006 | RECIPE_001-005 | INVITE_001-003 | ADMIN_001-012 | PROPOSAL_001-003 | SHARE_001-003 | TAG_001-007 | ADMIN_ING_001-009 | ADMIN_UNIT_001-007 ## Regle: maintenir `.claude/` a jour diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index e7bb8fb9..0bcf27ba 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -38,7 +38,8 @@ Controller: `controllers/tags.ts` | Route: `routes/tags.ts` ## Ingredients (/api/ingredients) - requireAuth ``` -GET /api/ingredients/ # autocomplete (search, recipeCount) +GET /api/ingredients/ # autocomplete (search, recipeCount, status) +GET /api/ingredients/:id/suggested-unit # suggested unit (defaultUnit → popular → null) ``` Controller: `controllers/ingredients.ts` | Route: `routes/ingredients.ts` @@ -170,11 +171,13 @@ Controller: `admin/controllers/tagsController.ts` | Route: `admin/routes/tagsRou ## Admin Ingredients (/api/admin/ingredients) - requireSuperAdmin ``` -GET /api/admin/ingredients/ # list all -POST /api/admin/ingredients/ # create -PATCH /api/admin/ingredients/:id # update -DELETE /api/admin/ingredients/:id # delete -POST /api/admin/ingredients/:id/merge # merge into another +GET /api/admin/ingredients/ # list all (?search=, ?status=APPROVED|PENDING) +POST /api/admin/ingredients/ # create (name, defaultUnitId?) +PATCH /api/admin/ingredients/:id # update (name?, defaultUnitId?) +DELETE /api/admin/ingredients/:id # delete +POST /api/admin/ingredients/:id/merge # merge into another (+ ProposalIngredient) +POST /api/admin/ingredients/:id/approve # approve PENDING (newName?) +POST /api/admin/ingredients/:id/reject # reject PENDING (reason required, hard delete) ``` Controller: `admin/controllers/ingredientsController.ts` | Route: `admin/routes/ingredientsRoutes.ts` @@ -228,4 +231,4 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti | adminRateLimiter | middleware/security.ts | 30 req/min global admin | | authRateLimiter | routes config | 5/15min sur auth endpoints | -## Total: 86 endpoints (54 user + 31 admin + 1 health) +## Total: 89 endpoints (55 user + 33 admin + 1 health) diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index ef6b7b12..fcb60efa 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -15,9 +15,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` - **Roadmap** : `docs/features/ingredients-rework/ROADMAP.md` -- **Sous-etape en cours** : 11.2 termine - passer a 11.3 +- **Sous-etape en cours** : 11.3 termine - passer a 11.4 - **Branche** : `IngredientsRework` -- **Tests** : 510 backend + 326 frontend = 836 total +- **Tests** : 524 backend + 326 frontend = 850 total ## Resume de reprise diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index 52aa6eef..a360bd88 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -40,9 +40,9 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Mocks: `__tests__/setup/mswHandlers.ts` - Utils: `__tests__/setup/testUtils.tsx` -## Inventaire des tests (~808 tests) +## Inventaire des tests (~850 tests) -### Backend Integration (20 fichiers, ~380 tests) +### Backend Integration (21 fichiers, ~420 tests) | Fichier | Module | Tests | |---------|--------|-------| @@ -53,13 +53,14 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | proposals.test.ts | Propositions modifications | 31 | | variants.test.ts | Liste variantes recettes | 10 | | tags.test.ts | Autocomplete tags (scope-aware) | 9 | -| ingredients.test.ts | Autocomplete ingredients | 5 | +| ingredients.test.ts | Autocomplete ingredients + suggested-unit | 5 | | communities.test.ts | CRUD communautes | 27 | | invitations.test.ts | Workflow invitations | 35 | | members.test.ts | Membres: list, promote, kick, orphan handling | 26 | | adminAuth.test.ts | Auth 2FA admin | 14 | | adminTags.test.ts | CRUD tags admin (+ scope filter) | 15 | -| adminIngredients.test.ts | CRUD ingredients admin | 12 | +| adminIngredients.test.ts | CRUD + approve/reject/merge ingredients admin | 28 | +| adminUnits.test.ts | CRUD units admin + user endpoint | 25 | | adminFeatures.test.ts | Features grant/revoke | 10 | | adminCommunities.test.ts | Communities admin | 8 | | adminDashboard.test.ts | Stats dashboard | 4 | diff --git a/backend/src/__tests__/integration/adminIngredients.test.ts b/backend/src/__tests__/integration/adminIngredients.test.ts index f7bf62e9..6982974d 100644 --- a/backend/src/__tests__/integration/adminIngredients.test.ts +++ b/backend/src/__tests__/integration/adminIngredients.test.ts @@ -6,6 +6,7 @@ import { createTestIngredient, createTestUser, createTestRecipe, + createTestUnit, loginAsAdmin, } from '../setup/testHelpers'; import { testPrisma } from '../setup/globalSetup'; @@ -22,7 +23,7 @@ describe('Admin Ingredients API', () => { // GET /api/admin/ingredients // ===================================== describe('GET /api/admin/ingredients', () => { - it('should return all ingredients with recipe counts', async () => { + it('should return all ingredients with recipe counts and enriched fields', async () => { await createTestIngredient('sugar'); await createTestIngredient('flour'); await createTestIngredient('butter'); @@ -36,12 +37,13 @@ describe('Admin Ingredients API', () => { expect(Array.isArray(res.body.ingredients)).toBe(true); expect(res.body.ingredients.length).toBeGreaterThanOrEqual(3); - // Verifier la structure const ingredient = res.body.ingredients.find((i: { name: string }) => i.name === 'sugar'); expect(ingredient).toBeDefined(); expect(ingredient.id).toBeDefined(); expect(ingredient.name).toBe('sugar'); + expect(ingredient.status).toBe('APPROVED'); expect(typeof ingredient.recipeCount).toBe('number'); + expect(typeof ingredient.proposalCount).toBe('number'); }); it('should filter ingredients by search query', async () => { @@ -59,10 +61,43 @@ describe('Admin Ingredients API', () => { )).toBe(true); }); - it('should return 401 without admin authentication', async () => { + it('should filter ingredients by status', async () => { + await createTestIngredient('approved_ing', { status: 'APPROVED' }); + await createTestIngredient('pending_ing', { status: 'PENDING' }); + const res = await request(app) - .get('/api/admin/ingredients'); + .get('/api/admin/ingredients?status=PENDING') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.ingredients.every((i: { status: string }) => i.status === 'PENDING')).toBe(true); + expect(res.body.ingredients.some((i: { name: string }) => i.name === 'pending_ing')).toBe(true); + }); + + it('should include createdBy and defaultUnit in response', async () => { + const user = await createTestUser(); + const unit = await createTestUnit({ name: 'test_gram', abbreviation: 'tg', category: 'WEIGHT' }); + await createTestIngredient('enriched_ing', { + status: 'PENDING', + createdById: user.id, + defaultUnitId: unit.id, + }); + const res = await request(app) + .get('/api/admin/ingredients') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + const ing = res.body.ingredients.find((i: { name: string }) => i.name === 'enriched_ing'); + expect(ing).toBeDefined(); + expect(ing.createdBy).toBeDefined(); + expect(ing.createdBy.username).toBe(user.username); + expect(ing.defaultUnit).toBeDefined(); + expect(ing.defaultUnit.name).toBe('test_gram'); + }); + + it('should return 401 without admin authentication', async () => { + const res = await request(app).get('/api/admin/ingredients'); expect(res.status).toBe(401); }); }); @@ -71,7 +106,7 @@ describe('Admin Ingredients API', () => { // POST /api/admin/ingredients // ===================================== describe('POST /api/admin/ingredients', () => { - it('should create a new ingredient', async () => { + it('should create a new ingredient as APPROVED', async () => { const res = await request(app) .post('/api/admin/ingredients') .set('Cookie', adminCookie) @@ -79,14 +114,20 @@ describe('Admin Ingredients API', () => { expect(res.status).toBe(201); expect(res.body.ingredient).toBeDefined(); - expect(res.body.ingredient.name).toBe('new test ingredient'); // Normalized to lowercase - expect(res.body.ingredient.id).toBeDefined(); + expect(res.body.ingredient.name).toBe('new test ingredient'); + expect(res.body.ingredient.status).toBe('APPROVED'); + }); - // Verifier en DB - const ingredient = await testPrisma.ingredient.findUnique({ - where: { name: 'new test ingredient' }, - }); - expect(ingredient).not.toBeNull(); + it('should create ingredient with defaultUnitId', async () => { + const unit = await createTestUnit({ name: 'create_unit', abbreviation: 'cru', category: 'WEIGHT' }); + + const res = await request(app) + .post('/api/admin/ingredients') + .set('Cookie', adminCookie) + .send({ name: 'With Unit', defaultUnitId: unit.id }); + + expect(res.status).toBe(201); + expect(res.body.ingredient.defaultUnitId).toBe(unit.id); }); it('should return 400 when name is missing', async () => { @@ -105,11 +146,21 @@ describe('Admin Ingredients API', () => { const res = await request(app) .post('/api/admin/ingredients') .set('Cookie', adminCookie) - .send({ name: 'Existing' }); // Different case, same ingredient + .send({ name: 'Existing' }); expect(res.status).toBe(409); expect(res.body.error).toContain('ADMIN_ING_002'); }); + + it('should return 400 for invalid defaultUnitId', async () => { + const res = await request(app) + .post('/api/admin/ingredients') + .set('Cookie', adminCookie) + .send({ name: 'Bad Unit', defaultUnitId: '00000000-0000-0000-0000-000000000000' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('ADMIN_ING_007'); + }); }); // ===================================== @@ -126,12 +177,32 @@ describe('Admin Ingredients API', () => { expect(res.status).toBe(200); expect(res.body.ingredient.name).toBe('newname'); + }); - // Verifier en DB - const updated = await testPrisma.ingredient.findUnique({ - where: { id: ingredient.id }, - }); - expect(updated?.name).toBe('newname'); + it('should update defaultUnitId', async () => { + const ingredient = await createTestIngredient('unit_update'); + const unit = await createTestUnit({ name: 'patch_unit', abbreviation: 'pu', category: 'VOLUME' }); + + const res = await request(app) + .patch(`/api/admin/ingredients/${ingredient.id}`) + .set('Cookie', adminCookie) + .send({ defaultUnitId: unit.id }); + + expect(res.status).toBe(200); + expect(res.body.ingredient.defaultUnitId).toBe(unit.id); + }); + + it('should clear defaultUnitId with null', async () => { + const unit = await createTestUnit({ name: 'clear_unit', abbreviation: 'clu', category: 'WEIGHT' }); + const ingredient = await createTestIngredient('clear_default', { defaultUnitId: unit.id }); + + const res = await request(app) + .patch(`/api/admin/ingredients/${ingredient.id}`) + .set('Cookie', adminCookie) + .send({ defaultUnitId: null }); + + expect(res.status).toBe(200); + expect(res.body.ingredient.defaultUnitId).toBeNull(); }); it('should return 404 for non-existent ingredient', async () => { @@ -172,7 +243,6 @@ describe('Admin Ingredients API', () => { expect(res.status).toBe(200); expect(res.body.message).toContain('deleted'); - // Verifier en DB const deleted = await testPrisma.ingredient.findUnique({ where: { id: ingredient.id }, }); @@ -198,7 +268,6 @@ describe('Admin Ingredients API', () => { const sourceIng = await createTestIngredient('source_ing'); const targetIng = await createTestIngredient('target_ing'); - // Creer une recette avec l'ingredient source await createTestRecipe(user.id, { ingredients: [{ name: 'source_ing', quantity: 100 }], }); @@ -211,13 +280,11 @@ describe('Admin Ingredients API', () => { expect(res.status).toBe(200); expect(res.body.message).toContain('merged'); - // Verifier que le source est supprime const deletedSource = await testPrisma.ingredient.findUnique({ where: { id: sourceIng.id }, }); expect(deletedSource).toBeNull(); - // Verifier que le target a les recettes const targetWithRecipes = await testPrisma.ingredient.findUnique({ where: { id: targetIng.id }, include: { recipes: true }, @@ -225,6 +292,42 @@ describe('Admin Ingredients API', () => { expect(targetWithRecipes?.recipes.length).toBeGreaterThan(0); }); + it('should also merge ProposalIngredient', async () => { + const user = await createTestUser(); + const sourceIng = await createTestIngredient('proposal_source'); + const targetIng = await createTestIngredient('proposal_target'); + + // Creer une recette et une proposal avec le source ingredient + const recipe = await testPrisma.recipe.create({ + data: { title: 'Test', content: 'Test', creatorId: user.id }, + }); + const proposal = await testPrisma.recipeUpdateProposal.create({ + data: { + recipeId: recipe.id, + proposerId: user.id, + proposedTitle: 'Updated', + proposedContent: 'Updated content', + }, + }); + await testPrisma.proposalIngredient.create({ + data: { proposalId: proposal.id, ingredientId: sourceIng.id, quantity: 50 }, + }); + + const res = await request(app) + .post(`/api/admin/ingredients/${sourceIng.id}/merge`) + .set('Cookie', adminCookie) + .send({ targetId: targetIng.id }); + + expect(res.status).toBe(200); + + // Verifier que le ProposalIngredient pointe vers le target + const proposalIngs = await testPrisma.proposalIngredient.findMany({ + where: { proposalId: proposal.id }, + }); + expect(proposalIngs.length).toBe(1); + expect(proposalIngs[0].ingredientId).toBe(targetIng.id); + }); + it('should return 400 when merging ingredient into itself', async () => { const ingredient = await createTestIngredient('selfmerge'); @@ -249,4 +352,139 @@ describe('Admin Ingredients API', () => { expect(res.body.error).toContain('ADMIN_ING_004'); }); }); + + // ===================================== + // POST /api/admin/ingredients/:id/approve + // ===================================== + describe('POST /api/admin/ingredients/:id/approve', () => { + it('should approve a PENDING ingredient and create audit log', async () => { + const ingredient = await createTestIngredient('pending_approve', { status: 'PENDING' }); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.ingredient.status).toBe('APPROVED'); + expect(res.body.ingredient.name).toBe('pending_approve'); + + // Verify audit log + const log = await testPrisma.adminActivityLog.findFirst({ + where: { type: 'INGREDIENT_APPROVED', targetId: ingredient.id }, + }); + expect(log).toBeDefined(); + }); + + it('should approve and rename in one step', async () => { + const ingredient = await createTestIngredient('typo_name', { status: 'PENDING' }); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie) + .send({ newName: 'Correct Name' }); + + expect(res.status).toBe(200); + expect(res.body.ingredient.status).toBe('APPROVED'); + expect(res.body.ingredient.name).toBe('correct name'); + }); + + it('should reject approving an already APPROVED ingredient', async () => { + const ingredient = await createTestIngredient('already_approved', { status: 'APPROVED' }); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('ADMIN_ING_008'); + }); + + it('should reject rename to existing name', async () => { + await createTestIngredient('existing_name'); + const ingredient = await createTestIngredient('to_rename', { status: 'PENDING' }); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie) + .send({ newName: 'existing_name' }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain('ADMIN_ING_002'); + }); + }); + + // ===================================== + // POST /api/admin/ingredients/:id/reject + // ===================================== + describe('POST /api/admin/ingredients/:id/reject', () => { + it('should reject and delete a PENDING ingredient with audit log', async () => { + const ingredient = await createTestIngredient('pending_reject', { status: 'PENDING' }); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/reject`) + .set('Cookie', adminCookie) + .send({ reason: 'Too vague, please be more specific' }); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('rejected'); + + // Verify hard delete + const deleted = await testPrisma.ingredient.findUnique({ where: { id: ingredient.id } }); + expect(deleted).toBeNull(); + + // Verify audit log with reason + const log = await testPrisma.adminActivityLog.findFirst({ + where: { type: 'INGREDIENT_REJECTED', targetId: ingredient.id }, + }); + expect(log).toBeDefined(); + expect((log!.metadata as Record).reason).toBe('Too vague, please be more specific'); + }); + + it('should cascade delete RecipeIngredient on reject', async () => { + const user = await createTestUser(); + const ingredient = await createTestIngredient('cascade_reject', { status: 'PENDING' }); + + // Creer une recette avec cet ingredient + const recipe = await testPrisma.recipe.create({ + data: { title: 'Test', content: 'Test', creatorId: user.id }, + }); + await testPrisma.recipeIngredient.create({ + data: { recipeId: recipe.id, ingredientId: ingredient.id, quantity: 50 }, + }); + + await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/reject`) + .set('Cookie', adminCookie) + .send({ reason: 'Duplicate' }); + + const recipeIngs = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: recipe.id }, + }); + expect(recipeIngs).toHaveLength(0); + }); + + it('should reject rejecting an APPROVED ingredient and require reason', async () => { + // Cannot reject an APPROVED ingredient + const approved = await createTestIngredient('approved_reject', { status: 'APPROVED' }); + + const res1 = await request(app) + .post(`/api/admin/ingredients/${approved.id}/reject`) + .set('Cookie', adminCookie) + .send({ reason: 'Mistake' }); + + expect(res1.status).toBe(400); + expect(res1.body.error).toContain('ADMIN_ING_008'); + + // Reason is required for PENDING + const pending = await createTestIngredient('no_reason', { status: 'PENDING' }); + + const res2 = await request(app) + .post(`/api/admin/ingredients/${pending.id}/reject`) + .set('Cookie', adminCookie) + .send({}); + + expect(res2.status).toBe(400); + expect(res2.body.error).toContain('ADMIN_ING_009'); + }); + }); }); diff --git a/backend/src/admin/controllers/ingredientsController.ts b/backend/src/admin/controllers/ingredientsController.ts index f11c1e48..993062b4 100644 --- a/backend/src/admin/controllers/ingredientsController.ts +++ b/backend/src/admin/controllers/ingredientsController.ts @@ -6,17 +6,28 @@ import { assertIsDefine } from "../../util/assertIsDefine"; /** * GET /api/admin/ingredients * Liste tous les ingredients avec count de recettes + * Filtre optionnel par ?search= et ?status=APPROVED|PENDING */ export const getAll: RequestHandler = async (req, res, next) => { try { - const { search } = req.query; + const { search, status } = req.query; + + const where: Record = {}; + + if (search) { + where.name = { contains: String(search), mode: "insensitive" }; + } + + if (status === "APPROVED" || status === "PENDING") { + where.status = status; + } const ingredients = await prisma.ingredient.findMany({ - where: search - ? { name: { contains: String(search), mode: "insensitive" } } - : undefined, + where, include: { - _count: { select: { recipes: true } }, + _count: { select: { recipes: true, proposals: true } }, + createdBy: { select: { id: true, username: true } }, + defaultUnit: { select: { id: true, name: true, abbreviation: true } }, }, orderBy: { name: "asc" }, }); @@ -25,7 +36,12 @@ export const getAll: RequestHandler = async (req, res, next) => { ingredients: ingredients.map((i) => ({ id: i.id, name: i.name, + status: i.status, + createdBy: i.createdBy, + defaultUnit: i.defaultUnit, recipeCount: i._count.recipes, + proposalCount: i._count.proposals, + createdAt: i.createdAt, })), }); } catch (error) { @@ -35,11 +51,11 @@ export const getAll: RequestHandler = async (req, res, next) => { /** * POST /api/admin/ingredients - * Cree un nouvel ingredient + * Cree un nouvel ingredient (APPROVED par defaut car admin) */ export const create: RequestHandler = async (req, res, next) => { try { - const { name } = req.body; + const { name, defaultUnitId } = req.body; const adminId = req.session.adminId; assertIsDefine(adminId); @@ -57,8 +73,20 @@ export const create: RequestHandler = async (req, res, next) => { throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); } + // Valider defaultUnitId si fourni + if (defaultUnitId) { + const unit = await prisma.unit.findUnique({ where: { id: defaultUnitId } }); + if (!unit) { + throw createHttpError(400, "ADMIN_ING_007: Default unit not found"); + } + } + const ingredient = await prisma.ingredient.create({ - data: { name: normalized }, + data: { + name: normalized, + status: "APPROVED", + defaultUnitId: defaultUnitId || null, + }, }); await prisma.adminActivityLog.create({ @@ -79,39 +107,62 @@ export const create: RequestHandler = async (req, res, next) => { /** * PATCH /api/admin/ingredients/:id - * Renomme un ingredient + * Modifie un ingredient (nom, defaultUnitId) */ export const update: RequestHandler = async (req, res, next) => { try { const { id } = req.params; - const { name } = req.body; + const { name, defaultUnitId } = req.body; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!name || typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_ING_001: Name is required"); - } - const ingredient = await prisma.ingredient.findUnique({ where: { id } }); if (!ingredient) { throw createHttpError(404, "ADMIN_ING_003: Ingredient not found"); } - const normalized = name.trim().toLowerCase(); + const data: Record = {}; + const metadata: Record = {}; - if (normalized !== ingredient.name) { - const existing = await prisma.ingredient.findUnique({ - where: { name: normalized }, - }); - if (existing) { - throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); + if (name !== undefined) { + if (typeof name !== "string" || name.trim().length === 0) { + throw createHttpError(400, "ADMIN_ING_001: Name is required"); + } + + const normalized = name.trim().toLowerCase(); + + if (normalized !== ingredient.name) { + const existing = await prisma.ingredient.findUnique({ + where: { name: normalized }, + }); + if (existing) { + throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); + } + metadata.oldName = ingredient.name; + metadata.newName = normalized; + data.name = normalized; } } - const oldName = ingredient.name; + if (defaultUnitId !== undefined) { + if (defaultUnitId === null) { + data.defaultUnitId = null; + } else { + const unit = await prisma.unit.findUnique({ where: { id: defaultUnitId } }); + if (!unit) { + throw createHttpError(400, "ADMIN_ING_007: Default unit not found"); + } + data.defaultUnitId = defaultUnitId; + } + } + + if (Object.keys(data).length === 0) { + return res.status(200).json({ ingredient }); + } + const updated = await prisma.ingredient.update({ where: { id }, - data: { name: normalized }, + data, }); await prisma.adminActivityLog.create({ @@ -120,7 +171,7 @@ export const update: RequestHandler = async (req, res, next) => { type: "INGREDIENT_UPDATED", targetType: "Ingredient", targetId: id, - metadata: { oldName, newName: normalized }, + metadata, }, }); @@ -166,6 +217,7 @@ export const remove: RequestHandler = async (req, res, next) => { /** * POST /api/admin/ingredients/:id/merge * Fusionne un ingredient source dans un ingredient cible + * Gere RecipeIngredient ET ProposalIngredient */ export const merge: RequestHandler = async (req, res, next) => { try { @@ -195,26 +247,43 @@ export const merge: RequestHandler = async (req, res, next) => { } await prisma.$transaction(async (tx) => { - // Recuperer les recettes du source + // Transferer RecipeIngredient const sourceRecipes = await tx.recipeIngredient.findMany({ where: { ingredientId: sourceId }, - select: { recipeId: true, quantity: true, order: true }, + select: { recipeId: true, quantity: true, unitId: true, order: true }, }); - // Pour chaque recette, ajouter le target si pas deja present - for (const { recipeId, quantity, order } of sourceRecipes) { + for (const { recipeId, quantity, unitId, order } of sourceRecipes) { const existing = await tx.recipeIngredient.findUnique({ where: { recipeId_ingredientId: { recipeId, ingredientId: targetId } }, }); if (!existing) { await tx.recipeIngredient.create({ - data: { recipeId, ingredientId: targetId, quantity, order }, + data: { recipeId, ingredientId: targetId, quantity, unitId, order }, }); } } - // Supprimer le source (cascade supprime les RecipeIngredient) + // Transferer ProposalIngredient + const sourceProposals = await tx.proposalIngredient.findMany({ + where: { ingredientId: sourceId }, + select: { proposalId: true, quantity: true, unitId: true, order: true }, + }); + + for (const { proposalId, quantity, unitId, order } of sourceProposals) { + const existing = await tx.proposalIngredient.findUnique({ + where: { proposalId_ingredientId: { proposalId, ingredientId: targetId } }, + }); + + if (!existing) { + await tx.proposalIngredient.create({ + data: { proposalId, ingredientId: targetId, quantity, unitId, order }, + }); + } + } + + // Supprimer le source (cascade supprime RecipeIngredient + ProposalIngredient) await tx.ingredient.delete({ where: { id: sourceId } }); }); @@ -239,3 +308,107 @@ export const merge: RequestHandler = async (req, res, next) => { next(error); } }; + +/** + * POST /api/admin/ingredients/:id/approve + * Approuve un ingredient PENDING (optionnel: renommer) + */ +export const approve: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const { newName } = req.body; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const ingredient = await prisma.ingredient.findUnique({ where: { id } }); + if (!ingredient) { + throw createHttpError(404, "ADMIN_ING_003: Ingredient not found"); + } + + if (ingredient.status !== "PENDING") { + throw createHttpError(400, "ADMIN_ING_008: Ingredient is not pending"); + } + + const data: Record = { status: "APPROVED" }; + const metadata: Record = { name: ingredient.name }; + + if (newName && typeof newName === "string" && newName.trim().length > 0) { + const normalized = newName.trim().toLowerCase(); + if (normalized !== ingredient.name) { + const existing = await prisma.ingredient.findUnique({ where: { name: normalized } }); + if (existing) { + throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); + } + data.name = normalized; + metadata.oldName = ingredient.name; + metadata.newName = normalized; + } + } + + const updated = await prisma.ingredient.update({ + where: { id }, + data, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "INGREDIENT_APPROVED", + targetType: "Ingredient", + targetId: id, + metadata, + }, + }); + + res.status(200).json({ ingredient: updated }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/admin/ingredients/:id/reject + * Rejette un ingredient PENDING (hard delete, raison obligatoire) + */ +export const reject: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const { reason } = req.body; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + if (!reason || typeof reason !== "string" || reason.trim().length === 0) { + throw createHttpError(400, "ADMIN_ING_009: Reason is required"); + } + + const ingredient = await prisma.ingredient.findUnique({ where: { id } }); + if (!ingredient) { + throw createHttpError(404, "ADMIN_ING_003: Ingredient not found"); + } + + if (ingredient.status !== "PENDING") { + throw createHttpError(400, "ADMIN_ING_008: Ingredient is not pending"); + } + + // Hard delete (cascade supprime RecipeIngredient + ProposalIngredient) + await prisma.ingredient.delete({ where: { id } }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "INGREDIENT_REJECTED", + targetType: "Ingredient", + targetId: id, + metadata: { + name: ingredient.name, + reason: reason.trim(), + createdById: ingredient.createdById, + }, + }, + }); + + res.status(200).json({ message: "Ingredient rejected and deleted" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/admin/routes/ingredientsRoutes.ts b/backend/src/admin/routes/ingredientsRoutes.ts index 383211c2..84320c5b 100644 --- a/backend/src/admin/routes/ingredientsRoutes.ts +++ b/backend/src/admin/routes/ingredientsRoutes.ts @@ -9,7 +9,7 @@ router.get("/", ingredientsController.getAll); // POST /api/admin/ingredients - Cree un ingredient router.post("/", ingredientsController.create); -// PATCH /api/admin/ingredients/:id - Renomme un ingredient +// PATCH /api/admin/ingredients/:id - Modifie un ingredient router.patch("/:id", ingredientsController.update); // DELETE /api/admin/ingredients/:id - Supprime un ingredient @@ -18,4 +18,10 @@ router.delete("/:id", ingredientsController.remove); // POST /api/admin/ingredients/:id/merge - Fusionne un ingredient dans un autre router.post("/:id/merge", ingredientsController.merge); +// POST /api/admin/ingredients/:id/approve - Approuve un ingredient PENDING +router.post("/:id/approve", ingredientsController.approve); + +// POST /api/admin/ingredients/:id/reject - Rejette un ingredient PENDING +router.post("/:id/reject", ingredientsController.reject); + export default router; diff --git a/backend/src/controllers/communityRecipes.ts b/backend/src/controllers/communityRecipes.ts index 1e11675f..e5595413 100644 --- a/backend/src/controllers/communityRecipes.ts +++ b/backend/src/controllers/communityRecipes.ts @@ -14,6 +14,7 @@ import { getModeratorIdsForTagNotification } from "../services/notificationServi interface IngredientInput { name: string; quantity?: number; + unitId?: string; } interface CreateCommunityRecipeBody { diff --git a/backend/src/controllers/ingredients.ts b/backend/src/controllers/ingredients.ts index c6f299eb..d3be1082 100644 --- a/backend/src/controllers/ingredients.ts +++ b/backend/src/controllers/ingredients.ts @@ -16,6 +16,7 @@ export const searchIngredients: RequestHandler ({ id: ingredient.id, name: ingredient.name, + status: ingredient.status, recipeCount: ingredient._count.recipes, })); @@ -59,3 +62,60 @@ export const searchIngredients: RequestHandler = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + const { id } = req.params; + + try { + assertIsDefine(authenticatedUserId); + + const ingredient = await prisma.ingredient.findUnique({ + where: { id }, + select: { id: true, defaultUnitId: true }, + }); + + if (!ingredient) { + return res.status(404).json({ error: "Ingredient not found" }); + } + + // 1. Si defaultUnitId existe (defini par admin) → utiliser + if (ingredient.defaultUnitId) { + return res.status(200).json({ + suggestedUnitId: ingredient.defaultUnitId, + source: "default", + }); + } + + // 2. Sinon, calculer l'unite la plus utilisee dans les recettes + const unitCounts = await prisma.recipeIngredient.groupBy({ + by: ["unitId"], + where: { + ingredientId: id, + unitId: { not: null }, + }, + _count: { unitId: true }, + orderBy: { _count: { unitId: "desc" } }, + take: 1, + }); + + if (unitCounts.length > 0 && unitCounts[0].unitId) { + return res.status(200).json({ + suggestedUnitId: unitCounts[0].unitId, + source: "popular", + }); + } + + // 3. Aucune suggestion + res.status(200).json({ + suggestedUnitId: null, + source: null, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/recipes.ts b/backend/src/controllers/recipes.ts index a2f0c55b..45c06c0f 100644 --- a/backend/src/controllers/recipes.ts +++ b/backend/src/controllers/recipes.ts @@ -207,6 +207,7 @@ export const getRecipe: RequestHandler = async (req, res, next) => { interface IngredientInput { name: string; quantity?: number; + unitId?: string; } interface CreateRecipeBody { diff --git a/backend/src/routes/ingredients.ts b/backend/src/routes/ingredients.ts index d67dd8d9..1dce03e8 100644 --- a/backend/src/routes/ingredients.ts +++ b/backend/src/routes/ingredients.ts @@ -4,5 +4,6 @@ import * as IngredientsController from "../controllers/ingredients"; const router = express.Router(); router.get("/", IngredientsController.searchIngredients); +router.get("/:id/suggested-unit", IngredientsController.getSuggestedUnit); export default router; diff --git a/backend/src/services/communityRecipeService.ts b/backend/src/services/communityRecipeService.ts index d5e3617f..c48b7585 100644 --- a/backend/src/services/communityRecipeService.ts +++ b/backend/src/services/communityRecipeService.ts @@ -68,8 +68,8 @@ export async function createCommunityRecipe( } if (data.ingredients.length > 0) { - await upsertIngredients(tx, personalRecipe.id, data.ingredients); - await upsertIngredients(tx, communityRecipe.id, data.ingredients); + await upsertIngredients(tx, personalRecipe.id, data.ingredients, userId); + await upsertIngredients(tx, communityRecipe.id, data.ingredients, userId); } // 4. Creer ActivityLog diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index 2269ad50..0f897fd8 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -11,6 +11,7 @@ type TransactionClient = Omit< export interface IngredientInput { name: string; quantity?: number; + unitId?: string; } // --- Helpers partages pour tags/ingredients --- @@ -36,24 +37,36 @@ export async function upsertTags( export async function upsertIngredients( tx: TransactionClient, recipeId: string, - ingredients: IngredientInput[] + ingredients: IngredientInput[], + userId?: string ) { for (let i = 0; i < ingredients.length; i++) { const ing = ingredients[i]; const ingredientName = ing.name.trim().toLowerCase(); if (!ingredientName) continue; - const ingredient = await tx.ingredient.upsert({ + // Chercher l'ingredient existant d'abord + let ingredient = await tx.ingredient.findUnique({ where: { name: ingredientName }, - create: { name: ingredientName }, - update: {}, }); + if (!ingredient) { + // Nouvel ingredient : PENDING si cree par un user, APPROVED si pas de userId (admin/seed) + ingredient = await tx.ingredient.create({ + data: { + name: ingredientName, + status: userId ? "PENDING" : "APPROVED", + createdById: userId ?? null, + }, + }); + } + await tx.recipeIngredient.create({ data: { recipeId, ingredientId: ingredient.id, quantity: ing.quantity ?? null, + unitId: ing.unitId ?? null, order: i, }, }); @@ -100,7 +113,7 @@ export async function createRecipe(userId: string, data: CreateRecipeData) { } if (data.ingredients.length > 0) { - await upsertIngredients(tx, recipe.id, data.ingredients); + await upsertIngredients(tx, recipe.id, data.ingredients, userId); } return tx.recipe.findUnique({ @@ -153,11 +166,11 @@ export async function updateRecipe( // Remplacer ingredients si fournis if (data.ingredients !== undefined) { await tx.recipeIngredient.deleteMany({ where: { recipeId } }); - await upsertIngredients(tx, recipeId, data.ingredients); + await upsertIngredients(tx, recipeId, data.ingredients, userId); } // Synchronisation bidirectionnelle - await syncLinkedRecipes(tx, recipeId, data, recipe); + await syncLinkedRecipes(tx, recipeId, data, recipe, userId); return tx.recipe.findUnique({ where: { id: recipeId }, @@ -177,7 +190,8 @@ async function syncLinkedRecipes( tx: TransactionClient, recipeId: string, data: UpdateRecipeData, - recipe: RecipeForSync + recipe: RecipeForSync, + userId: string ) { const syncData: Record = {}; if (data.title !== undefined) syncData.title = data.title.trim(); @@ -234,7 +248,7 @@ async function syncLinkedRecipes( if (data.ingredients !== undefined) { for (const linkedId of linkedRecipeIds) { await tx.recipeIngredient.deleteMany({ where: { recipeId: linkedId } }); - await upsertIngredients(tx, linkedId, data.ingredients); + await upsertIngredients(tx, linkedId, data.ingredients, userId); } } } diff --git a/docs/features/ingredients-rework/ROADMAP.md b/docs/features/ingredients-rework/ROADMAP.md index 62db6549..7bd91c3a 100644 --- a/docs/features/ingredients-rework/ROADMAP.md +++ b/docs/features/ingredients-rework/ROADMAP.md @@ -30,17 +30,17 @@ ## 11.3 - Backend Ingredients (gouvernance + moderation) -- [ ] Refactoring recipeService : upsertIngredients → gestion quantity Float + unitId + creation PENDING -- [ ] Endpoint GET /api/ingredients : enrichir avec status, filtrer APPROVED + PENDING -- [ ] Endpoint GET /api/ingredients/:id/suggested-unit (pre-selection intelligente) -- [ ] Adaptation endpoints admin existants : filtre par status, enrichir reponses -- [ ] Endpoint admin : POST /api/admin/ingredients/:id/approve (avec rename optionnel) -- [ ] Endpoint admin : POST /api/admin/ingredients/:id/reject (avec raison obligatoire) -- [ ] Enrichir merge admin : gerer ProposalIngredient en plus de RecipeIngredient -- [ ] Enrichir PATCH admin : gestion du defaultUnitId -- [ ] Audit log : INGREDIENT_APPROVED, INGREDIENT_REJECTED -- [ ] Protection suppression unite : verifier usage dans RecipeIngredient + ProposalIngredient -- [ ] Tests unitaires + integration +- [x] Refactoring recipeService : upsertIngredients → gestion quantity Float + unitId + creation PENDING +- [x] Endpoint GET /api/ingredients : enrichir avec status, filtrer APPROVED + PENDING +- [x] Endpoint GET /api/ingredients/:id/suggested-unit (pre-selection intelligente) +- [x] Adaptation endpoints admin existants : filtre par status, enrichir reponses +- [x] Endpoint admin : POST /api/admin/ingredients/:id/approve (avec rename optionnel) +- [x] Endpoint admin : POST /api/admin/ingredients/:id/reject (avec raison obligatoire) +- [x] Enrichir merge admin : gerer ProposalIngredient en plus de RecipeIngredient +- [x] Enrichir PATCH admin : gestion du defaultUnitId +- [x] Audit log : INGREDIENT_APPROVED, INGREDIENT_REJECTED +- [x] Protection suppression unite : verifier usage dans RecipeIngredient + ProposalIngredient +- [x] Tests unitaires + integration ## 11.4 - Backend Proposals + Ingredients From 1ce253e470e259c08b9318a7a13d93a29407e022 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Wed, 18 Feb 2026 14:39:42 +0100 Subject: [PATCH 016/221] feat: proposals with ProposalIngredient + ingredient WebSocket notifications (Phase 11.4-11.5) Phase 11.4 - Backend Proposals + Ingredients: - upsertProposalIngredients: stores proposed ingredients (creates PENDING if new) - createProposal: accepts proposedIngredients, stores in ProposalIngredient table - acceptProposal: replaces RecipeIngredients from ProposalIngredients on all linked recipes - rejectProposal: copies ProposalIngredients to variant RecipeIngredients - PROPOSAL_INGREDIENTS_SELECT + PROPOSAL_RESPONSE_SELECT constants Phase 11.5 - Backend Notifications: - INGREDIENT_APPROVED/INGREDIENT_MODIFIED/INGREDIENT_REJECTED/INGREDIENT_MERGED WebSocket events - Emitted only when ingredient has a createdById (created by a user) - Fix: disable adminRateLimiter in test mode (was causing 429 after 30 req/min in tests) Tests: 524 -> 544 (+20) --- .claude/context/PROGRESS.md | 4 +- .../integration/adminIngredients.test.ts | 171 ++++++++- .../__tests__/integration/proposals.test.ts | 337 ++++++++++++++++++ .../controllers/ingredientsController.ts | 43 +++ backend/src/controllers/proposals.ts | 89 +++-- backend/src/middleware/security.ts | 24 +- backend/src/services/proposalService.ts | 85 ++++- backend/src/services/recipeService.ts | 37 ++ backend/src/util/prismaSelects.ts | 20 ++ docs/features/ingredients-rework/ROADMAP.md | 20 +- 10 files changed, 757 insertions(+), 73 deletions(-) diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index fcb60efa..2414417c 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -15,9 +15,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` - **Roadmap** : `docs/features/ingredients-rework/ROADMAP.md` -- **Sous-etape en cours** : 11.3 termine - passer a 11.4 +- **Sous-etape en cours** : 11.5 termine - passer a 11.6 - **Branche** : `IngredientsRework` -- **Tests** : 524 backend + 326 frontend = 850 total +- **Tests** : 544 backend + 326 frontend = 870 total ## Resume de reprise diff --git a/backend/src/__tests__/integration/adminIngredients.test.ts b/backend/src/__tests__/integration/adminIngredients.test.ts index 6982974d..cb153cb4 100644 --- a/backend/src/__tests__/integration/adminIngredients.test.ts +++ b/backend/src/__tests__/integration/adminIngredients.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import request from 'supertest'; import app from '../../app'; import { @@ -10,6 +10,7 @@ import { loginAsAdmin, } from '../setup/testHelpers'; import { testPrisma } from '../setup/globalSetup'; +import appEvents from '../../services/eventEmitter'; describe('Admin Ingredients API', () => { let adminCookie: string; @@ -487,4 +488,172 @@ describe('Admin Ingredients API', () => { expect(res2.body.error).toContain('ADMIN_ING_009'); }); }); + + // ===================================== + // Phase 11.5 - Notifications WebSocket + // ===================================== + describe('WebSocket Notifications (Phase 11.5)', () => { + it('should emit INGREDIENT_APPROVED event when approving a PENDING ingredient with a creator', async () => { + const creator = await createTestUser(); + const ingredient = await createTestIngredient('notif_approve', { + status: 'PENDING', + createdById: creator.id, + }); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie); + + appEvents.off('activity', listener); + + expect(res.status).toBe(200); + expect(emittedEvents).toHaveLength(1); + const event = emittedEvents[0] as Record; + expect(event.type).toBe('INGREDIENT_APPROVED'); + expect(event.communityId).toBeNull(); + expect(event.targetUserIds).toContain(creator.id); + expect((event.metadata as Record).ingredientName).toBe('notif_approve'); + }); + + it('should emit INGREDIENT_MODIFIED event when approving with rename', async () => { + const creator = await createTestUser(); + const ingredient = await createTestIngredient('notif_typo', { + status: 'PENDING', + createdById: creator.id, + }); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie) + .send({ newName: 'Correct Name Notif' }); + + appEvents.off('activity', listener); + + expect(res.status).toBe(200); + expect(emittedEvents).toHaveLength(1); + const event = emittedEvents[0] as Record; + expect(event.type).toBe('INGREDIENT_MODIFIED'); + expect(event.targetUserIds).toContain(creator.id); + const meta = event.metadata as Record; + expect(meta.ingredientName).toBe('notif_typo'); + expect(meta.newName).toBe('correct name notif'); + }); + + it('should NOT emit any event when approving an ingredient without a creator', async () => { + const ingredient = await createTestIngredient('notif_no_creator', { status: 'PENDING' }); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/approve`) + .set('Cookie', adminCookie); + + appEvents.off('activity', listener); + + expect(emittedEvents).toHaveLength(0); + }); + + it('should emit INGREDIENT_REJECTED event when rejecting a PENDING ingredient with a creator', async () => { + const creator = await createTestUser(); + const ingredient = await createTestIngredient('notif_reject', { + status: 'PENDING', + createdById: creator.id, + }); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + const res = await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/reject`) + .set('Cookie', adminCookie) + .send({ reason: 'Ingredient trop vague' }); + + appEvents.off('activity', listener); + + expect(res.status).toBe(200); + expect(emittedEvents).toHaveLength(1); + const event = emittedEvents[0] as Record; + expect(event.type).toBe('INGREDIENT_REJECTED'); + expect(event.communityId).toBeNull(); + expect(event.targetUserIds).toContain(creator.id); + const meta = event.metadata as Record; + expect(meta.ingredientName).toBe('notif_reject'); + expect(meta.reason).toBe('Ingredient trop vague'); + }); + + it('should NOT emit any event when rejecting an ingredient without a creator', async () => { + const ingredient = await createTestIngredient('notif_reject_no_creator', { status: 'PENDING' }); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + await request(app) + .post(`/api/admin/ingredients/${ingredient.id}/reject`) + .set('Cookie', adminCookie) + .send({ reason: 'No creator' }); + + appEvents.off('activity', listener); + + expect(emittedEvents).toHaveLength(0); + }); + + it('should emit INGREDIENT_MERGED event when merging a source with a creator', async () => { + const creator = await createTestUser(); + const source = await createTestIngredient('notif_merge_source', { + status: 'PENDING', + createdById: creator.id, + }); + const target = await createTestIngredient('notif_merge_target'); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + await request(app) + .post(`/api/admin/ingredients/${source.id}/merge`) + .set('Cookie', adminCookie) + .send({ targetId: target.id }); + + appEvents.off('activity', listener); + + expect(emittedEvents).toHaveLength(1); + const event = emittedEvents[0] as Record; + expect(event.type).toBe('INGREDIENT_MERGED'); + expect(event.communityId).toBeNull(); + expect(event.targetUserIds).toContain(creator.id); + const meta = event.metadata as Record; + expect(meta.ingredientName).toBe('notif_merge_source'); + expect(meta.targetName).toBe('notif_merge_target'); + }); + + it('should NOT emit any event when merging a source without a creator', async () => { + const source = await createTestIngredient('notif_merge_no_creator'); + const target = await createTestIngredient('notif_merge_no_creator_target'); + + const emittedEvents: unknown[] = []; + const listener = (event: unknown) => emittedEvents.push(event); + appEvents.on('activity', listener); + + await request(app) + .post(`/api/admin/ingredients/${source.id}/merge`) + .set('Cookie', adminCookie) + .send({ targetId: target.id }); + + appEvents.off('activity', listener); + + expect(emittedEvents).toHaveLength(0); + }); + }); }); diff --git a/backend/src/__tests__/integration/proposals.test.ts b/backend/src/__tests__/integration/proposals.test.ts index 78567951..cb1a1984 100644 --- a/backend/src/__tests__/integration/proposals.test.ts +++ b/backend/src/__tests__/integration/proposals.test.ts @@ -571,4 +571,341 @@ describe("Proposals API", () => { expect(res.status).toBe(404); }); }); + + // ===================================== + // Phase 11.4 - Proposals + Ingredients + // ===================================== + describe("Proposals with proposedIngredients (Phase 11.4)", () => { + // ---- Creation avec ingredients ---- + describe("POST /api/recipes/:recipeId/proposals with proposedIngredients", () => { + it("should store proposedIngredients in the proposal", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Recipe with ingredients", + proposedContent: "Content with ingredients", + proposedIngredients: [ + { name: "Farine", quantity: 200 }, + { name: "Sucre", quantity: 100 }, + ], + }); + + expect(res.status).toBe(201); + expect(res.body.proposedIngredients).toBeDefined(); + expect(res.body.proposedIngredients).toHaveLength(2); + expect(res.body.proposedIngredients[0].ingredient.name).toBe("farine"); + expect(res.body.proposedIngredients[0].quantity).toBe(200); + expect(res.body.proposedIngredients[1].ingredient.name).toBe("sucre"); + }); + + it("should create PENDING ingredient when new name is submitted", async () => { + const suffix = uniqueSuffix(); + const newIngredientName = `ingredient_nouveau_${suffix}`; + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Recipe with new ingredient", + proposedContent: "Content", + proposedIngredients: [{ name: newIngredientName, quantity: 1 }], + }); + + expect(res.status).toBe(201); + + const ingredient = await testPrisma.ingredient.findFirst({ + where: { name: newIngredientName.toLowerCase() }, + }); + expect(ingredient).not.toBeNull(); + expect(ingredient?.status).toBe("PENDING"); + expect(ingredient?.createdById).toBe(proposer.id); + }); + + it("should reuse existing ingredient without creating duplicate", async () => { + const existingIngredient = await testPrisma.ingredient.create({ + data: { name: "ingredient_existant_reuse", status: "APPROVED" }, + }); + + const countBefore = await testPrisma.ingredient.count({ + where: { name: "ingredient_existant_reuse" }, + }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Recipe reusing ingredient", + proposedContent: "Content", + proposedIngredients: [ + { name: existingIngredient.name, quantity: 3 }, + ], + }); + + expect(res.status).toBe(201); + + const countAfter = await testPrisma.ingredient.count({ + where: { name: "ingredient_existant_reuse" }, + }); + expect(countAfter).toBe(countBefore); + }); + + it("should return 400 when proposedIngredients exceeds 50", async () => { + const tooManyIngredients = Array.from({ length: 51 }, (_, i) => ({ + name: `ingredient_limit_${i}`, + })); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Too many ingredients", + proposedContent: "Content", + proposedIngredients: tooManyIngredients, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("INGREDIENT_003"); + }); + + it("should create proposal without ingredients when not provided", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "No ingredients", + proposedContent: "Content", + }); + + expect(res.status).toBe(201); + expect(res.body.proposedIngredients).toHaveLength(0); + }); + + it("should store unitId in ProposalIngredient when provided", async () => { + const suffix = uniqueSuffix(); + const unit = await testPrisma.unit.create({ + data: { + name: `gramme_test_${suffix}`, + abbreviation: `g_${suffix}`, + category: "WEIGHT", + sortOrder: 1, + }, + }); + + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Recipe with unit", + proposedContent: "Content", + proposedIngredients: [ + { name: "Chocolat", quantity: 150, unitId: unit.id }, + ], + }); + + expect(res.status).toBe(201); + expect(res.body.proposedIngredients[0].unitId).toBe(unit.id); + }); + }); + + // ---- Acceptation avec remplacement ingredients ---- + describe("POST /api/proposals/:proposalId/accept with proposedIngredients", () => { + let proposalWithIngredientsId: string; + + beforeEach(async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Recipe with new ingredients", + proposedContent: "New content with ingredients", + proposedIngredients: [ + { name: "Oeuf", quantity: 3 }, + { name: "Beurre", quantity: 50 }, + ], + }); + proposalWithIngredientsId = res.body.id; + }); + + it("should replace RecipeIngredients when accepting a proposal with ingredients", async () => { + const res = await request(app) + .post(`/api/proposals/${proposalWithIngredientsId}/accept`) + .set("Cookie", recipeCreatorCookie); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("ACCEPTED"); + + const recipeIngredients = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: communityRecipeId }, + include: { ingredient: true }, + orderBy: { order: "asc" }, + }); + + expect(recipeIngredients).toHaveLength(2); + expect(recipeIngredients[0].ingredient.name).toBe("oeuf"); + expect(recipeIngredients[0].quantity).toBe(3); + expect(recipeIngredients[1].ingredient.name).toBe("beurre"); + expect(recipeIngredients[1].quantity).toBe(50); + }); + + it("should propagate ingredient replacement to the personal recipe", async () => { + await request(app) + .post(`/api/proposals/${proposalWithIngredientsId}/accept`) + .set("Cookie", recipeCreatorCookie); + + const personalIngredients = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: personalRecipeId }, + include: { ingredient: true }, + orderBy: { order: "asc" }, + }); + + expect(personalIngredients).toHaveLength(2); + expect(personalIngredients[0].ingredient.name).toBe("oeuf"); + expect(personalIngredients[1].ingredient.name).toBe("beurre"); + }); + + it("should not touch RecipeIngredients when proposal has no proposedIngredients", async () => { + // Ajouter un ingredient a la recette communautaire d'abord + const ingredient = await testPrisma.ingredient.create({ + data: { name: "ingredient_preserved", status: "APPROVED" }, + }); + await testPrisma.recipeIngredient.create({ + data: { + recipeId: communityRecipeId, + ingredientId: ingredient.id, + quantity: 5, + order: 0, + }, + }); + + // Creer un proposal sans ingredients + const proposalRes = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Only title change", + proposedContent: "Only content change", + }); + + // Modifier la recette pour qu'elle soit plus recente que le proposal... + // On doit contourner la contrainte updatedAt > createdAt en creant directement + await testPrisma.recipeUpdateProposal.update({ + where: { id: proposalRes.body.id }, + data: { createdAt: new Date(Date.now() + 1000) }, + }); + + const acceptRes = await request(app) + .post(`/api/proposals/${proposalRes.body.id}/accept`) + .set("Cookie", recipeCreatorCookie); + + expect(acceptRes.status).toBe(200); + + // Les ingredients doivent etre preserves + const ingredients = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: communityRecipeId }, + }); + expect(ingredients).toHaveLength(1); + expect(ingredients[0].ingredientId).toBe(ingredient.id); + }); + }); + + // ---- Rejet avec copie ingredients dans la variante ---- + describe("POST /api/proposals/:proposalId/reject with proposedIngredients", () => { + it("should copy proposedIngredients to the variant's RecipeIngredients", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Rejected with ingredients", + proposedContent: "Content", + proposedIngredients: [ + { name: "Carotte", quantity: 2 }, + { name: "Poireau", quantity: 1 }, + ], + }); + const proposalId = res.body.id; + + const rejectRes = await request(app) + .post(`/api/proposals/${proposalId}/reject`) + .set("Cookie", recipeCreatorCookie); + + expect(rejectRes.status).toBe(200); + const variantId = rejectRes.body.variant.id; + + const variantIngredients = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: variantId }, + include: { ingredient: true }, + orderBy: { order: "asc" }, + }); + + expect(variantIngredients).toHaveLength(2); + expect(variantIngredients[0].ingredient.name).toBe("carotte"); + expect(variantIngredients[0].quantity).toBe(2); + expect(variantIngredients[1].ingredient.name).toBe("poireau"); + expect(variantIngredients[1].quantity).toBe(1); + }); + + it("should create variant without RecipeIngredients when proposal has no ingredients", async () => { + const proposalRes = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "No ingredients proposal", + proposedContent: "Content", + }); + + const rejectRes = await request(app) + .post(`/api/proposals/${proposalRes.body.id}/reject`) + .set("Cookie", recipeCreatorCookie); + + expect(rejectRes.status).toBe(200); + const variantId = rejectRes.body.variant.id; + + const variantIngredients = await testPrisma.recipeIngredient.findMany({ + where: { recipeId: variantId }, + }); + expect(variantIngredients).toHaveLength(0); + }); + }); + + // ---- proposedIngredients dans les reponses GET ---- + describe("GET proposals responses include proposedIngredients", () => { + let proposalId: string; + + beforeEach(async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "Proposal with ingredients", + proposedContent: "Content", + proposedIngredients: [{ name: "Tomate", quantity: 4 }], + }); + proposalId = res.body.id; + }); + + it("GET /api/proposals/:id should include proposedIngredients", async () => { + const res = await request(app) + .get(`/api/proposals/${proposalId}`) + .set("Cookie", proposerCookie); + + expect(res.status).toBe(200); + expect(res.body.proposedIngredients).toBeDefined(); + expect(res.body.proposedIngredients).toHaveLength(1); + expect(res.body.proposedIngredients[0].ingredient.name).toBe("tomate"); + }); + + it("GET /api/recipes/:recipeId/proposals should include proposedIngredients", async () => { + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie); + + expect(res.status).toBe(200); + const proposal = res.body.data.find((p: { id: string }) => p.id === proposalId); + expect(proposal).toBeDefined(); + expect(proposal.proposedIngredients).toHaveLength(1); + expect(proposal.proposedIngredients[0].ingredient.name).toBe("tomate"); + }); + }); + }); }); diff --git a/backend/src/admin/controllers/ingredientsController.ts b/backend/src/admin/controllers/ingredientsController.ts index 993062b4..0ff3906d 100644 --- a/backend/src/admin/controllers/ingredientsController.ts +++ b/backend/src/admin/controllers/ingredientsController.ts @@ -2,6 +2,7 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../../util/db"; import { assertIsDefine } from "../../util/assertIsDefine"; +import appEvents from "../../services/eventEmitter"; /** * GET /api/admin/ingredients @@ -301,6 +302,20 @@ export const merge: RequestHandler = async (req, res, next) => { }, }); + // Notification WebSocket au createur de l'ingredient source + if (source.createdById) { + appEvents.emitActivity({ + type: "INGREDIENT_MERGED", + userId: adminId, + communityId: null, + targetUserIds: [source.createdById], + metadata: { + ingredientName: source.name, + targetName: target.name, + }, + }); + } + res.status(200).json({ message: `Ingredient "${source.name}" merged into "${target.name}"`, }); @@ -360,6 +375,20 @@ export const approve: RequestHandler = async (req, res, next) => { }, }); + // Notification WebSocket au createur (si un user a cree cet ingredient) + if (ingredient.createdById) { + const isRenamed = metadata.newName !== undefined; + appEvents.emitActivity({ + type: isRenamed ? "INGREDIENT_MODIFIED" : "INGREDIENT_APPROVED", + userId: adminId, + communityId: null, + targetUserIds: [ingredient.createdById], + metadata: isRenamed + ? { ingredientName: ingredient.name, newName: metadata.newName } + : { ingredientName: ingredient.name }, + }); + } + res.status(200).json({ ingredient: updated }); } catch (error) { next(error); @@ -407,6 +436,20 @@ export const reject: RequestHandler = async (req, res, next) => { }, }); + // Notification WebSocket au createur (si un user a cree cet ingredient) + if (ingredient.createdById) { + appEvents.emitActivity({ + type: "INGREDIENT_REJECTED", + userId: adminId, + communityId: null, + targetUserIds: [ingredient.createdById], + metadata: { + ingredientName: ingredient.name, + reason: reason.trim(), + }, + }); + } + res.status(200).json({ message: "Ingredient rejected and deleted" }); } catch (error) { next(error); diff --git a/backend/src/controllers/proposals.ts b/backend/src/controllers/proposals.ts index 2ce0b4c6..93ca20c0 100644 --- a/backend/src/controllers/proposals.ts +++ b/backend/src/controllers/proposals.ts @@ -7,12 +7,33 @@ import { parsePagination, buildPaginationMeta } from "../util/pagination"; import { requireMembership } from "../services/membershipService"; import { acceptProposal as acceptProposalService, rejectProposal as rejectProposalService } from "../services/proposalService"; import appEvents from "../services/eventEmitter"; +import { IngredientInput, upsertProposalIngredients } from "../services/recipeService"; +import { PROPOSAL_INGREDIENTS_SELECT } from "../util/prismaSelects"; interface CreateProposalBody { proposedTitle?: string; proposedContent?: string; + proposedIngredients?: IngredientInput[]; } +const PROPOSAL_RESPONSE_SELECT = { + id: true, + proposedTitle: true, + proposedContent: true, + status: true, + createdAt: true, + decidedAt: true, + recipeId: true, + proposerId: true, + proposer: { + select: { + id: true, + username: true, + }, + }, + proposedIngredients: PROPOSAL_INGREDIENTS_SELECT, +}; + /** * POST /api/recipes/:recipeId/proposals * Creer une proposition de modification sur une recette communautaire @@ -23,7 +44,7 @@ export const createProposal: RequestHandler< CreateProposalBody, unknown > = async (req, res, next) => { - const { proposedTitle, proposedContent } = req.body; + const { proposedTitle, proposedContent, proposedIngredients } = req.body; const authenticatedUserId = req.session.userId; const { recipeId } = req.params; @@ -38,6 +59,11 @@ export const createProposal: RequestHandler< throw createHttpError(400, "RECIPE_004: Content required"); } + // Validation du nombre d'ingredients + if (proposedIngredients && proposedIngredients.length > 50) { + throw createHttpError(400, "INGREDIENT_003: Too many ingredients (max 50)"); + } + // Recuperer la recette avec sa communaute const recipe = await prisma.recipe.findFirst({ where: { @@ -82,24 +108,14 @@ export const createProposal: RequestHandler< recipeId, proposerId: authenticatedUserId, }, - select: { - id: true, - proposedTitle: true, - proposedContent: true, - status: true, - createdAt: true, - decidedAt: true, - recipeId: true, - proposerId: true, - proposer: { - select: { - id: true, - username: true, - }, - }, - }, + select: { id: true }, }); + // Stocker les ingredients proposes + if (proposedIngredients && proposedIngredients.length > 0) { + await upsertProposalIngredients(tx, newProposal.id, proposedIngredients, authenticatedUserId); + } + // Creer ActivityLog await tx.activityLog.create({ data: { @@ -111,7 +127,12 @@ export const createProposal: RequestHandler< }, }); - return newProposal; + const created = await tx.recipeUpdateProposal.findUnique({ + where: { id: newProposal.id }, + select: PROPOSAL_RESPONSE_SELECT, + }); + // Ne peut pas etre null : on vient de le creer + return created!; }); appEvents.emitActivity({ @@ -193,22 +214,7 @@ export const getProposals: RequestHandler< const [proposals, total] = await Promise.all([ prisma.recipeUpdateProposal.findMany({ where: whereClause, - select: { - id: true, - proposedTitle: true, - proposedContent: true, - status: true, - createdAt: true, - decidedAt: true, - recipeId: true, - proposerId: true, - proposer: { - select: { - id: true, - username: true, - }, - }, - }, + select: PROPOSAL_RESPONSE_SELECT, orderBy: { createdAt: "desc", }, @@ -250,20 +256,7 @@ export const getProposal: RequestHandler< deletedAt: null, }, select: { - id: true, - proposedTitle: true, - proposedContent: true, - status: true, - createdAt: true, - decidedAt: true, - recipeId: true, - proposerId: true, - proposer: { - select: { - id: true, - username: true, - }, - }, + ...PROPOSAL_RESPONSE_SELECT, recipe: { select: { id: true, diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index d6a856e9..5cdd707f 100644 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -51,17 +51,19 @@ export const authRateLimiter: RequestHandler = env.NODE_ENV === "test" legacyHeaders: false, }); -export const adminRateLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute - max: 30, // 30 requetes par minute - message: { error: "ADMIN_011: Too many requests, please slow down" }, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => { - // Skip pour les routes auth (elles ont leur propre rate limiter plus strict) - return req.path.startsWith("/auth"); - }, -}); +export const adminRateLimiter: RequestHandler = env.NODE_ENV === "test" + ? ((_req, _res, next) => next()) + : rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 30, // 30 requetes par minute + message: { error: "ADMIN_011: Too many requests, please slow down" }, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => { + // Skip pour les routes auth (elles ont leur propre rate limiter plus strict) + return req.path.startsWith("/auth"); + }, + }); /** * Middleware pour forcer HTTPS en production diff --git a/backend/src/services/proposalService.ts b/backend/src/services/proposalService.ts index 51ccae2f..b57a279e 100644 --- a/backend/src/services/proposalService.ts +++ b/backend/src/services/proposalService.ts @@ -1,5 +1,6 @@ import { ActivityType } from "@prisma/client"; import prisma from "../util/db"; +import { PROPOSAL_INGREDIENTS_SELECT } from "../util/prismaSelects"; const PROPOSAL_SELECT = { id: true, @@ -16,6 +17,7 @@ const PROPOSAL_SELECT = { username: true, }, }, + proposedIngredients: PROPOSAL_INGREDIENTS_SELECT, }; interface ProposalWithRecipe { @@ -38,8 +40,32 @@ interface ProposalWithRecipe { }; } +/** + * Copie les ProposalIngredients vers les RecipeIngredients d'une recette cible. + * Supprime d'abord les RecipeIngredients existants. + */ +async function applyProposalIngredients( + tx: Omit, + recipeId: string, + proposalIngredients: Array<{ ingredientId: string; quantity: number | null; unitId: string | null; order: number }> +) { + await tx.recipeIngredient.deleteMany({ where: { recipeId } }); + for (const pi of proposalIngredients) { + await tx.recipeIngredient.create({ + data: { + recipeId, + ingredientId: pi.ingredientId, + quantity: pi.quantity, + unitId: pi.unitId, + order: pi.order, + }, + }); + } +} + /** * Accepte une proposition : met a jour la recette + propage aux copies liees. + * Si des ProposalIngredients existent, remplace les RecipeIngredients. */ export async function acceptProposal( proposalId: string, @@ -49,7 +75,21 @@ export async function acceptProposal( return prisma.$transaction(async (tx) => { const now = new Date(); - // 1. Mettre a jour la recette communautaire + // Recuperer les ingredients proposes + const proposalIngredients = await tx.proposalIngredient.findMany({ + where: { proposalId }, + select: { + ingredientId: true, + quantity: true, + unitId: true, + order: true, + }, + orderBy: { order: "asc" }, + }); + + const hasProposedIngredients = proposalIngredients.length > 0; + + // 1. Mettre a jour la recette communautaire (titre + contenu) await tx.recipe.update({ where: { id: proposal.recipe.id }, data: { @@ -59,6 +99,11 @@ export async function acceptProposal( }, }); + // Remplacer les ingredients sur la recette communautaire + if (hasProposedIngredients) { + await applyProposalIngredients(tx, proposal.recipe.id, proposalIngredients); + } + // 2. Si la recette a un originRecipeId (lien vers la perso), propager if (proposal.recipe.originRecipeId) { const originRecipe = await tx.recipe.findFirst({ @@ -80,6 +125,10 @@ export async function acceptProposal( }, }); + if (hasProposedIngredients) { + await applyProposalIngredients(tx, originRecipe.id, proposalIngredients); + } + // 3. Propager aux autres copies communautaires const otherCommunityRecipes = await tx.recipe.findMany({ where: { @@ -100,6 +149,12 @@ export async function acceptProposal( }, }); + if (hasProposedIngredients) { + for (const linked of otherCommunityRecipes) { + await applyProposalIngredients(tx, linked.id, proposalIngredients); + } + } + // Creer ActivityLog RECIPE_UPDATED pour chaque communaute const activityLogs = otherCommunityRecipes .filter((r) => r.communityId) @@ -154,6 +209,7 @@ interface ProposalForReject { /** * Refuse une proposition et cree une variante pour le proposeur. + * Si des ProposalIngredients existent, les copie dans la variante. */ export async function rejectProposal( proposalId: string, @@ -162,6 +218,18 @@ export async function rejectProposal( return prisma.$transaction(async (tx) => { const now = new Date(); + // Recuperer les ingredients proposes avant de creer la variante + const proposalIngredients = await tx.proposalIngredient.findMany({ + where: { proposalId }, + select: { + ingredientId: true, + quantity: true, + unitId: true, + order: true, + }, + orderBy: { order: "asc" }, + }); + // 1. Creer une variante pour le proposeur const variant = await tx.recipe.create({ data: { @@ -186,6 +254,21 @@ export async function rejectProposal( }, }); + // Copier les ingredients proposes dans la variante + if (proposalIngredients.length > 0) { + for (const pi of proposalIngredients) { + await tx.recipeIngredient.create({ + data: { + recipeId: variant.id, + ingredientId: pi.ingredientId, + quantity: pi.quantity, + unitId: pi.unitId, + order: pi.order, + }, + }); + } + } + // 2. Mettre a jour la proposition const updatedProposal = await tx.recipeUpdateProposal.update({ where: { id: proposalId }, diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index 0f897fd8..a254e8a7 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -73,6 +73,43 @@ export async function upsertIngredients( } } +export async function upsertProposalIngredients( + tx: TransactionClient, + proposalId: string, + ingredients: IngredientInput[], + userId?: string +) { + for (let i = 0; i < ingredients.length; i++) { + const ing = ingredients[i]; + const ingredientName = ing.name.trim().toLowerCase(); + if (!ingredientName) continue; + + let ingredient = await tx.ingredient.findUnique({ + where: { name: ingredientName }, + }); + + if (!ingredient) { + ingredient = await tx.ingredient.create({ + data: { + name: ingredientName, + status: userId ? "PENDING" : "APPROVED", + createdById: userId ?? null, + }, + }); + } + + await tx.proposalIngredient.create({ + data: { + proposalId, + ingredientId: ingredient.id, + quantity: ing.quantity ?? null, + unitId: ing.unitId ?? null, + order: i, + }, + }); + } +} + // --- Select pour re-fetch apres create/update --- const RECIPE_RESULT_SELECT = { diff --git a/backend/src/util/prismaSelects.ts b/backend/src/util/prismaSelects.ts index 32d7d525..ac7f4f8f 100644 --- a/backend/src/util/prismaSelects.ts +++ b/backend/src/util/prismaSelects.ts @@ -18,6 +18,26 @@ export const RECIPE_TAGS_SELECT = { }, } as const; +/** Select pour les ingredients proposes dans une proposal, tries par ordre */ +export const PROPOSAL_INGREDIENTS_SELECT = { + select: { + id: true, + quantity: true, + unitId: true, + order: true, + ingredient: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + orderBy: { + order: "asc" as const, + }, +}; + /** Select pour les ingredients d'une recette, tries par ordre */ export const RECIPE_INGREDIENTS_SELECT = { select: { diff --git a/docs/features/ingredients-rework/ROADMAP.md b/docs/features/ingredients-rework/ROADMAP.md index 7bd91c3a..f29df2dc 100644 --- a/docs/features/ingredients-rework/ROADMAP.md +++ b/docs/features/ingredients-rework/ROADMAP.md @@ -44,19 +44,19 @@ ## 11.4 - Backend Proposals + Ingredients -- [ ] Adapter creation de proposal : stocker ProposalIngredient (ingredients proposes) -- [ ] Adapter acceptation de proposal : remplacer RecipeIngredient par ProposalIngredient -- [ ] Adapter rejet de proposal : cascade delete ProposalIngredient -- [ ] Creation ingredient PENDING depuis une proposal (meme flow que recette) -- [ ] Tests unitaires + integration +- [x] Adapter creation de proposal : stocker ProposalIngredient (ingredients proposes) +- [x] Adapter acceptation de proposal : remplacer RecipeIngredient par ProposalIngredient +- [x] Adapter rejet de proposal : cascade delete ProposalIngredient +- [x] Creation ingredient PENDING depuis une proposal (meme flow que recette) +- [x] Tests unitaires + integration ## 11.5 - Backend Notifications -- [ ] Notification WebSocket : INGREDIENT_APPROVED (au createur) -- [ ] Notification WebSocket : INGREDIENT_MODIFIED (au createur, avec newName) -- [ ] Notification WebSocket : INGREDIENT_MERGED (au createur, avec targetName) -- [ ] Notification WebSocket : INGREDIENT_REJECTED (au createur, avec reason) -- [ ] Tests +- [x] Notification WebSocket : INGREDIENT_APPROVED (au createur) +- [x] Notification WebSocket : INGREDIENT_MODIFIED (au createur, avec newName) +- [x] Notification WebSocket : INGREDIENT_MERGED (au createur, avec targetName) +- [x] Notification WebSocket : INGREDIENT_REJECTED (au createur, avec reason) +- [x] Tests ## 11.6 - Frontend Units & Ingredients (refactoring formulaire) From aaa97471b6c2940da99f41d1b64b0dc05e692954 Mon Sep 17 00:00:00 2001 From: "matthias.bouloc" Date: Wed, 18 Feb 2026 14:52:12 +0100 Subject: [PATCH 017/221] feat: frontend UnitSelector, IngredientList rework & ingredient notifications (Phase 11.6) - New UnitSelector component: dropdown grouped by category (Weight/Volume/Spoon/Count/Qualitative) - Refactored IngredientList: numeric quantity, UnitSelector per row, PENDING badge in autocomplete - Intelligent unit pre-selection via getSuggestedUnit API call on ingredient selection - New TypeScript types: Unit, UnitCategory, IngredientStatus, UnitsByCategory, ProposalIngredient - New API functions: getUnits(), getSuggestedUnit(), updated RecipeInput with unitId - Updated RecipeFormPage: simplified ingredient mapping with numeric quantity + unitId - Ingredient notification toasts: APPROVED, MODIFIED, MERGED, REJECTED with dynamic metadata - MSW handlers for GET /api/units and GET /api/ingredients/:id/suggested-unit - Tests: 326 -> 337 frontend (+11 new: 7 UnitSelector + 4 IngredientList) --- .claude/context/PROGRESS.md | 4 +- docs/features/ingredients-rework/ROADMAP.md | 14 +-- frontend/src/__tests__/setup/mswHandlers.ts | 50 +++++++- .../components/form/IngredientList.test.tsx | 107 +++++++++++++++--- .../components/form/UnitSelector.test.tsx | 97 ++++++++++++++++ .../src/components/form/IngredientList.tsx | 67 ++++++++--- frontend/src/components/form/UnitSelector.tsx | 45 ++++++++ frontend/src/hooks/useNotificationToasts.ts | 22 ++++ frontend/src/models/recipe.ts | 30 +++++ frontend/src/network/api.ts | 14 ++- frontend/src/pages/RecipeFormPage.tsx | 17 ++- 11 files changed, 412 insertions(+), 55 deletions(-) create mode 100644 frontend/src/__tests__/unit/components/form/UnitSelector.test.tsx create mode 100644 frontend/src/components/form/UnitSelector.tsx diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 2414417c..516f7d2d 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -15,9 +15,9 @@ Phases 0 a 9.3 terminees. - **Spec** : `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` - **Roadmap** : `docs/features/ingredients-rework/ROADMAP.md` -- **Sous-etape en cours** : 11.5 termine - passer a 11.6 +- **Sous-etape en cours** : 11.6 termine - passer a 11.7 - **Branche** : `IngredientsRework` -- **Tests** : 544 backend + 326 frontend = 870 total +- **Tests** : 544 backend + 337 frontend = 881 total ## Resume de reprise diff --git a/docs/features/ingredients-rework/ROADMAP.md b/docs/features/ingredients-rework/ROADMAP.md index f29df2dc..db986d07 100644 --- a/docs/features/ingredients-rework/ROADMAP.md +++ b/docs/features/ingredients-rework/ROADMAP.md @@ -60,13 +60,13 @@ ## 11.6 - Frontend Units & Ingredients (refactoring formulaire) -- [ ] Composant UnitSelector : dropdown groupee par categorie -- [ ] Refactoring IngredientList : autocomplete + champ quantite numerique + UnitSelector -- [ ] Pre-selection unite intelligente (appel suggested-unit au choix d'ingredient) -- [ ] Badge "nouveau" sur ingredients PENDING dans l'autocomplete -- [ ] Endpoint frontend API : GET /api/units, GET /api/ingredients/:id/suggested-unit -- [ ] Types TypeScript : Unit, UnitCategory, IngredientStatus, ProposalIngredient -- [ ] Tests composants +- [x] Composant UnitSelector : dropdown groupee par categorie +- [x] Refactoring IngredientList : autocomplete + champ quantite numerique + UnitSelector +- [x] Pre-selection unite intelligente (appel suggested-unit au choix d'ingredient) +- [x] Badge "nouveau" sur ingredients PENDING dans l'autocomplete +- [x] Endpoint frontend API : GET /api/units, GET /api/ingredients/:id/suggested-unit +- [x] Types TypeScript : Unit, UnitCategory, IngredientStatus, ProposalIngredient +- [x] Tests composants ## 11.7 - Frontend Administration diff --git a/frontend/src/__tests__/setup/mswHandlers.ts b/frontend/src/__tests__/setup/mswHandlers.ts index 59271501..ac3b3572 100644 --- a/frontend/src/__tests__/setup/mswHandlers.ts +++ b/frontend/src/__tests__/setup/mswHandlers.ts @@ -38,11 +38,24 @@ export const mockTags = [ ]; export const mockIngredients = [ - { id: 'ing-1', name: 'sugar', recipeCount: 10 }, - { id: 'ing-2', name: 'flour', recipeCount: 8 }, - { id: 'ing-3', name: 'butter', recipeCount: 5 }, + { id: 'ing-1', name: 'sugar', recipeCount: 10, status: 'APPROVED' }, + { id: 'ing-2', name: 'flour', recipeCount: 8, status: 'APPROVED' }, + { id: 'ing-3', name: 'butter', recipeCount: 5, status: 'APPROVED' }, ]; +export const mockUnits = { + WEIGHT: [ + { id: 'unit-1', name: 'gramme', abbreviation: 'g', category: 'WEIGHT', sortOrder: 1 }, + { id: 'unit-2', name: 'kilogramme', abbreviation: 'kg', category: 'WEIGHT', sortOrder: 2 }, + ], + VOLUME: [ + { id: 'unit-3', name: 'centilitre', abbreviation: 'cl', category: 'VOLUME', sortOrder: 2 }, + ], + COUNT: [ + { id: 'unit-4', name: 'piece', abbreviation: 'pc', category: 'COUNT', sortOrder: 1 }, + ], +}; + export const mockFeatures = [ { id: 'feat-1', code: 'MVP', name: 'MVP Feature', description: 'Default feature', isDefault: true, communityCount: 3 }, { id: 'feat-2', code: 'PREMIUM', name: 'Premium Feature', description: 'Premium only', isDefault: false, communityCount: 1 }, @@ -561,12 +574,39 @@ export const handlers = [ return HttpResponse.json({ data: [ - { id: 'ing-1', name: 'sugar', recipeCount: 10 }, - { id: 'ing-2', name: 'flour', recipeCount: 8 }, + { id: 'ing-1', name: 'sugar', recipeCount: 10, status: 'APPROVED' }, + { id: 'ing-2', name: 'flour', recipeCount: 8, status: 'APPROVED' }, + { id: 'ing-3', name: 'new_pending', recipeCount: 1, status: 'PENDING' }, ], }); }), + // GET /api/units + http.get(`${API_URL}/api/units`, () => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + return HttpResponse.json(mockUnits); + }), + + // GET /api/ingredients/:id/suggested-unit + http.get(`${API_URL}/api/ingredients/:id/suggested-unit`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json( + { error: 'AUTH_001: Not authenticated' }, + { status: 401 } + ); + } + // ing-1 (sugar) -> suggest gramme unit + if (params.id === 'ing-1') { + return HttpResponse.json({ suggestedUnitId: 'unit-1', source: 'popular' }); + } + return HttpResponse.json({ suggestedUnitId: null, source: null }); + }), + // ===================================== // Admin Dashboard // ===================================== diff --git a/frontend/src/__tests__/unit/components/form/IngredientList.test.tsx b/frontend/src/__tests__/unit/components/form/IngredientList.test.tsx index e370bf41..11b43ee0 100644 --- a/frontend/src/__tests__/unit/components/form/IngredientList.test.tsx +++ b/frontend/src/__tests__/unit/components/form/IngredientList.test.tsx @@ -1,10 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState as reactUseState } from 'react'; import { render } from '../../../setup/testUtils'; import IngredientList, { IngredientInput } from '../../../../components/form/IngredientList'; import { setUserAuthenticated, resetAuthState } from '../../../setup/mswHandlers'; +// Wrapper avec vrai state pour tester les interactions autocomplete +const StatefulIngredientList = ({ initial = [{ name: '' }] }: { initial?: IngredientInput[] }) => { + const [ingredients, setIngredients] = reactUseState(initial); + return ; +}; + describe('IngredientList', () => { const mockOnChange = vi.fn(); @@ -36,13 +43,13 @@ describe('IngredientList', () => { await user.click(screen.getByText('Add ingredient')); - expect(mockOnChange).toHaveBeenCalledWith([{ name: '', quantity: '' }]); + expect(mockOnChange).toHaveBeenCalledWith([{ name: '' }]); }); it('should display existing ingredients', () => { const ingredients: IngredientInput[] = [ - { name: 'sugar', quantity: '100g' }, - { name: 'flour', quantity: '200g' }, + { name: 'sugar', quantity: 100 }, + { name: 'flour', quantity: 200 }, ]; render( @@ -53,15 +60,15 @@ describe('IngredientList', () => { ); expect(screen.getByDisplayValue('sugar')).toBeInTheDocument(); - expect(screen.getByDisplayValue('100g')).toBeInTheDocument(); + expect(screen.getByDisplayValue('100')).toBeInTheDocument(); expect(screen.getByDisplayValue('flour')).toBeInTheDocument(); - expect(screen.getByDisplayValue('200g')).toBeInTheDocument(); + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); }); it('should update ingredient name', async () => { const user = userEvent.setup(); const ingredients: IngredientInput[] = [ - { name: 'sugar', quantity: '100g' }, + { name: 'sugar', quantity: 100 }, ]; render( @@ -80,10 +87,11 @@ describe('IngredientList', () => { }); }); - it('should update ingredient quantity', async () => { + it('should update ingredient quantity as a number', async () => { const user = userEvent.setup(); + // Start with no quantity so typing produces a clean number const ingredients: IngredientInput[] = [ - { name: 'sugar', quantity: '100g' }, + { name: 'sugar' }, ]; render( @@ -93,20 +101,21 @@ describe('IngredientList', () => { /> ); - const quantityInput = screen.getByDisplayValue('100g'); - await user.clear(quantityInput); - await user.type(quantityInput, '200g'); + const quantityInput = screen.getByPlaceholderText('Qty'); + await user.type(quantityInput, '250'); await waitFor(() => { expect(mockOnChange).toHaveBeenCalled(); + const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0]; + expect(typeof lastCall[0].quantity).toBe('number'); }); }); it('should remove ingredient when remove button is clicked', async () => { const user = userEvent.setup(); const ingredients: IngredientInput[] = [ - { name: 'sugar', quantity: '100g' }, - { name: 'flour', quantity: '200g' }, + { name: 'sugar', quantity: 100 }, + { name: 'flour', quantity: 200 }, ]; render( @@ -124,13 +133,13 @@ describe('IngredientList', () => { await user.click(removeButtons[0]); expect(mockOnChange).toHaveBeenCalledWith([ - { name: 'flour', quantity: '200g' }, + { name: 'flour', quantity: 200 }, ]); }); it('should render placeholder text for empty inputs', () => { const ingredients: IngredientInput[] = [ - { name: '', quantity: '' }, + { name: '' }, ]; render( @@ -141,6 +150,72 @@ describe('IngredientList', () => { ); expect(screen.getByPlaceholderText('Ingredient name')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Quantity (optional)')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Qty')).toBeInTheDocument(); + }); + + it('should show PENDING badge for PENDING ingredients in autocomplete', async () => { + const user = userEvent.setup(); + + render(); + + const nameInput = screen.getByPlaceholderText('Ingredient name'); + await user.type(nameInput, 'new'); + + await waitFor(() => { + // "new_pending" ingredient from MSW mock should show with badge "nouveau" + expect(screen.getByText('nouveau')).toBeInTheDocument(); + }, { timeout: 2000 }); + }); + + it('should render UnitSelector dropdown for each ingredient row', async () => { + const ingredients: IngredientInput[] = [ + { name: 'sugar', quantity: 100 }, + ]; + + render( + + ); + + // Wait for units to load and selector to appear + await waitFor(() => { + expect(screen.getByRole('combobox', { name: 'Unit' })).toBeInTheDocument(); + }); + }); + + it('should pre-select unit when selecting ingredient from autocomplete', async () => { + const user = userEvent.setup(); + const onChangeSpy = vi.fn(); + + // Wrapper hybride: state reel mais on espie les appels + const SpyWrapper = () => { + const [ingredients, setIngredients] = reactUseState([{ name: '' }]); + const handleChange = (updated: IngredientInput[]) => { + setIngredients(updated); + onChangeSpy(updated); + }; + return ; + }; + + render(); + + const nameInput = screen.getByPlaceholderText('Ingredient name'); + await user.type(nameInput, 'sug'); + + await waitFor(() => { + expect(screen.getByText('sugar')).toBeInTheDocument(); + }, { timeout: 2000 }); + + await user.click(screen.getByText('sugar')); + + // Should pre-select the unit from getSuggestedUnit (unit-1 = 'g' for ing-1) + await waitFor(() => { + const calls = onChangeSpy.mock.calls; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall[0].unitId).toBe('unit-1'); + expect(lastCall[0].ingredientId).toBe('ing-1'); + }, { timeout: 2000 }); }); }); diff --git a/frontend/src/__tests__/unit/components/form/UnitSelector.test.tsx b/frontend/src/__tests__/unit/components/form/UnitSelector.test.tsx new file mode 100644 index 00000000..4c17512d --- /dev/null +++ b/frontend/src/__tests__/unit/components/form/UnitSelector.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../../setup/testUtils'; +import UnitSelector from '../../../../components/form/UnitSelector'; +import { UnitsByCategory } from '../../../../models/recipe'; + +const mockUnits: UnitsByCategory = { + WEIGHT: [ + { id: 'unit-1', name: 'gramme', abbreviation: 'g', category: 'WEIGHT', sortOrder: 1 }, + { id: 'unit-2', name: 'kilogramme', abbreviation: 'kg', category: 'WEIGHT', sortOrder: 2 }, + ], + VOLUME: [ + { id: 'unit-3', name: 'centilitre', abbreviation: 'cl', category: 'VOLUME', sortOrder: 2 }, + ], + COUNT: [ + { id: 'unit-4', name: 'piece', abbreviation: 'pc', category: 'COUNT', sortOrder: 1 }, + ], +}; + +describe('UnitSelector', () => { + it('should render a select with default empty option', () => { + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }); + expect(select).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Unit' })).toBeInTheDocument(); + }); + + it('should render optgroups by category', () => { + render(); + + expect(screen.getByRole('option', { name: 'g' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'kg' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'cl' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'pc' })).toBeInTheDocument(); + }); + + it('should show selected unit', () => { + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }) as HTMLSelectElement; + expect(select.value).toBe('unit-1'); + }); + + it('should call onChange with unit id when selection changes', async () => { + const user = userEvent.setup(); + const mockOnChange = vi.fn(); + + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }); + await user.selectOptions(select, 'unit-1'); + + expect(mockOnChange).toHaveBeenCalledWith('unit-1'); + }); + + it('should call onChange with null when empty option is selected', async () => { + const user = userEvent.setup(); + const mockOnChange = vi.fn(); + + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }); + await user.selectOptions(select, ''); + + expect(mockOnChange).toHaveBeenCalledWith(null); + }); + + it('should be disabled when units object is empty', () => { + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }); + expect(select).toBeDisabled(); + }); + + it('should be disabled when disabled prop is true', () => { + render(); + + const select = screen.getByRole('combobox', { name: 'Unit' }); + expect(select).toBeDisabled(); + }); + + it('should not render empty categories', () => { + const sparseUnits: UnitsByCategory = { + WEIGHT: [ + { id: 'unit-1', name: 'gramme', abbreviation: 'g', category: 'WEIGHT', sortOrder: 1 }, + ], + }; + + render(); + + // VOLUME/COUNT/SPOON/QUALITATIVE optgroups should not be present + expect(screen.queryByRole('option', { name: 'cl' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'pc' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/form/IngredientList.tsx b/frontend/src/components/form/IngredientList.tsx index d3bdf0aa..dddd16b0 100644 --- a/frontend/src/components/form/IngredientList.tsx +++ b/frontend/src/components/form/IngredientList.tsx @@ -1,13 +1,16 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { FaPlus, FaTimes } from "react-icons/fa"; import APIManager from "../../network/api"; -import { IngredientSearchResult } from "../../models/recipe"; +import { IngredientSearchResult, UnitsByCategory } from "../../models/recipe"; import { useClickOutside } from "../../hooks/useClickOutside"; import { useDebouncedEffect } from "../../hooks/useDebouncedEffect"; +import UnitSelector from "./UnitSelector"; export interface IngredientInput { name: string; - quantity: string; + quantity?: number; + unitId?: string; + ingredientId?: string; } interface IngredientListProps { @@ -18,11 +21,12 @@ interface IngredientListProps { interface IngredientRowProps { ingredient: IngredientInput; index: number; + units: UnitsByCategory; onUpdate: (index: number, ingredient: IngredientInput) => void; onRemove: (index: number) => void; } -const IngredientRow = ({ ingredient, index, onUpdate, onRemove }: IngredientRowProps) => { +const IngredientRow = ({ ingredient, index, units, onUpdate, onRemove }: IngredientRowProps) => { const [suggestions, setSuggestions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -44,9 +48,20 @@ const IngredientRow = ({ ingredient, index, onUpdate, onRemove }: IngredientRowP useClickOutside(containerRef, useCallback(() => setShowDropdown(false), [])); - const selectSuggestion = (name: string) => { - onUpdate(index, { ...ingredient, name }); + const selectSuggestion = async (suggestion: IngredientSearchResult) => { setShowDropdown(false); + let unitId = ingredient.unitId; + + try { + const suggested = await APIManager.getSuggestedUnit(suggestion.id); + if (suggested.suggestedUnitId) { + unitId = suggested.suggestedUnitId; + } + } catch { + // Ignore pre-selection errors + } + + onUpdate(index, { ...ingredient, name: suggestion.name, ingredientId: suggestion.id, unitId }); }; return ( @@ -56,7 +71,7 @@ const IngredientRow = ({ ingredient, index, onUpdate, onRemove }: IngredientRowP type="text" value={ingredient.name} onChange={(e) => { - onUpdate(index, { ...ingredient, name: e.target.value }); + onUpdate(index, { ...ingredient, name: e.target.value, ingredientId: undefined }); setShowDropdown(true); }} onFocus={() => setShowDropdown(true)} @@ -74,10 +89,15 @@ const IngredientRow = ({ ingredient, index, onUpdate, onRemove }: IngredientRowP
onUpdate(index, { ...ingredient, quantity: e.target.value })} - placeholder="Quantity (optional)" - className="input input-bordered w-32" + type="number" + value={ingredient.quantity ?? ""} + onChange={(e) => { + const val = e.target.value; + onUpdate(index, { ...ingredient, quantity: val ? parseFloat(val) : undefined }); + }} + placeholder="Qty" + min={0} + step="any" + className="input input-bordered w-24" + /> + onUpdate(index, { ...ingredient, unitId: unitId ?? undefined })} + units={units} /> - {/* Search */} -
+ {/* Filters */} +
setSearch(e.target.value)} /> +
{/* Table */} @@ -130,6 +233,9 @@ function AdminIngredientsPage() { Name + Status + Default Unit + Created By Recipes Actions @@ -139,9 +245,37 @@ function AdminIngredientsPage() { ingredients.map((item) => ( {item.name} + + {item.status === "PENDING" ? ( + Pending + ) : ( + Approved + )} + + + {item.defaultUnit ? ( + {item.defaultUnit.abbreviation} + ) : ( + - + )} + + + {item.createdBy ? ( + {item.createdBy.username} + ) : ( + admin + )} + {item.recipeCount} -
+
+ {item.status === "PENDING" && ( + <> + + + + + )} @@ -151,7 +285,7 @@ function AdminIngredientsPage() { )) ) : ( - No ingredients found + No ingredients found )} @@ -175,6 +309,19 @@ function AdminIngredientsPage() { onKeyDown={(e) => e.key === "Enter" && handleSave()} />
+
+ + +
)} + {/* Approve + Rename Modal */} + {approveModalOpen && approveItem && ( +
+
+

Approve & Rename

+

+ Approve ingredient "{approveItem.name}". Optionally change the name. +

+
+ + setApproveNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleApproveWithRename()} + /> +
+
+ + +
+
+
{ setApproveModalOpen(false); setApproveItem(null); }} /> +
+ )} + + {/* Reject Modal */} + {rejectModalOpen && rejectItem && ( +
+
+

Reject Ingredient

+

+ Reject ingredient "{rejectItem.name}". This will permanently delete it and remove it from all recipes. +

+
+ +