diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 252c1c9e..4e9dbe6e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -33,41 +33,92 @@ 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. +MVP complet (phases 0-17). Voir `.claude/context/PROGRESS.md` pour les features en cours. ## Codes erreur -AUTH_001 (non auth) | COMMUNITY_001-006 | RECIPE_001-002 | INVITE_001-003 | ADMIN_001-012 +**User API** : AUTH_001-012 | USER_001 | COMMUNITY_001-006 | RECIPE_001-009 | INVITE_001-006 | MEMBER_001-004 | PROPOSAL_001-004 | SHARE_001-006 | PUBLISH_001-003 | TAG_001-007 | INGREDIENT_003 | NOTIF_001-005 | IMPORT_001-003 | VALIDATION_001 + +**Admin API** : ADMIN_001-011 | ADMIN_TAG_001-006 | ADMIN_ING_001-009 | ADMIN_UNIT_001-007 | ADMIN_REC_001-003 | ADMIN_COM_001-003 | ADMIN_FEAT_001-006 ## 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 + +## Organisation docs/ + +``` +docs/ + 0 - brainstorming futur.md # Idees futures (transversal) + features/ # Specs par feature post-MVP + tags-rework/ + SPEC_TAGS_REWORK.md + ingredients-rework/ + SPEC_INGREDIENTS_REWORK.md + recipe-rework-v2/ + SPEC_RECIPE_REWORK_V2.md + notifications-rework/ + SPEC_NOTIFICATIONS_REWORK.md + input-validation-security/ + SPEC_INPUT_VALIDATION.md + photo-upload/ + SPEC_PHOTO_UPLOAD.md + GUIDE_MINIO.md + audit-refactorisation/ + SPEC_AUDIT_REFACTORISATION.md + recipe-import/ + SPEC_RECIPE_IMPORT.md + ROADMAP.md + mobile-rework/ # EN COURS + SPEC_MOBILE_REWORK.md + ROADMAP.md + e2e-testing/ # PLANIFIE + SPEC_E2E_TESTING.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` | -| 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` | +| Besoin | Fichier | +| ----------------------------------- | ------------------------------------------------------------------- | +| 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` | +| Idees futures | `docs/0 - brainstorming futur.md` | +| **Feature : Recipe Import** | | +| Spec Recipe Import | `docs/features/recipe-import/SPEC_RECIPE_IMPORT.md` | +| Roadmap Recipe Import | `docs/features/recipe-import/ROADMAP.md` | +| **Feature : Mobile Rework** | | +| Spec Mobile Rework | `docs/features/mobile-rework/SPEC_MOBILE_REWORK.md` | +| Roadmap Mobile Rework | `docs/features/mobile-rework/ROADMAP.md` | +| **Feature : E2E Testing** | | +| Spec E2E Testing | `docs/features/e2e-testing/SPEC_E2E_TESTING.md` | +| Roadmap E2E Testing | `docs/features/e2e-testing/ROADMAP.md` | +| **Specs features (reference)** | | +| Tags Rework | `docs/features/tags-rework/SPEC_TAGS_REWORK.md` | +| Ingredients Rework | `docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md` | +| Recipe Rework v2 | `docs/features/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md` | +| Input Validation | `docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md` | +| Notifications Rework | `docs/features/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md` | +| Photo Upload | `docs/features/photo-upload/SPEC_PHOTO_UPLOAD.md` | +| Guide MinIO | `docs/features/photo-upload/GUIDE_MINIO.md` | +| Audit Refactorisation | `docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md` | diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 26ad710c..081c8839 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -3,146 +3,268 @@ Base URL: `http://localhost:3001` ## Health + ``` GET /health ``` ## User Auth (/api/auth) - userSession middleware + ``` POST /api/auth/signup # signup(email, username, password) POST /api/auth/login # login(email, password) POST /api/auth/logout # logout GET /api/auth/me # current user ``` + Controller: `controllers/auth.ts` | Route: `routes/auth.ts` ## Recipes (/api/recipes) - requireAuth + ``` -GET /api/recipes/ # list (paginated, filter by tags, search) -GET /api/recipes/:recipeId # detail (owner or community member) -POST /api/recipes/ # create (title, content, tags[], ingredients[]) -PATCH /api/recipes/:recipeId # update (owner, +membership for community recipes) +GET /api/recipes/ # list (paginated, filter by tags, search) → includes servings, prepTime, cookTime, restTime +GET /api/recipes/:recipeId # detail (owner or community member) → includes steps[], servings, times +POST /api/recipes/ # create (title, servings, steps[], prepTime?, cookTime?, restTime?, tags[], ingredients[]) +PATCH /api/recipes/:recipeId # update (owner, +membership for community recipes) → partial: title?, servings?, steps[]?, times?, tags?, ingredients? DELETE /api/recipes/:recipeId # soft delete (owner, +membership for community recipes) -GET /api/recipes/:recipeId/variants # list variants (isVariant=true, same community) -POST /api/recipes/:recipeId/share # fork to another community -POST /api/recipes/:recipeId/publish # publish personal recipe to communities +GET /api/recipes/:recipeId/variants # list variants (isVariant=true, same community) → includes servings, times +POST /api/recipes/:recipeId/upload-url # presigned PUT URL for image upload (owner only) +POST /api/recipes/:recipeId/confirm-upload # confirm image upload (validate MIME/size on MinIO) +DELETE /api/recipes/:recipeId/image # delete recipe image (MinIO + DB) +POST /api/recipes/:recipeId/share # fork to another community (copies steps, servings, times) +POST /api/recipes/:recipeId/publish # publish personal recipe to communities (copies steps, servings, times) GET /api/recipes/:recipeId/communities # list communities where recipe has copies +POST /api/recipes/import-url # import recipe from URL (JSON-LD extraction) ``` -Controller: `controllers/recipes.ts` | Route: `routes/recipes.ts` + +Controller: `controllers/recipes.ts`, `controllers/recipeImage.ts`, `controllers/recipeImport.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` ## 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` +## 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 POST /api/communities/ # create (auto MODERATOR) GET /api/communities/:communityId # detail (memberOf) PATCH /api/communities/:communityId # update (MODERATOR) +POST /api/communities/:communityId/upload-url # presigned PUT URL for avatar upload (MODERATOR) +POST /api/communities/:communityId/confirm-upload # confirm avatar upload (validate MIME/size) +DELETE /api/communities/:communityId/image # delete community avatar (MODERATOR) ``` -Controller: `controllers/communities.ts` | Route: `routes/communities.ts` + +Controller: `controllers/communities.ts`, `controllers/communityImage.ts` | Route: `routes/communities.ts` ### Recipes (nested under /api/communities/:communityId) + ``` GET /api/communities/:communityId/recipes # list (memberOf, paginated, filter tags/search) POST /api/communities/:communityId/recipes # create (memberOf, creates personal + community copy) ``` + Controller: `controllers/communityRecipes.ts` ### Members (nested under /api/communities/:communityId) + ``` GET /api/communities/:communityId/members # list (memberOf) PATCH /api/communities/:communityId/members/:userId # promote (MODERATOR, no demote) DELETE /api/communities/:communityId/members/:userId # kick/leave (memberOf) ``` + Controller: `controllers/members.ts` ### Invitations (nested under /api/communities/:communityId) + ``` GET /api/communities/:communityId/invites # list (MODERATOR, ?status=) POST /api/communities/:communityId/invites # create (MODERATOR) 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) ``` + Controller: `controllers/activity.ts` ## Users (/api/users) - requireAuth + ``` 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) ``` -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` + +## Notifications (/api/notifications) - requireAuth + +``` +GET /api/notifications/ # list paginated (page, limit, category, unreadOnly, grouped) +GET /api/notifications/unread-count # total + by category +PATCH /api/notifications/read # batch mark as read (body: ids[]) +PATCH /api/notifications/read-all # mark all as read (body: category?) +PATCH /api/notifications/:id/read # mark single as read +GET /api/notifications/preferences # preferences (global + per community, 5 categories) +PUT /api/notifications/preferences # update preference (category, enabled, communityId?) +``` + +Controller: `controllers/notifications.ts` | Route: `routes/notifications.ts` ## User Invitations + ``` POST /api/invites/:inviteId/accept # accept POST /api/invites/:inviteId/reject # reject ``` + Controller: `controllers/invites.ts` | Route: `routes/invites.ts` ## Proposals (/api/proposals) - requireAuth + ``` -GET /api/proposals/:proposalId # detail proposition -POST /api/proposals/:proposalId/accept # accepter (createur recette) -POST /api/proposals/:proposalId/reject # refuser + creer variante +GET /api/proposals/:proposalId # detail proposition → includes proposedSteps[], proposedServings, proposedTimes, proposedIngredients[] +POST /api/proposals/:proposalId/accept # accepter (createur recette) → applique steps/servings/times + sync +POST /api/proposals/:proposalId/reject # refuser + creer variante (avec proposedSteps/servings/times) ``` + Controller: `controllers/proposals.ts` | Route: `routes/proposals.ts` ### Proposals (nested under /api/recipes/:recipeId) + ``` -GET /api/recipes/:recipeId/proposals # list propositions (?status=) -POST /api/recipes/:recipeId/proposals # creer proposition +GET /api/recipes/:recipeId/proposals # list propositions (?status=) → includes proposedSteps[], proposedServings, proposedTimes +POST /api/recipes/:recipeId/proposals # creer proposition (proposedTitle, proposedSteps[], proposedServings?, proposedTimes?, proposedIngredients?) ``` + 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 + ``` POST /api/admin/auth/login # login (email, password) POST /api/admin/auth/totp/verify # verify TOTP code POST /api/admin/auth/logout # logout GET /api/admin/auth/me # current admin (requireSuperAdmin) ``` + Controller: `admin/controllers/authController.ts` | Route: `admin/routes/authRoutes.ts` ## 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 +GET /api/admin/tags/:id/recipes # list recipes for tag (?includeDeleted=true) ``` -Controller: `admin/controllers/tagsController.ts` | Route: `admin/routes/tagsRoutes.ts` + +Controller: `admin/controllers/tagsController.ts` + `recipesController.ts` | Route: `admin/routes/tagsRoutes.ts` + +## Admin Recipes (/api/admin/recipes) - requireSuperAdmin + +``` +GET /api/admin/recipes/:recipeId # full detail (tags, ingredients, steps, creator, community) +PATCH /api/admin/recipes/:recipeId # update scalar fields (title, servings, times) +DELETE /api/admin/recipes/:recipeId # soft delete (deletedAt) +``` + +Controller: `admin/controllers/recipesController.ts` | Route: `admin/routes/recipesRoutes.ts` +Error codes: ADMIN_REC_001-003 ## 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` +## 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 GET /api/admin/communities/:id # detail @@ -151,36 +273,41 @@ DELETE /api/admin/communities/:id # soft delete POST /api/admin/communities/:communityId/features/:featureId # grant feature DELETE /api/admin/communities/:communityId/features/:featureId # revoke feature ``` + Controller: `admin/controllers/communitiesController.ts` | Route: `admin/routes/communitiesRoutes.ts` ## Admin Features (/api/admin/features) - requireSuperAdmin + ``` GET /api/admin/features/ # list all POST /api/admin/features/ # create PATCH /api/admin/features/:id # update ``` + Controller: `admin/controllers/featuresController.ts` | Route: `admin/routes/featuresRoutes.ts` ## Admin Dashboard & Activity - requireSuperAdmin + ``` GET /api/admin/dashboard/stats # global stats GET /api/admin/activity/ # admin activity logs (paginated) ``` + Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/activityController.ts` --- ## Middleware Chain -| Middleware | Fichier | Role | -|-----------|---------|------| -| userSession | app.ts (express-session) | Session user (connect.sid) | -| adminSession | app.ts (express-session) | Session admin (admin.sid) | -| requireAuth | middleware/auth.ts | Verifie session.userId | -| requireSuperAdmin | admin/middleware/requireSuperAdmin.ts | Verifie session.adminId + totpVerified | -| memberOf | middleware/community.ts | Verifie appartenance communaute | -| requireCommunityRole | middleware/community.ts | Verifie role dans communaute | -| 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) +| Middleware | Fichier | Role | +| -------------------- | ------------------------------------- | -------------------------------------- | +| userSession | app.ts (express-session) | Session user (connect.sid) | +| adminSession | app.ts (express-session) | Session admin (admin.sid) | +| requireAuth | middleware/auth.ts | Verifie session.userId | +| requireSuperAdmin | admin/middleware/requireSuperAdmin.ts | Verifie session.adminId + totpVerified | +| memberOf | middleware/community.ts | Verifie appartenance communaute | +| requireCommunityRole | middleware/community.ts | Verifie role dans communaute | +| adminRateLimiter | middleware/security.ts | 30 req/min global admin | +| authRateLimiter | routes config | 5/15min sur auth endpoints | + +## Total: 99 endpoints (65 user + 33 admin + 1 health) diff --git a/.claude/context/DB_MODELS.md b/.claude/context/DB_MODELS.md index 206d5ddb..fff5925d 100644 --- a/.claude/context/DB_MODELS.md +++ b/.claude/context/DB_MODELS.md @@ -1,51 +1,75 @@ # Database Schema Reference -Source: `backend/prisma/schema.prisma` (489 lines) +Source: `backend/prisma/schema.prisma` DB: PostgreSQL | ORM: Prisma -## Models (20 total) +## Models (30 total) ### Sessions (isolees) -| Model | Champs cles | Notes | -|-------|-------------|-------| -| Session | id, sid, data, expiresAt | User sessions (connect.sid) | -| AdminSession | id, sid, data, expiresAt | Admin sessions (admin.sid) | + +| Model | Champs cles | Notes | +| ------------ | ------------------------ | --------------------------- | +| Session | id, sid, data, expiresAt | User sessions (connect.sid) | +| AdminSession | id, sid, data, expiresAt | Admin sessions (admin.sid) | ### Admin (4 models) -| Model | Champs cles | Notes | -|-------|-------------|-------| -| AdminUser | id, email, username, password, totpSecret, totpEnabled | 2FA TOTP obligatoire, index email+username | -| Feature | id, code(unique), name, description?, isDefault | Briques ("MVP" par defaut) | -| CommunityFeature | communityId, featureId, grantedById?, revokedAt? | Pivot, soft revoke, @@unique(communityId,featureId) | -| AdminActivityLog | id, type(AdminActionType), targetType?, targetId?, metadata?(Json), adminId | Audit, index type+createdAt | + +| Model | Champs cles | Notes | +| ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- | +| AdminUser | id, email, username, password, totpSecret, totpEnabled | 2FA TOTP obligatoire, index email+username | +| Feature | id, code(unique), name, description?, isDefault | Briques ("MVP" par defaut) | +| CommunityFeature | communityId, featureId, grantedById?, revokedAt? | Pivot, soft revoke, @@unique(communityId,featureId) | +| AdminActivityLog | id, type(AdminActionType), targetType?, targetId?, metadata?(Json), adminId | Audit, index type+createdAt | ### Users & Communities (4 models) -| Model | Champs cles | Notes | -|-------|-------------|-------| -| User | id, email, username, password, deletedAt? | Soft delete, index email+username+deletedAt | -| Community | id, name, description?, visibility(INVITE_ONLY), deletedAt? | Soft delete | -| UserCommunity | userId, communityId, role(MEMBER/MODERATOR), joinedAt, deletedAt? | Soft delete, @@unique(userId,communityId) | + +| Model | Champs cles | Notes | +| --------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- | +| User | id, email, username, password, deletedAt? | Soft delete, index email+username+deletedAt | +| Community | id, name, description?, visibility(INVITE_ONLY), imageKey?, deletedAt? | Soft delete | +| 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) -| 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 | -| Tag | id, name(unique) | Index name | -| RecipeTag | recipeId, tagId | PK composite, **Cascade** delete | -| Ingredient | id, name(unique) | Index name | -| RecipeIngredient | recipeId, ingredientId, quantity?, order | **Cascade** delete, @@unique(recipeId,ingredientId) | +### Recipes (10 models) + +| Model | Champs cles | Notes | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| Recipe | id, title, servings(default 4), prepTime?, cookTime?, restTime?, imageKey?, isVariant, creatorId, communityId?, originRecipeId?, sharedFromCommunityId?, deletedAt? | Soft delete. communityId=null → perso. Phase 15: imageUrl → imageKey | +| RecipeStep | id, recipeId(FK CASCADE), order, instruction | Index(recipeId, order). Phase 13 | +| RecipeUpdateProposal | recipeId, proposerId, proposedTitle, proposedServings?, proposedPrepTime?, proposedCookTime?, proposedRestTime?, status(PENDING/ACCEPTED/REJECTED), deletedAt?, proposedSteps[], proposedIngredients[] | Soft delete. Phase 13: proposedContent → proposedSteps | +| ProposalStep | id, proposalId(FK CASCADE), order, instruction | Index(proposalId, order). Phase 13 | +| 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 | +| 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 (2 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 | + +### Notifications (2 models - Phase 12) + +| Model | Champs cles | Notes | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Notification | id, userId, type, category(NotificationCategory), title, message, actionUrl?, metadata?(Json), actorId?, communityId?, recipeId?, groupKey?, readAt?, createdAt | Index userId+readAt+createdAt, userId+createdAt, userId+groupKey+createdAt, createdAt. Cascade on user/community, SetNull on actor/recipe | +| NotificationPreference | id, userId, communityId?(null=global), category(NotificationCategory), enabled(default true), updatedAt | @@unique(userId,communityId,category). Remplace ModeratorNotificationPreference | ### Analytics (2 models - futur) -| Model | Champs cles | Notes | -|-------|-------------|-------| + +| Model | Champs cles | Notes | +| --------------- | -------------------------------------- | -------------- | | RecipeAnalytics | recipeId(unique), views, shares, forks | Cascade delete | -| RecipeView | recipeId, userId?, viewedAt | Cascade delete | +| RecipeView | recipeId, userId?, viewedAt | Cascade delete | ### Activity (1 model) -| Model | Champs cles | Notes | -|-------|-------------|-------| + +| Model | Champs cles | Notes | +| ----------- | -------------------------------------------------------------------- | --------------------------------------- | | ActivityLog | type(ActivityType), userId, communityId?, recipeId?, metadata?(Json) | Index communityId+userId+createdAt+type | ## Enums @@ -56,36 +80,68 @@ 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 + +UnitCategory: WEIGHT | VOLUME | SPOON | COUNT | QUALITATIVE +IngredientStatus: APPROVED | PENDING + +NotificationCategory: INVITATION | RECIPE_PROPOSAL | TAG | INGREDIENT | MODERATION + 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 | + RECIPE_UPDATED | RECIPE_DELETED | FEATURE_CREATED | FEATURE_UPDATED | FEATURE_GRANTED | FEATURE_REVOKED | ADMIN_LOGIN | ADMIN_LOGOUT | ADMIN_TOTP_SETUP 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 ``` -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-> Ingredient (createdById, relation "IngredientCreator") +User <-1:N-> TagSuggestion (suggestedById) +User <-1:N-> UserCommunityTagPreference +User <-1:N-> Notification (recipient, relation "NotificationRecipient") +User <-1:N-> Notification (actor, relation "NotificationActor") +User <-1:N-> NotificationPreference +Recipe <-N:N-> Tag (via RecipeTag, cascade) +Recipe <-N:N-> Ingredient (via RecipeIngredient, cascade) +Recipe <-self-> Recipe (originRecipeId -> variantes/forks) +Recipe <-1:N-> RecipeStep (cascade on delete) +Recipe <-1:N-> TagSuggestion (cascade on delete) +RecipeUpdateProposal <-1:N-> ProposalStep (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) +Community <-1:N-> Notification (cascade) +Community <-1:N-> NotificationPreference (cascade) +Recipe <-1:N-> Notification (SetNull on delete) +User <-1:N-> CommunityInvite (inviter + invitee) +Community <-N:N-> Feature (via CommunityFeature, soft revoke) +AdminUser <-1:N-> AdminActivityLog ``` ## Regles delete -| 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 | -| Soft revoke | CommunityFeature | revokedAt timestamp | +| Type | Modeles | Methode | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| Soft delete (deletedAt) | User, Community, UserCommunity, Recipe, RecipeUpdateProposal, CommunityInvite | Applicatif (where deletedAt: null) | +| Hard delete (Cascade) | RecipeTag, RecipeIngredient, RecipeStep, ProposalIngredient, ProposalStep, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, Notification (via User/Community), NotificationPreference | DB cascade | +| Soft revoke | CommunityFeature | revokedAt timestamp | diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index 48bbab81..a8c2e9bc 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -3,24 +3,33 @@ ## Backend (backend/src/) ### Controllers (logique metier) + ``` controllers/ ├── activity.ts # getCommunityActivity, getMyActivity ├── auth.ts # signup, login, logout, me ├── communities.ts # CRUD communautes +├── communityImage.ts # upload-url, confirm-upload, delete avatar communaute ├── 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) +├── recipeImage.ts # upload-url, confirm-upload, delete image recette ├── recipeVariants.ts # getVariants (liste variantes d'une recette) +├── recipeImport.ts # importRecipeFromUrl (URL import endpoint) ├── recipeShare.ts # shareRecipe, publishToCommunities, getRecipeCommunities -├── tags.ts # autocomplete tags -├── ingredients.ts # autocomplete ingredients +├── 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 + suggested-unit +├── units.ts # list units grouped by category └── users.ts # search users, update profile ``` ### Routes (endpoints API) + ``` routes/ ├── auth.ts # /api/auth/* @@ -28,21 +37,27 @@ 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 +├── units.ts # /api/units +└── users.ts # /api/users/* (incl. tag-preferences, notification-preferences) ``` ### Middleware + ``` middleware/ ├── auth.ts # requireAuth (verifie session.userId) ├── community.ts # memberOf, requireCommunityRole ├── httpLogger.ts # pino-http middleware (remplace morgan) -└── security.ts # helmet, CORS, rate limiting +├── security.ts # helmet, CORS, rate limiting +├── csrf.ts # CSRF protection middleware +└── validateUUID.ts # Validation UUID v4 dans les params ``` ### Admin (module isole) + ``` admin/ ├── controllers/ @@ -50,15 +65,19 @@ admin/ │ ├── communitiesController.ts # list, detail, update, delete, grant/revoke feature │ ├── membersController.ts # admin member management │ ├── tagsController.ts # CRUD + merge tags -│ ├── ingredientsController.ts # CRUD + merge ingredients +│ ├── recipesController.ts # tag recipes list, detail, update, soft delete +│ ├── ingredientsController.ts # CRUD + merge + approve/reject ingredients +│ ├── unitsController.ts # CRUD units (+ usage protection) │ ├── featuresController.ts # CRUD features │ ├── dashboardController.ts # stats globales │ └── activityController.ts # logs activite admin ├── routes/ │ ├── authRoutes.ts │ ├── communitiesRoutes.ts -│ ├── tagsRoutes.ts +│ ├── tagsRoutes.ts # + GET /:id/recipes +│ ├── recipesRoutes.ts │ ├── ingredientsRoutes.ts +│ ├── unitsRoutes.ts │ ├── featuresRoutes.ts │ ├── dashboardRoutes.ts │ └── activityRoutes.ts @@ -67,32 +86,51 @@ admin/ ``` ### Services + ``` services/ +├── tagService.ts # Logique scope-aware tags (resolve, autocomplete, fork) +├── recipeService.ts # upsertTags, upsertIngredients, upsertSteps, upsertProposalSteps, createRecipe, updateRecipe, syncLinkedRecipes +├── communityRecipeService.ts # createCommunityRecipe (perso + comm) +├── shareService.ts # forkRecipe, publishRecipe, getRecipeFamilyCommunities +├── membershipService.ts # requireRecipeAccess, requireRecipeOwnership +├── proposalService.ts # acceptProposal (steps/servings/times + sync), rejectProposal (variant with steps) ├── orphanHandling.ts # Gestion recettes orphelines (auto-reject proposals) +├── notificationService.ts # create, broadcast, preferences, templates, grouping +├── recipeImportService.ts # importFromUrl, parseIngredientLine, parseIsoDuration (JSON-LD extraction) +├── tagSuggestionService.ts # create, accept, reject tag suggestions +├── storageService.ts # MinIO/S3 : presigned URL, headObject, deleteObject, validateUploadedFile ├── eventEmitter.ts # AppEventEmitter singleton (emit activity events) -└── socketServer.ts # Socket.IO server init, auth middleware, room management +└── socketServer.ts # Socket.IO server init, auth, rooms, notification persistence ``` ### Autres backend + ``` app.ts # Config Express, montage routes, sessions -server.ts # Entry point (listen) +server.ts # Entry point (listen + notification cleanup job) types/ ├── express.d.ts # Extension types Express └── session.d.ts # Types session +config/ +└── storage.ts # MinIO/S3 config (storageConfig, buildImageUrl) util/ ├── logger.ts # Logger Pino central (silent test, pretty dev, JSON prod) ├── pagination.ts # parsePagination, buildPaginationMeta -├── validation.ts # normalizeNames, isValidHttpUrl, regex constants -├── responseFormatters.ts # formatTags, formatIngredients +├── validation.ts # normalizeNames, isValidHttpUrl, regex constants, validateServings, validateTime, validateSteps +├── responseFormatters.ts # formatTags, formatIngredients, formatSteps +├── prismaSelects.ts # RECIPE_TAGS_SELECT, RECIPE_STEPS_SELECT, PROPOSAL_STEPS_SELECT, PROPOSAL_INGREDIENTS_SELECT ├── db.ts # Prisma client singleton └── validateEnv.ts # envalid env vars +jobs/ +├── notificationCleanup.ts # Cron daily cleanup read notifications > 30 days +└── imageCleanup.ts # Cron daily 3h30 cleanup orphan images (soft-deleted > 7 days) scripts/ └── createAdmin.ts # CLI creation SuperAdmin ``` ### Tests backend + ``` __tests__/ ├── setup/ @@ -103,10 +141,13 @@ __tests__/ │ ├── pagination.test.ts # Pagination utils │ ├── validation.test.ts # Validation utils & constants │ ├── responseFormatters.test.ts # Response formatters +│ ├── storageService.test.ts # Storage service (mock S3) +│ ├── recipeImportService.test.ts # Recipe import (URL validation, SSRF, JSON-LD parsing, ingredient parsing) │ └── middleware/ │ ├── auth.test.ts # requireAuth │ ├── requireSuperAdmin.test.ts # requireSuperAdmin, requireAdminSession -│ └── security.test.ts # requireHttps, rate limiters +│ ├── security.test.ts # requireHttps, rate limiters +│ └── csrf.test.ts # CSRF protection └── integration/ ├── websocket.test.ts ├── activity.test.ts @@ -116,18 +157,31 @@ __tests__/ ├── ingredients.test.ts ├── communities.test.ts ├── communityRecipes.test.ts + ├── communityTags.test.ts ├── invitations.test.ts ├── members.test.ts ├── adminAuth.test.ts ├── adminTags.test.ts ├── adminIngredients.test.ts + ├── adminUnits.test.ts + ├── adminRecipes.test.ts ├── adminFeatures.test.ts ├── adminCommunities.test.ts ├── adminDashboard.test.ts ├── adminActivity.test.ts ├── proposals.test.ts ├── share.test.ts - └── variants.test.ts + ├── variants.test.ts + ├── notificationService.test.ts + ├── notifications.test.ts + ├── tagPreferences.test.ts + ├── tagSuggestions.test.ts + ├── notificationCleanup.test.ts + ├── recipeImport.test.ts # Recipe import endpoint (auth, validation, SSRF) + ├── recipeImage.test.ts # Recipe image upload endpoints + ├── communityImage.test.ts # Community image upload endpoints + ├── imageCleanup.test.ts # Image cleanup cron job + └── users.test.ts # User profile update ``` --- @@ -135,6 +189,7 @@ __tests__/ ## Frontend (frontend/src/) ### Pages + ``` pages/ ├── HomePage.tsx # Accueil (redirect vers dashboard si connecte) @@ -147,6 +202,7 @@ pages/ ├── CommunityDetailPage.tsx # Detail communaute (icones + side panel) ├── CommunityEditPage.tsx # Edition communaute (fallback route, edit inline via SidePanel) ├── InvitationsPage.tsx # Invitations recues +├── NotificationsPage.tsx # Page notifications (filtres, pagination, groupement) ├── ProfilePage.tsx # Profil utilisateur (edit username/email/password) ├── SignUpPage.tsx # Inscription ├── PrivacyPage.tsx # Politique confidentialite @@ -155,13 +211,15 @@ pages/ ├── AdminLoginPage.tsx # Login admin 2FA ├── AdminDashboardPage.tsx # Dashboard admin (stats) ├── AdminTagsPage.tsx # CRUD + merge tags - ├── AdminIngredientsPage.tsx # CRUD + merge ingredients + ├── AdminIngredientsPage.tsx # CRUD + merge + approve/reject ingredients + ├── AdminUnitsPage.tsx # CRUD units (category filter, sortOrder) ├── AdminFeaturesPage.tsx # CRUD features (code, name, isDefault) ├── AdminCommunitiesPage.tsx # Liste, detail, delete, grant/revoke features └── AdminActivityPage.tsx # Logs activite admin paginee ``` ### Components + ``` components/ ├── Layout/ @@ -169,14 +227,15 @@ components/ │ └── Sidebar.tsx # Sidebar navigation communautes ├── Navbar/ │ ├── NavBar.tsx # Barre navigation -│ ├── NotificationDropdown.tsx # Dropdown notifications (invitations) +│ ├── NotificationDropdown.tsx # Dropdown notifications (5 categories, grouping, auto-mark) │ ├── NavBarLoggedInView/ # Nav connecte (icone user + dropdown menu) │ └── NavBarLoggedOutView/ # Nav deconnecte ├── 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 @@ -196,15 +255,33 @@ 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) +│ ├── TimeBadges.tsx # Badges temps prep/cuisson/repos/total (Phase 13) +│ ├── ServingsSelector.tsx # Selecteur portions -/input/+ (Phase 13) +│ ├── 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 # Notification preferences (5 categories, per-community overrides) ├── form/ -│ ├── TagSelector.tsx # Multi-select tags (debounce, create on-the-fly) -│ ├── IngredientSelector.tsx # Selecteur ingredients -│ └── IngredientList.tsx # Liste ingredients dynamique +│ ├── SearchSelector.tsx # Composant generique recherche + selection (debounce, create on-the-fly) +│ ├── TagSelector.tsx # Multi-select tags (utilise SearchSelector) +│ ├── IngredientSelector.tsx # Selecteur ingredients (utilise SearchSelector) +│ ├── IngredientList.tsx # Liste ingredients dynamique (autocomplete, units, PENDING badge) +│ ├── UnitSelector.tsx # Dropdown unites groupee par categorie +│ └── StepEditor.tsx # Editeur etapes numerotees reorder/delete (Phase 13) +├── mobile/ +│ ├── BottomTabBar.tsx # Navigation onglets bas mobile (4 tabs, badge notifs, keyboard hide) +│ ├── BottomSheet.tsx # Sheet reutilisable (overlay, slide-up, focus trap, Escape) +│ └── ActionSheet.tsx # Variante BottomSheet pour listes d'actions contextuelles ├── admin/ │ ├── AdminLayout.tsx # Layout admin (sidebar + header + outlet) │ └── AdminProtectedRoute.tsx # Guard admin +├── ImageUpload.tsx # Upload image existante (drag&drop, preview, presigned URL) +├── ImagePicker.tsx # Selection image pour creation (preview, processImage) +├── ImportRecipeModal.tsx # Modal import recette (texte brut ou URL) ├── AddEditRecipeDialog.tsx # Dialog creation/edition ├── ErrorBoundary.tsx # Error boundary React (crash → fallback UI) ├── LoginModal.tsx # Modal login @@ -214,6 +291,7 @@ components/ ``` ### Contexts & Network + ``` contexts/ ├── AuthContext.tsx # Auth user (session, login/logout) @@ -223,23 +301,36 @@ contexts/ network/ └── api.ts # Client Axios, fonctions API + +services/ +└── recipeParser.ts # parseRecipeText() — parsing texte brut (ingredients, etapes, metadonnees) ``` ### Models & Types + ``` 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 +├── preferences.ts # TagPreference types +├── notification.ts # Notification, NotificationCategory, preferences types └── admin.ts # AdminUser types ``` ### Autres frontend + ``` -App.tsx # Routes React Router +App.tsx # Routes React Router (simplifie, delegue a routes/) main.tsx # Entry point React +routes/ +├── userRoutes.tsx # Routes utilisateur (public + protegees) +└── adminRoutes.tsx # Routes admin (protegees) hooks/ +├── useAsyncData.ts # Generic async data fetching (loading/error/data/refetch) +├── useImageUpload.ts # Upload image (state + API wiring recipe/community) ├── useClickOutside.ts # Detect clicks outside a ref element ├── useDebouncedEffect.ts # Effect with configurable delay ├── useConfirm.tsx # Confirmation dialog hook (promise-based) @@ -247,9 +338,16 @@ hooks/ ├── useRecipeActions.ts # Recipe CRUD actions ├── useSocketEvent.ts # Subscribe/unsubscribe to socket events ├── useCommunityRoom.ts # Join/leave community socket room -└── useNotificationToasts.ts # Toast notifications from socket events +├── useNotificationToasts.ts # Toast notifications from notification:new socket event +├── useNotifications.ts # Paginated notifications with filters and mark as read +├── useUnreadCount.ts # Real-time unread count (REST init + WebSocket updates) +├── useIsMobile.ts # Detect viewport < 768px via matchMedia (mobile rework) +└── useKeyboardVisible.ts # Detect virtual keyboard via visualViewport API (mobile rework) utils/ ├── format.Date.ts # formatDate, formatDateShort +├── formatDuration.ts # formatDuration: 45→"45 min", 90→"1h30" (Phase 13) +├── scaleQuantity.ts # scaleQuantity: proportionnel arrondi 2 dec (Phase 13) +├── imageUtils.ts # processImage: validate, resize, convert WebP (Phase 15) └── communityEvents.ts # Event bus for community refresh errors/ # Classes erreur assets/ # Assets statiques @@ -257,6 +355,7 @@ styles/ # CSS ``` ### Tests frontend + ``` __tests__/ ├── setup/ @@ -277,9 +376,13 @@ __tests__/ │ ├── useSocketEvent.test.ts │ ├── useCommunityRoom.test.ts │ ├── useNotificationToasts.test.ts - │ └── usePaginatedList.test.ts + │ ├── usePaginatedList.test.ts + │ ├── useIsMobile.test.ts # Mobile rework (4 tests) + │ └── useKeyboardVisible.test.ts # Mobile rework (5 tests) ├── utils/ │ ├── formatDate.test.ts + │ ├── formatDuration.test.ts # Phase 13 (4 tests) + │ ├── scaleQuantity.test.ts # Phase 13 (8 tests) │ └── communityEvents.test.ts ├── pages/ │ ├── CommunitiesPage.test.tsx @@ -287,19 +390,28 @@ __tests__/ │ ├── DashboardPage.test.tsx │ ├── HomePage.test.tsx │ ├── NotFoundPage.test.tsx - │ ├── ProfilePage.test.tsx + │ ├── ProfilePage.test.tsx # incl. Mobile rework Phase 2 (3 tests) │ ├── RecipesPage.test.tsx │ ├── RecipeFormPage.test.tsx + │ ├── RecipeDetailPage.mobile.test.tsx # Mobile rework Phase 3 (4 tests) │ ├── SignUpPage.test.tsx │ └── admin/ │ ├── AdminLoginPage.test.tsx │ ├── AdminDashboardPage.test.tsx │ ├── AdminTagsPage.test.tsx │ ├── AdminIngredientsPage.test.tsx + │ ├── AdminUnitsPage.test.tsx │ ├── AdminFeaturesPage.test.tsx │ ├── AdminCommunitiesPage.test.tsx │ └── AdminActivityPage.test.tsx + ├── services/ + │ └── recipeParser.test.ts # Parsing texte brut recette (65 tests) └── components/ + ├── profile/ + │ ├── TagPreferencesSection.test.tsx + │ └── NotificationPreferencesSection.test.tsx + ├── communities/ + │ └── CommunityTagsList.test.tsx ├── Layout/ │ ├── MainLayout.test.tsx │ └── Sidebar.test.tsx @@ -308,20 +420,39 @@ __tests__/ │ └── AdminProtectedRoute.test.tsx ├── recipes/ │ ├── RecipeCard.test.tsx - │ └── RecipeFilters.test.tsx + │ ├── RecipeFilters.test.tsx + │ ├── RecipeFilters.mobile.test.tsx # Mobile rework Phase 3 (8 tests) + │ ├── TimeBadges.test.tsx # Phase 13 (7 tests) + │ ├── ServingsSelector.test.tsx # Phase 13 (6 tests) + │ ├── SuggestTagModal.test.tsx + │ ├── TagBadge.test.tsx + │ └── TagSuggestionsList.test.tsx + ├── proposals/ + │ ├── ProposeModificationModal.test.tsx + │ ├── ProposeModificationModal.mobile.test.tsx # Mobile rework Phase 4 (3 tests) + │ └── ProposalsList.test.tsx ├── form/ │ ├── TagSelector.test.tsx - │ └── IngredientList.test.tsx + │ ├── IngredientList.test.tsx + │ ├── IngredientList.mobile.test.tsx # Mobile rework Phase 4 (3 tests) + │ ├── UnitSelector.test.tsx + │ ├── StepEditor.test.tsx # Phase 13 (8 tests) + │ └── StepEditor.mobile.test.tsx # Mobile rework Phase 4 (3 tests) ├── ActivityFeed.test.tsx ├── ErrorBoundary.test.tsx ├── InviteCard.test.tsx ├── InviteUserModal.test.tsx ├── LoginModal.test.tsx ├── MembersList.test.tsx + ├── MembersList.mobile.test.tsx # Mobile rework Phase 3 (4 tests) ├── Modal.test.tsx ├── NavBar.test.tsx ├── ProtectedRoute.test.tsx - └── ShareRecipeModal.test.tsx + ├── ShareRecipeModal.test.tsx + └── mobile/ + ├── BottomTabBar.test.tsx # Mobile rework (8 tests) + ├── BottomSheet.test.tsx # Mobile rework (9 tests) + └── ActionSheet.test.tsx # Mobile rework (7 tests) ``` --- diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index d437684b..52cd9eeb 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -1,68 +1,27 @@ # 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). +Phases 0 a 17 terminees (tags rework, ingredients rework, notifications, recipe v2, input validation, photo upload, recipe import, audit refactorisation, fix HelloFresh). + +## Feature terminee : Mobile Rework (Phases 1-5) -## Ce qui reste a faire +- **Spec** : `docs/features/mobile-rework/SPEC_MOBILE_REWORK.md` +- **Roadmap** : `docs/features/mobile-rework/ROADMAP.md` -### Checklist validation MVP (non cochees) +## Feature terminee : Recipe Import -- [ ] 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) +- **Spec** : `docs/features/recipe-import/SPEC_RECIPE_IMPORT.md` +- **Roadmap** : `docs/features/recipe-import/ROADMAP.md` -### Maintenance technique +## Feature planifiee : E2E Testing -- [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/e2e-testing/SPEC_E2E_TESTING.md` +- **Roadmap** : `docs/features/e2e-testing/ROADMAP.md` -## Tests actuels +## Idees futures -| Suite | Fichiers | Tests | -|-------|----------|-------| -| Frontend | 47 | 273 | -| Backend | 27 | 390 | -| **Total** | **74** | **663** | +Voir `docs/0 - brainstorming futur.md` ## Resume de reprise diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index 9cc4311f..405475f9 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -24,6 +24,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier ## Configuration ### Backend (backend/vitest.config.ts) + - Framework: Vitest + Supertest - Environment: Node.js - Setup: `__tests__/setup/globalSetup.ts` @@ -33,6 +34,7 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier - Helpers: `src/__tests__/setup/testHelpers.ts` ### Frontend (frontend/vitest.config.ts) + - Framework: Vitest + Testing Library + MSW - Environment: jsdom - Setup: `__tests__/setup/vitestSetup.ts` @@ -40,92 +42,133 @@ 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) - -### Backend Integration (19 fichiers, ~339 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 | -| proposals.test.ts | Propositions modifications | 31 | -| variants.test.ts | Liste variantes recettes | 10 | -| tags.test.ts | Autocomplete tags | 5 | -| 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 | -| 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 | - -### Backend Unit (7 fichiers, ~51 tests) - -| Fichier | Module | Tests | -|---------|--------|-------| -| eventEmitter.test.ts | Event emitter | 3 | -| pagination.test.ts | parsePagination, buildPaginationMeta | 14 | -| validation.test.ts | normalizeNames, isValidHttpUrl, constants | 17 | -| responseFormatters.test.ts | formatTags, formatIngredients | 5 | -| middleware/auth.test.ts | requireAuth | 4 | -| middleware/requireSuperAdmin.test.ts | requireSuperAdmin, requireAdminSession | 6 | -| middleware/security.test.ts | requireHttps, rateLimiters, helmet | 5 | - -### Frontend Unit (47 fichiers, ~273 tests) - -| Fichier | Composant | Tests | -|---------|-----------|-------| -| AuthContext.test.tsx | Context auth user | 6 | -| AdminAuthContext.test.tsx | Context auth admin | 7 | -| LoginModal.test.tsx | Modal login | 6 | -| Modal.test.tsx | Composant modal | 4 | -| SignUpPage.test.tsx | Page inscription | 6 | -| ProtectedRoute.test.tsx | Guard user | 5 | -| NavBar.test.tsx | Navigation | 4 | -| AdminProtectedRoute.test.tsx | Guard admin | 4 | -| 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/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 | -| pages/admin/AdminActivityPage.test.tsx | Page activity admin | 5 | -| RecipeCard.test.tsx | Carte recette | 8 | -| RecipeFilters.test.tsx | Filtres recettes | 8 | -| TagSelector.test.tsx | Selecteur tags | 6 | -| IngredientList.test.tsx | Liste ingredients | 6 | -| RecipesPage.test.tsx | Page recettes | 3 | -| MainLayout.test.tsx | Layout principal | 6 | -| Sidebar.test.tsx | Sidebar navigation | 10 | -| HomePage.test.tsx | Page accueil | 6 | -| CommunitiesPage.test.tsx | Page liste communautes | 7 | -| CommunityDetailPage.test.tsx | Page detail communaute | 11 | -| InviteCard.test.tsx | Carte invitation | 5 | -| MembersList.test.tsx | Liste membres | 6 | -| InviteUserModal.test.tsx | Modal invitation | 5 | -| ActivityFeed.test.tsx | Feed activite | 8 | -| ShareRecipeModal.test.tsx | Modal partage recette | 7 | -| 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 | 5 | -| hooks/usePaginatedList.test.ts | Hook paginated list | 6 | -| utils/formatDate.test.ts | Format date utils | 5 | -| utils/communityEvents.test.ts | Community events bus | 2 | -| pages/DashboardPage.test.tsx | Page dashboard user | 8 | -| pages/ProfilePage.test.tsx | Page profil user | 8 | -| pages/NotFoundPage.test.tsx | Page 404 | 2 | -| pages/RecipeFormPage.test.tsx | Page formulaire recette | 2 | +## Inventaire des tests (~802 backend + ~469 frontend = ~1271 tests) + +### Couverture (seuils CI) + +- Backend : 91.81% statements, 83.31% branches (seuil: 80%/70%) +- Frontend : 66.44% statements, 76.31% branches (seuil: 50%/50%) + +### Backend Integration (33 fichiers, ~665 tests) + +| Fichier | Module | Tests | +| --------------------------- | ----------------------------------------------------------------------------- | ----- | +| activity.test.ts | Activity feed (community + personal) | 15 | +| auth.test.ts | User signup/login/logout/me | 23 | +| recipes.test.ts | CRUD recettes (perso, steps/servings/times + validation) | 52 | +| communityRecipes.test.ts | CRUD recettes communautaires (+ tags scope-aware) | 35 | +| proposals.test.ts | Propositions modifications (steps/servings/times + proposedIngredients) | 45 | +| variants.test.ts | Liste variantes recettes (+ servings/times) | 11 | +| tags.test.ts | Autocomplete tags (scope-aware) | 9 | +| ingredients.test.ts | Autocomplete ingredients + suggested-unit | 5 | +| communities.test.ts | CRUD communautes | 27 | +| invitations.test.ts | Workflow invitations | 36 | +| members.test.ts | Membres: list, promote, kick, orphan handling | 26 | +| adminAuth.test.ts | Auth 2FA admin | 15 | +| adminTags.test.ts | CRUD tags admin (+ scope filter) | 17 | +| adminIngredients.test.ts | CRUD + approve/reject/merge ingredients admin + notifications | 35 | +| adminUnits.test.ts | CRUD units admin + user endpoint | 28 | +| adminFeatures.test.ts | Features grant/revoke | 13 | +| adminRecipes.test.ts | Recipes admin (tag recipes, detail, update, delete) | 21 | +| adminCommunities.test.ts | Communities admin | 12 | +| adminDashboard.test.ts | Stats dashboard | 4 | +| adminActivity.test.ts | Logs activite | 4 | +| share.test.ts | Partage inter-communautes + publish + sync steps/servings + fork tags | 31 | +| communityTags.test.ts | CRUD + approve/reject tags communaute + permissions (T13) | 34 | +| tagSuggestions.test.ts | Suggestions de tags | 29 | +| tagPreferences.test.ts | Tag visibility + notification preferences + getModeratorIds | 23 | +| notificationService.test.ts | Notification service (create, broadcast, preferences, templates) | 30 | +| notifications.test.ts | Notifications API (CRUD, grouping, batch, preferences) | 28 | +| websocket.test.ts | WebSocket (auth, rooms, notification:new, notification:count, persistence) | 8 | +| notificationCleanup.test.ts | Notification cleanup job (retention, batch, edge cases) | 6 | +| recipeImage.test.ts | Recipe image upload endpoints (presigned URL, confirm, delete, permissions) | 12 | +| communityImage.test.ts | Community image upload endpoints (presigned URL, confirm, delete, role-based) | 13 | +| imageCleanup.test.ts | Image cleanup cron (recipes, communities, retention, error resilience) | 8 | +| recipeImport.test.ts | Recipe import endpoint (auth, validation, SSRF) | 6 | +| users.test.ts | User profile update (username, email, password) | 4 | + +### Backend Unit (10 fichiers, ~137 tests) + +| Fichier | Module | Tests | +| ------------------------------------ | ---------------------------------------------------------------------------------------- | ----- | +| eventEmitter.test.ts | Event emitter | 3 | +| pagination.test.ts | parsePagination, buildPaginationMeta | 14 | +| validation.test.ts | normalizeNames, isValidHttpUrl, constants, validateServings, validateTime, validateSteps | 37 | +| responseFormatters.test.ts | formatTags, formatIngredients, formatSteps | 8 | +| middleware/auth.test.ts | requireAuth | 4 | +| middleware/requireSuperAdmin.test.ts | requireSuperAdmin, requireAdminSession | 6 | +| middleware/security.test.ts | requireHttps, rateLimiters, helmet | 5 | +| middleware/csrf.test.ts | CSRF protection middleware | 2 | +| storageService.test.ts | S3/MinIO service (presigned URL, head, delete, validate) | 10 | +| recipeImportService.test.ts | Recipe import (URL validation, SSRF, JSON-LD parsing, ingredient parsing) | 48 | + +### Frontend Unit (63 fichiers, ~469 tests) + +| Fichier | Composant | Tests | +| ----------------------------------------------- | --------------------------------------------------------------------- | ----- | +| AuthContext.test.tsx | Context auth user | 6 | +| AdminAuthContext.test.tsx | Context auth admin | 7 | +| ThemeContext.test.tsx | Context theme (forest/winter) | 7 | +| SocketContext.test.tsx | Context Socket.IO | 2 | +| LoginModal.test.tsx | Modal login | 6 | +| Modal.test.tsx | Composant modal | 4 | +| ErrorBoundary.test.tsx | Error boundary React | 2 | +| SignUpPage.test.tsx | Page inscription | 7 | +| ProtectedRoute.test.tsx | Guard user | 5 | +| NavBar.test.tsx | Navigation | 6 | +| AdminProtectedRoute.test.tsx | Guard admin | 4 | +| 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 (+ scope filter) | 12 | +| pages/admin/AdminIngredientsPage.test.tsx | Page ingredients admin (status, approve/reject) | 17 | +| pages/admin/AdminUnitsPage.test.tsx | Page units admin (CRUD, category filter) | 10 | +| pages/admin/AdminFeaturesPage.test.tsx | Page features admin | 6 | +| pages/admin/AdminCommunitiesPage.test.tsx | Page communities admin | 8 | +| pages/admin/AdminActivityPage.test.tsx | Page activity admin | 5 | +| RecipeCard.test.tsx | Carte recette (+ image) | 12 | +| RecipeFilters.test.tsx | Filtres recettes | 9 | +| TagSelector.test.tsx | Selecteur tags | 9 | +| TagBadge.test.tsx | Badge tag avec style pending/approved | 10 | +| IngredientList.test.tsx | Liste ingredients (autocomplete, units, PENDING) | 10 | +| UnitSelector.test.tsx | Selecteur unites par categorie | 8 | +| form/StepEditor.test.tsx | Editeur etapes numerotees reorder/delete | 9 | +| RecipesPage.test.tsx | Page recettes | 3 | +| MainLayout.test.tsx | Layout principal | 6 | +| Sidebar.test.tsx | Sidebar navigation | 10 | +| HomePage.test.tsx | Page accueil | 6 | +| 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 | +| ShareRecipeModal.test.tsx | Modal partage recette | 7 | +| recipes/SuggestTagModal.test.tsx | Modal suggestion tag | 6 | +| recipes/TagSuggestionsList.test.tsx | Liste suggestions tags owner | 5 | +| recipes/TimeBadges.test.tsx | Badges temps prep/cuisson/repos/total | 7 | +| recipes/ServingsSelector.test.tsx | Selecteur portions -/input/+ | 6 | +| profile/TagPreferencesSection.test.tsx | Toggle tag visibility per community | 5 | +| profile/NotificationPreferencesSection.test.tsx | Notification preferences (5 categories, global toggles, error states) | 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 (notification:new event) | 5 | +| hooks/usePaginatedList.test.ts | Hook paginated list | 6 | +| utils/formatDate.test.ts | Format date utils | 5 | +| utils/formatDuration.test.ts | Format duree min → "1h30" | 4 | +| utils/scaleQuantity.test.ts | Scale proportionnel arrondi 2 dec | 8 | +| utils/communityEvents.test.ts | Community events bus | 2 | +| services/recipeParser.test.ts | Parsing texte brut recette (ingredients, etapes, metadonnees) | 65 | +| pages/DashboardPage.test.tsx | Page dashboard user | 8 | +| pages/ProfilePage.test.tsx | Page profil user | 8 | +| pages/NotFoundPage.test.tsx | Page 404 | 2 | +| pages/RecipeFormPage.test.tsx | Page formulaire recette | 5 | +| proposals/ProposeModificationModal.test.tsx | Modal proposition avec ingredients | 9 | +| proposals/ProposalsList.test.tsx | Liste propositions (diff ingredients, accept/reject) | 6 | ## Couverture cible @@ -136,12 +179,12 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier ```typescript // Backend: backend/src/__tests__/integration/example.test.ts -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import request from 'supertest'; -import { app } from '../../app'; -import { testPrisma, createTestUser, cleanupTestData } from '../setup/testHelpers'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { app } from "../../app"; +import { testPrisma, createTestUser, cleanupTestData } from "../setup/testHelpers"; // Frontend: frontend/src/__tests__/unit/Example.test.tsx -import { render, screen } from '../setup/testUtils'; -import { Example } from '../../components/Example'; +import { render, screen } from "../setup/testUtils"; +import { Example } from "../../components/Example"; ``` diff --git a/.claude/refactoring/BACKEND.md b/.claude/refactoring/BACKEND.md deleted file mode 100644 index 0a07c420..00000000 --- a/.claude/refactoring/BACKEND.md +++ /dev/null @@ -1,281 +0,0 @@ -# Backend Refactoring - -## PRIORITE 1 - Fondations (DRY, Services, Securite) - -### B1.1 - Extraire constantes de validation partagees - -**Probleme**: Regex et constantes dupliquees entre `auth.ts` et `users.ts` -**Action**: Creer `src/utils/validation.ts` avec les constantes partagees -**Fichiers concernes**: -- `src/controllers/auth.ts` (lignes 6-10) - EMAIL_REGEX, USERNAME_REGEX, MIN_USERNAME_LENGTH -- `src/controllers/users.ts` (lignes 6-9) - memes constantes copiees -- `src/controllers/communities.ts` (lignes 8-12) - objet VALIDATION -**Resultat attendu**: Un seul fichier source pour toutes les regles de validation -**Status**: TODO - ---- - -### B1.2 - Extraire utilitaire de pagination - -**Probleme**: Parsing limit/offset repete 6+ fois avec la meme logique -**Action**: Creer `src/utils/pagination.ts` avec `parsePagination(query)` et `formatPaginatedResponse(data, total, limit, offset)` -**Fichiers concernes**: -- `src/controllers/recipes.ts` (lignes 16-17) -- `src/controllers/communityRecipes.ts` (lignes 229-233) -- `src/controllers/recipeVariants.ts` (lignes 24-25) -- `src/controllers/proposals.ts` (lignes 148-152) -- `src/controllers/activity.ts` (lignes 22-23, 100) -- `src/controllers/tags.ts` (ligne 13) -- `src/controllers/ingredients.ts` (ligne 13) -**Pattern a extraire**: -```typescript -const limit = Math.min(Math.max(parseInt(req.query.limit || "20", 10), 1), 100); -const offset = Math.max(parseInt(req.query.offset || "0", 10), 0); -``` -**Resultat attendu**: Appel unique `const { limit, offset } = parsePagination(req.query)` -**Status**: TODO - ---- - -### B1.3 - Creer service de verification membership - -**Probleme**: Verification membership copiee 15+ fois dans les controllers -**Action**: Creer `src/services/membershipService.ts` avec : -- `verifyMembership(userId, communityId)` -> retourne membership ou throw 403 -- `verifyMembershipRole(userId, communityId, role)` -> idem avec check role -- `verifyRecipeAccess(userId, recipe)` -> gere personal vs community -**Fichiers concernes**: -- `src/controllers/recipes.ts` (lignes 196-215, 444-454, 698-708) -- `src/controllers/recipeVariants.ts` (lignes 47-66) -- `src/controllers/proposals.ts` (lignes 62-72, 182-192, 301-312) -- `src/controllers/recipeShare.ts` (lignes 93-107) -- `src/controllers/invites.ts` (ligne 77) -- `src/controllers/members.ts` (ligne 79) -**Pattern a extraire**: -```typescript -const membership = await prisma.userCommunity.findFirst({ - where: { userId, communityId, deletedAt: null } -}); -if (!membership) throw createHttpError(403, "COMMUNITY_001: Not a member"); -``` -**Status**: TODO - ---- - -### B1.4 - Extraire Prisma select constants - -**Probleme**: Memes objets `select` / `include` Prisma repetes dans 4+ controllers -**Action**: Creer `src/utils/prismaSelects.ts` avec les selects reutilisables -**Fichiers concernes**: -- `src/controllers/recipes.ts` - select tags/ingredients (multiple handlers) -- `src/controllers/communityRecipes.ts` - idem -- `src/controllers/recipeVariants.ts` - idem -- `src/controllers/recipeShare.ts` - idem -**Objets a extraire**: -- `RECIPE_TAGS_SELECT` (tag id + name) -- `RECIPE_INGREDIENTS_SELECT` (id, quantity, order, ingredient name) -- `RECIPE_WITH_RELATIONS_SELECT` (combinaison complete) -**Status**: TODO - ---- - -### B1.5 - Extraire service de formatage response recipe - -**Probleme**: Mapping `recipe -> response` duplique dans tous les controllers recipe -**Action**: Creer `src/utils/responseFormatters.ts` avec `formatRecipeResponse(recipe)`, `formatRecipeListItem(recipe)` -**Fichiers concernes**: -- `src/controllers/recipes.ts` (lignes 98-105, 217-240, 373-389, 648-664) -- `src/controllers/communityRecipes.ts` -- `src/controllers/recipeVariants.ts` -- `src/controllers/recipeShare.ts` -**Pattern a extraire**: -```typescript -tags: recipe.tags.map((rt) => rt.tag) -ingredients: recipe.ingredients.map((ri) => ({ - id: ri.id, name: ri.ingredient.name, ingredientId: ri.ingredient.id, - quantity: ri.quantity, order: ri.order -})) -``` -**Status**: TODO - ---- - -### B1.6 - Extraire tag/ingredient normalization - -**Probleme**: Normalisation des noms (trim + lowercase + dedupe) repetee 3+ fois -**Action**: Ajouter `normalizeNames(items: string[]): string[]` dans `src/utils/validation.ts` -**Fichiers concernes**: -- `src/controllers/recipes.ts` (lignes 287, 472) -- `src/controllers/communityRecipes.ts` (ligne 67) -- `src/controllers/recipeShare.ts` -**Pattern a extraire**: -```typescript -[...new Set(items.map((t) => t.trim().toLowerCase()).filter(Boolean))] -``` -**Status**: TODO - ---- - -### B1.7 - Fusionner controllers tags et ingredients - -**Probleme**: `tags.ts` et `ingredients.ts` sont quasi-identiques (60 lignes chacun, 90% copie-colle) -**Action**: Creer un controller generique ou factoriser la logique commune -**Fichiers concernes**: -- `src/controllers/tags.ts` (60 lignes) -- `src/controllers/ingredients.ts` (60 lignes) -**Status**: TODO - ---- - -### B1.8 - Fix securite : autorisation cancelInvite - -**Probleme**: N'importe quel membre peut annuler l'invite d'un autre membre -**Action**: Ajouter verification `userId === invite.inviterId || role === "MODERATOR"` -**Fichier**: `src/controllers/invites.ts` (lignes 215-295) -**Status**: TODO - ---- - -### B1.9 - Fix securite : rate limiting auth user - -**Probleme**: Pas de rate limiting sur signup/login user (admin en a) -**Action**: Ajouter `authRateLimiter` sur les routes auth user -**Fichier**: `src/routes/auth.ts` -**Status**: TODO - ---- - -### B1.10 - Fix securite : validation imageUrl - -**Probleme**: `imageUrl` accepte n'importe quelle string (risque XSS via `javascript:` URLs) -**Action**: Valider que l'URL commence par `http://` ou `https://` -**Fichiers concernes**: -- `src/controllers/recipes.ts` -- `src/controllers/communityRecipes.ts` (ligne 25) -**Status**: TODO - ---- - -### B1.11 - Supprimer dead code - -**Probleme**: Fonction exportee mais jamais importee -**Action**: Supprimer `handleOrphanedRecipesWithTransaction()` de `src/services/orphanHandling.ts` (lignes 121-128) -**Status**: TODO - ---- - -## PRIORITE 2 - Refactoring controllers (extraire business logic) - -### B2.1 - Creer recipeService.ts - -**Probleme**: `recipes.ts` (721 lignes) contient toute la logique metier -**Action**: Creer `src/services/recipeService.ts` avec : -- `createRecipe(data, userId)` - creation + tags/ingredients -- `updateRecipe(recipeId, data, userId)` - update + sync logic -- `deleteRecipe(recipeId, userId)` - soft delete -- `syncLinkedRecipes(recipe, updatedFields)` - logique de synchronisation bidirectionnelle (actuellement lignes 518-602) -**Fichier source**: `src/controllers/recipes.ts` -**Status**: TODO - ---- - -### B2.2 - Creer communityRecipeService.ts - -**Probleme**: Logique metier dans controller communityRecipes -**Action**: Extraire la logique de creation/listing de recettes communautaires -**Fichier source**: `src/controllers/communityRecipes.ts` (363 lignes) -**Status**: TODO - ---- - -### B2.3 - Creer proposalService.ts - -**Probleme**: Business logic complexe dans proposals controller -**Action**: Extraire accept/reject/create proposal logic -**Fichier source**: `src/controllers/proposals.ts` (657 lignes) -**Status**: TODO - ---- - -### B2.4 - Creer shareService.ts - -**Probleme**: Chain traversal logic complexe dans controller -**Action**: Extraire logique de share + parent chain -**Fichier source**: `src/controllers/recipeShare.ts` (575 lignes) -**Status**: TODO - ---- - -### B2.5 - Fix N+1 queries dans orphanHandling - -**Probleme**: Boucle avec 3 requetes DB par proposal -**Action**: Utiliser des operations batch (`createMany`, `updateMany`) au lieu de boucles individuelles -**Fichier**: `src/services/orphanHandling.ts` (lignes 67-107) -**Status**: TODO - ---- - -## PRIORITE 3 - Type safety & qualite - -### B3.1 - Remplacer `any` par types Prisma - -**Probleme**: 4 `whereClause: any` qui perdent le type checking -**Action**: Utiliser `Prisma.XxxWhereInput` pour chaque cas -**Fichiers concernes**: -- `src/controllers/recipes.ts` (ligne 26) -> `Prisma.RecipeWhereInput` -- `src/controllers/communityRecipes.ts` (ligne 248) -> `Prisma.RecipeWhereInput` -- `src/controllers/recipeVariants.ts` (ligne 70) -> `Prisma.RecipeWhereInput` -- `src/controllers/proposals.ts` (ligne 196) -> `Prisma.RecipeUpdateProposalWhereInput` -**Status**: TODO - ---- - -### B3.2 - Remplacer non-null assertions par guards - -**Probleme**: 14 `req.session.adminId!` dans admin controllers -**Action**: Remplacer par `const adminId = req.session.adminId; if (!adminId) return next(...)` -**Fichiers concernes**: -- `src/admin/controllers/featuresController.ts` (lignes 43, 96, 148, 208) -- `src/admin/controllers/tagsController.ts` (lignes 42, 86, 137, 171) -- `src/admin/controllers/ingredientsController.ts` (lignes 42, 86, 137, 170) -- `src/admin/controllers/communitiesController.ts` (lignes 128, 168) -**Status**: TODO - ---- - -### B3.3 - Typer RequestHandler avec generics - -**Probleme**: `RequestHandler` sans type params = `any` pour params/body/query -**Action**: Ajouter les generics `RequestHandler` -**Fichiers concernes**: Tous les controllers (prioriser `members.ts`, `invites.ts`) -**Status**: TODO - ---- - -### B3.4 - Standardiser les codes erreur - -**Probleme**: Certains endpoints n'ont pas de code erreur (invites, users, activity) -**Action**: Ajouter les codes erreur manquants selon la convention existante -**Fichiers concernes**: -- `src/controllers/invites.ts` (ligne 47 : "One of email..." sans code) -- `src/controllers/users.ts` (ligne 53 : "User not found" sans code) -- `src/controllers/activity.ts` (aucun code erreur) -**Status**: TODO - ---- - -### B3.5 - Trim consistant sur tous les inputs - -**Probleme**: Certains controllers trim les inputs, d'autres non -**Action**: Ajouter `.trim()` sur tous les `req.body` string fields -**Fichiers concernes**: `invites.ts` (lignes 31-55), verifier les autres -**Status**: TODO - ---- - -### B3.6 - Pagination bounds dans admin controllers - -**Probleme**: Admin activity controller ne valide pas offset >= 0 -**Action**: Appliquer `parsePagination()` (cree en B1.2) aux routes admin aussi -**Fichier**: `src/admin/controllers/activityController.ts` (ligne 14) -**Status**: TODO diff --git a/.claude/refactoring/FRONTEND.md b/.claude/refactoring/FRONTEND.md deleted file mode 100644 index 11bd0e4e..00000000 --- a/.claude/refactoring/FRONTEND.md +++ /dev/null @@ -1,280 +0,0 @@ -# Frontend Refactoring - -## PRIORITE 1 - Hooks, utilitaires, dead code - -### F1.1 - Creer hook useClickOutside - -**Probleme**: Pattern click-outside duplique dans 5 composants -**Action**: Creer `src/hooks/useClickOutside.ts` -**Signature**: `useClickOutside(ref: RefObject, callback: () => void)` -**Fichiers concernes**: -- `src/components/form/IngredientList.tsx` (lignes 64-73) -- `src/components/form/TagSelector.tsx` (lignes 57-66) -- `src/components/form/IngredientSelector.tsx` (lignes 55-63) -- `src/components/proposals/VariantsDropdown.tsx` (lignes 35-43) -- `src/components/Navbar/NotificationDropdown.tsx` (lignes 44-52) -- `src/components/Navbar/NavBarLoggedInView/NavBarLoggedInView.tsx` (ligne 14) -**Status**: TODO - ---- - -### F1.2 - Creer hook useDebounce - -**Probleme**: Logique debounce (setTimeout/clearTimeout + ref) dupliquee 4+ fois -**Action**: Creer `src/hooks/useDebounce.ts` -**Signature**: `useDebouncedCallback(callback: Function, delay: number)` -**Fichiers concernes**: -- `src/components/form/TagSelector.tsx` (lignes 39-55) -- `src/components/form/IngredientList.tsx` (lignes 48-62) -- `src/components/RecipesPageLoggedInView.tsx` (filter changes) -- `src/components/invitations/InviteUserModal.tsx` (lignes 46-55) -**Status**: TODO - ---- - -### F1.3 - Creer utilitaire buildQueryString - -**Probleme**: Construction URLSearchParams repetee 7 fois dans api.ts -**Action**: Creer fonction utilitaire dans `src/network/api.ts` ou `src/utils/queryString.ts` -**Signature**: `buildQueryString(params: Record): string` -**Fichier concerne**: `src/network/api.ts` (lignes 70-78, 120-128, 232-240, 245-254, 260-269, 466-475, 477-486) -**Pattern a extraire**: -```typescript -const queryParams = new URLSearchParams(); -if (params.limit) queryParams.set("limit", params.limit.toString()); -if (params.offset) queryParams.set("offset", params.offset.toString()); -// ... etc -``` -**Status**: TODO - ---- - -### F1.4 - Centraliser le formatage de dates - -**Probleme**: Implementations locales de `formatDate` dans plusieurs composants au lieu d'utiliser `utils/format.Date.ts` -**Action**: Consolider dans `src/utils/format.Date.ts`, supprimer les implementations locales -**Fichiers concernes**: -- `src/utils/format.Date.ts` (utilitaire existant) -- `src/components/proposals/VariantsDropdown.tsx` (lignes 50-55 - implementation locale) -- `src/components/proposals/ProposalsList.tsx` (lignes 71-79 - implementation locale) -**Status**: TODO - ---- - -### F1.5 - Supprimer dead code - -**Probleme**: Composants legacy jamais importes -**Action**: Supprimer les fichiers suivants apres verification qu'ils ne sont importes nulle part -**Fichiers a supprimer**: -- `src/components/Recipe.tsx` - composant legacy non importe -- `src/components/AddEditRecipeDialog.tsx` - remplace par RecipeFormPage, non importe -- `src/styles/Recipe.module.css` - CSS module du composant supprime -- Verifier et supprimer si inutilises : `src/styles/RecipesPage.module.css`, `src/styles/App.module.css` -**Status**: TODO - ---- - -### F1.6 - Standardiser error handling dans api.ts - -**Probleme**: Mix de `handleApiError` generique + handlers inline custom (11+ variantes) -**Action**: Creer des error handler factories ou un wrapper unifie -**Fichier**: `src/network/api.ts` -**Cas identifies**: -- Lignes 80-81 : `handleApiError` generique -- Lignes 85-97 : custom handler avec 404/403 -- Lignes 149-156 : custom 400 -- Lignes 165-175 : custom 409 -- Lignes 193-202 : custom 403/400 -- Lignes 400-412, 414-426 : admin handlers -**Bug potentiel ligne 136**: `return response.data.community` au lieu de `response.data` (a verifier) -**Status**: TODO - ---- - -## PRIORITE 2 - Composants dupliques & reutilisables - -### F2.1 - Fusionner ShareRecipeModal et SharePersonalRecipeModal - -**Probleme**: 2 composants 90% identiques (180 lignes chacun) -**Action**: Creer un seul `ShareModal` avec prop `mode: "community" | "personal"` -**Differences a gerer**: -- Props : `currentCommunityId` (community only) -- Filtre : exclure communaute courante + deja partagees vs seulement deja partagees -- API call : `shareRecipe()` vs `publishToCommunities()` -- Textes mineurs -**Fichiers concernes**: -- `src/components/share/ShareRecipeModal.tsx` (189 lignes) -- `src/components/share/SharePersonalRecipeModal.tsx` (180 lignes) -**Status**: TODO - ---- - -### F2.2 - Factoriser RecipeCard et RecipeListRow - -**Probleme**: 2 composants avec 95% de logique identique, seul le layout differe -**Action**: Extraire la logique commune dans un hook `useRecipeItem` ou un composant HOC -**Logique commune**: -- Handler functions (delete, share, tag click) -- Tag rendering -- Creator display -- Action buttons -**Fichiers concernes**: -- `src/components/recipes/RecipeCard.tsx` (136 lignes) -- `src/components/recipes/RecipeListRow.tsx` (138 lignes) -**Status**: TODO - ---- - -### F2.3 - Creer composants UI reutilisables - -**Probleme**: Patterns UI repetes sans abstraction -**Action**: Creer les composants suivants dans `src/components/ui/` - -**a) ErrorAlert** (repete 5+ fois) -```tsx -{error &&
{error}
} -``` -Fichiers : LoginModal, ShareRecipeModal, SharePersonalRecipeModal, ProposeModificationModal, InviteUserModal - -**b) LoadingSpinner** (repete 20+ fois) -```tsx - -``` - -**c) EmptyState** (repete avec variations) -```tsx -

{message}

-``` -Fichiers : RecipesPageLoggedInView, CommunityRecipesList, ActivityFeed - -**d) StatusBadge** (logique badge repetee) -```tsx -{status} -``` -Fichiers : SentInvitesList, InviteCard - -**Status**: TODO - ---- - -### F2.4 - Extraire hook usePaginatedList - -**Probleme**: Logique pagination + load more + append quasi-identique dans 2 composants -**Action**: Creer `src/hooks/usePaginatedList.ts` -**Signature**: `usePaginatedList(fetchFn, filters) => { data, isLoading, error, loadMore, hasMore, reset }` -**Fichiers concernes**: -- `src/components/RecipesPageLoggedInView.tsx` (lignes 49-79) -- `src/components/communities/CommunityRecipesList.tsx` (lignes 47-77) -**Status**: TODO - ---- - -### F2.5 - Remplacer window.confirm par modal custom - -**Probleme**: `window.confirm()` utilise 5 fois - UX legacy, non stylisable -**Action**: Creer `src/components/ui/ConfirmDialog.tsx` ou hook `useConfirm()` -**Fichiers concernes**: -- `src/components/recipes/RecipeCard.tsx` (ligne 39) -- `src/components/recipes/RecipeListRow.tsx` (ligne 39) -- `src/components/communities/MembersList.tsx` (lignes 24, 40, 63) -**Status**: TODO - ---- - -### F2.6 - Factoriser invite response handlers - -**Probleme**: Logique accept/reject identique dans 2 composants -**Action**: Extraire dans un hook `useInviteActions()` -**Fichiers concernes**: -- `src/components/Navbar/NotificationDropdown.tsx` (lignes 63-91) -- `src/components/invitations/InviteCard.tsx` (lignes 17-40) -**Status**: TODO - ---- - -## PRIORITE 3 - Qualite & bonnes pratiques React - -### F3.1 - Remplacer anti-pattern key pour force re-render - -**Probleme**: `proposalsKey` incremente pour forcer le re-render de ProposalsList -**Action**: Utiliser un callback `onRefresh` ou un state partage pour trigger le refetch -**Fichier**: `src/pages/RecipeDetailPage.tsx` (lignes 23, 276) -**Status**: TODO - ---- - -### F3.2 - Remplacer window.dispatchEvent par state/context - -**Probleme**: `window.dispatchEvent(new Event("community-updated"))` pour sync inter-composants -**Action**: Utiliser un context ou un callback prop -**Fichier**: `src/pages/CommunityDetailPage.tsx` (ligne 100) -**Status**: TODO - ---- - -### F3.3 - Fix index comme key dans IngredientList - -**Probleme**: Utilise `index` comme key dans une liste modifiable (reorder/delete possible) -**Action**: Generer un ID stable pour chaque ingredient (uuid ou nanoid) -**Fichier**: `src/components/form/IngredientList.tsx` (ligne 159) -**Status**: TODO - ---- - -### F3.4 - Retirer console.error du code production - -**Probleme**: 15+ `console.error` dans le code de production -**Action**: Remplacer par un logger conditionnel ou supprimer -**Fichiers concernes**: -- `src/network/api.ts` (ligne 19) -- `src/contexts/AuthContext.tsx` (ligne 85) -- `src/pages/RecipeDetailPage.tsx` (lignes 39, 57, 77) -- `src/pages/RecipeFormPage.tsx` (lignes 60, 96) -- `src/components/RecipesPageLoggedInView.tsx` (lignes 73, 148) -- `src/components/Navbar/NavBarLoggedInView/NavBarLoggedInView.tsx` (ligne 26) -- `src/components/communities/CommunityRecipesList.tsx` (lignes 71, 110) -- `src/components/form/IngredientSelector.tsx` (ligne 30) -- `src/components/form/IngredientList.tsx` (ligne 41) -- `src/components/form/TagSelector.tsx` (ligne 32) -- `src/components/share/SharePersonalRecipeModal.tsx` (lignes 43, 75) -- `src/components/share/ShareRecipeModal.tsx` (lignes 46, 84) -- `src/components/ErrorBoundary.tsx` (ligne 22 - celui-ci est acceptable) -**Status**: TODO - ---- - -### F3.5 - Decomposer les pages trop grosses - -**Probleme**: Pages de 300+ lignes qui melangent state, logic et UI -**Action**: Extraire en sous-composants et hooks - -**a) RecipeDetailPage.tsx (317 lignes)** -- Extraire `useRecipeDetail(recipeId)` hook -- Extraire composant `RecipeModals` pour les 3 modals -**b) CommunityDetailPage.tsx (310 lignes)** -- Extraire logique edit inline dans un hook -- Extraire gestion panel width -**c) RecipesPageLoggedInView.tsx (289 lignes)** -- Sera grandement simplifie par F2.4 (usePaginatedList) - -**Status**: TODO - ---- - -### F3.6 - Consolider states lies en objets - -**Probleme**: Multiple `useState` pour des states conceptuellement lies -**Action**: Regrouper en objets ou utiliser `useReducer` pour les cas complexes -**Exemples**: -- `RecipeDetailPage.tsx` : 3 modals booleans -> `useState({ propose: false, share: false, publish: false })` -- `ProfilePage.tsx` : profileError/profileLoading/profileSuccess -> objet unique -- `NotificationDropdown.tsx` : actionLoading/actionError -> objet unique -**Status**: TODO - ---- - -### F3.7 - Ajouter aria-label manquants - -**Probleme**: Certains boutons icon-only n'ont pas d'aria-label -**Action**: Audit a11y rapide et ajout des labels manquants -**Status**: TODO diff --git a/.claude/refactoring/README.md b/.claude/refactoring/README.md deleted file mode 100644 index aa2d4bec..00000000 --- a/.claude/refactoring/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Refactoring Mission - ForestManager - -Audit realise le 2026-02-10 sur la branche `Developement`. -Ce dossier documente toutes les taches de refactoring identifiees. - -## Structure - -| Fichier | Contenu | -|---------|---------| -| `BACKEND.md` | Toutes les taches backend, classees par priorite | -| `FRONTEND.md` | Toutes les taches frontend, classees par priorite | -| `TRACKER.md` | Suivi global d'avancement (checklist) | - -## Regles - -- Cocher dans `TRACKER.md` apres chaque tache terminee -- Ne jamais supprimer une tache : la marquer DONE ou SKIPPED avec raison -- Lancer les tests apres chaque groupe de modifications (`npm test`) -- Commiter regulierement (1 commit par groupe logique) - -## Ordre d'execution recommande - -1. Backend Priorite 1 (fondations : constantes, services, securite) -2. Frontend Priorite 1 (hooks, utilitaires, dead code) -3. Frontend Priorite 2 (composants dupliques, UI reutilisables) -4. Backend Priorite 2 + Frontend Priorite 3 (type safety, qualite) diff --git a/.claude/refactoring/TRACKER.md b/.claude/refactoring/TRACKER.md deleted file mode 100644 index 9219ff77..00000000 --- a/.claude/refactoring/TRACKER.md +++ /dev/null @@ -1,194 +0,0 @@ -# Refactoring Tracker - -Derniere mise a jour : 2026-02-10 - -## Avancement global - -| Groupe | Total | Done | Reste | -| ------------------------- | ------ | ----- | ------ | -| Backend P1 (fondations) | 11 | 10 | 1 | -| Backend P2 (services) | 5 | 5 | 0 | -| Backend P3 (type safety) | 6 | 6 | 0 | -| Frontend P1 (hooks/utils) | 6 | 6 | 0 | -| Frontend P2 (composants) | 6 | 4 | 2 | -| Frontend P3 (qualite) | 7 | 7 | 0 | -| **TOTAL** | **41** | **38** | **3** | - ---- - -## Backend - Priorite 1 (Fondations) - -- [x] B1.1 - Extraire constantes de validation partagees -- [x] B1.2 - Extraire utilitaire de pagination -- [x] B1.3 - Creer service de verification membership -- [x] B1.4 - Extraire Prisma select constants -- [x] B1.5 - Extraire service de formatage response recipe -- [x] B1.6 - Extraire tag/ingredient normalization -- [ ] B1.7 - Fusionner controllers tags et ingredients (SKIPPED - abstraction prematuree, ~60 lignes chacun) -- [x] B1.8 - Fix securite : autorisation cancelInvite -- [x] B1.9 - Fix securite : rate limiting auth user -- [x] B1.10 - Fix securite : validation imageUrl -- [x] B1.11 - Supprimer dead code orphanHandling - -## Backend - Priorite 2 (Services) - -- [x] B2.1 - Creer recipeService.ts -- [x] B2.2 - Creer communityRecipeService.ts -- [x] B2.3 - Creer proposalService.ts -- [x] B2.4 - Creer shareService.ts -- [x] B2.5 - Fix N+1 queries dans orphanHandling - -## Backend - Priorite 3 (Type safety) - -- [x] B3.1 - Remplacer `any` par types Prisma -- [x] B3.2 - Remplacer non-null assertions par guards -- [x] B3.3 - Typer RequestHandler avec generics -- [x] B3.4 - Standardiser les codes erreur -- [x] B3.5 - Trim consistant sur tous les inputs -- [x] B3.6 - Pagination bounds dans admin controllers - -## Frontend - Priorite 1 (Hooks & utils) - -- [x] F1.1 - Creer hook useClickOutside -- [x] F1.2 - Creer hook useDebouncedEffect -- [x] F1.3 - Creer utilitaire buildQueryString -- [x] F1.4 - Centraliser le formatage de dates -- [x] F1.5 - Supprimer dead code (Recipe.tsx, AddEditRecipeDialog.tsx, CSS modules) -- [x] F1.6 - Standardiser error handling dans api.ts - -## Frontend - Priorite 2 (Composants) - -- [x] F2.1 - Fusionner ShareRecipeModal et SharePersonalRecipeModal -- [x] F2.2 - Factoriser RecipeCard et RecipeListRow -- [ ] F2.3 - Creer composants UI reutilisables (SKIPPED - one-liners JSX, abstraction prematuree) -- [x] F2.4 - Extraire hook usePaginatedList -- [x] F2.5 - Remplacer window.confirm par modal custom -- [ ] F2.6 - Factoriser invite response handlers (SKIPPED - logique trop differente entre les 2 composants) - -## Frontend - Priorite 3 (Qualite) - -- [x] F3.1 - Remplacer anti-pattern key pour force re-render -- [x] F3.2 - Remplacer window.dispatchEvent par event module -- [x] F3.3 - Fix index comme key dans IngredientList -- [x] F3.4 - Retirer console.error du code production -- [x] F3.5 - Decomposer les pages trop grosses -- [x] F3.6 - Consolider states lies en objets -- [x] F3.7 - Ajouter aria-label manquants - ---- - -## Notes de session - -### Session 1 - 2026-02-10 - -**Fichiers crees:** -- `backend/src/util/validation.ts` - Constantes de validation + normalizeNames + isValidHttpUrl -- `backend/src/util/pagination.ts` - parsePagination + buildPaginationMeta -- `backend/src/util/prismaSelects.ts` - RECIPE_TAGS_SELECT, RECIPE_INGREDIENTS_SELECT, RECIPE_DETAIL_INCLUDE -- `backend/src/services/membershipService.ts` - requireMembership, requireRecipeAccess, requireRecipeOwnership - -**Fichiers modifies (14):** -- Controllers: auth, users, communities, recipes, communityRecipes, recipeVariants, proposals, invites, tags, ingredients, activity -- Middleware: security.ts (ajout authRateLimiter, skip en mode test) -- Routes: auth.ts (ajout authRateLimiter sur signup/login) -- Services: orphanHandling.ts (suppression dead code) -- Tests: variants.test.ts (correction code erreur COMMUNITY_001 -> RECIPE_002) - -**Decision:** B1.4 (Prisma selects) applique partiellement - uniquement la ou le select est identique. Les selects qui incluent des champs extra (community, sharedFromCommunity) sont laisses inline pour eviter les bugs. - -**Tests:** 332/332 passent apres modifications. - -### Session 2 - 2026-02-10 - -**B1.5 termine:** -- Cree `backend/src/util/responseFormatters.ts` (formatTags, formatIngredients) -- Applique dans: recipes.ts, communityRecipes.ts, recipeVariants.ts, recipeShare.ts - -**B1.7 skipped:** Controllers tags/ingredients trop petits (~60 lignes) pour justifier une abstraction generique. Risque de sur-ingenierie. - -### Session 3 - 2026-02-10 - -**B2 (Services) complete:** -- `backend/src/services/recipeService.ts` - createRecipe, updateRecipe, syncLinkedRecipes, upsertTags, upsertIngredients -- `backend/src/services/communityRecipeService.ts` - createCommunityRecipe (dual personal+community) -- `backend/src/services/proposalService.ts` - acceptProposal (avec propagation), rejectProposal (avec variante) -- `backend/src/services/shareService.ts` - forkRecipe, publishRecipe, getRecipeFamilyCommunities - -**B2.5:** Optimise orphanHandling : batch updateMany pour proposals, batch createMany pour activity logs - -**Reduction controllers:** -- recipes.ts: 637 -> 366 (-42%) -- communityRecipes.ts: 320 -> 210 (-34%) -- proposals.ts: 616 -> 434 (-30%) -- recipeShare.ts: 569 -> 262 (-54%) - -**Tests:** 332/332 backend passent apres modifications. - -### Session 4 - 2026-02-10 - -**B3 (Type safety) complete:** -- B3.1: Remplace `whereClause: any` par `Prisma.RecipeWhereInput` / `Prisma.RecipeUpdateProposalWhereInput` dans 4 controllers -- B3.2: Remplace 14 `req.session.adminId!` par pattern `assertIsDefine(adminId)` dans 4 admin controllers -- B3.3: Ajoute RequestHandler generics dans members.ts (3 handlers) et invites.ts (6 handlers), supprime les casts `as` -- B3.4: Ajoute codes erreur manquants: MEMBER_001-004, PROPOSAL_004, INVITE_004-006, USER_001 -- B3.5: Ajoute `.trim()` sur email/username/userId dans invites.ts createInvite -- B3.6: Applique parsePagination/buildPaginationMeta dans admin activityController.ts - -**Tests:** 332/332 backend passent apres modifications. - -### Session 5 - 2026-02-10 - -**F1 (Hooks & utils) complete:** - -**Fichiers crees:** -- `frontend/src/hooks/useClickOutside.ts` - hook reutilisable pour fermer dropdowns au clic exterieur -- `frontend/src/hooks/useDebouncedEffect.ts` - hook reutilisable pour debounce dans useEffect - -**F1.1:** Applique useClickOutside dans 6 composants (IngredientList, TagSelector, IngredientSelector, VariantsDropdown, NotificationDropdown, NavBarLoggedInView) -**F1.2:** Applique useDebouncedEffect dans 3 composants (IngredientList, TagSelector, IngredientSelector). InviteUserModal avait deja un pattern inline plus simple (setTimeout dans useEffect), non modifie. -**F1.3:** Cree `buildQueryString()` dans api.ts, remplace 7 blocs URLSearchParams manuels -**F1.4:** Ajoute `formatDateShort()` dans format.Date.ts, remplace implementations locales dans VariantsDropdown et ProposalsList -**F1.5:** Supprime 6 fichiers dead code: Recipe.tsx, AddEditRecipeDialog.tsx, Recipe.module.css, RecipesPage.module.css, App.module.css, utils.module.css -**F1.6:** Cree `handleApiErrorWith()` factory dans api.ts, remplace 11 error handlers inline. Supprime aussi `console.error` du handler generique. - -**Tests:** 332/332 backend + 167/167 frontend passent. - -### Session 6 - 2026-02-10 - -**F2 (Composants) - 4/6 fait, 2 skipped:** - -**Fichiers crees:** -- `frontend/src/components/share/ShareModal.tsx` - modal unifiee avec mode "community" | "personal" -- `frontend/src/hooks/useRecipeActions.ts` - logique commune RecipeCard/RecipeListRow -- `frontend/src/hooks/usePaginatedList.ts` - hook generique pagination + load more -- `frontend/src/hooks/useConfirm.tsx` - modal de confirmation custom (remplace window.confirm) - -**F2.1:** Fusionne ShareRecipeModal + SharePersonalRecipeModal en un seul ShareModal. Wrappers backwards-compatible preserves pour les imports existants. Supprime les 2 anciens fichiers. -**F2.2:** Cree useRecipeActions hook, deduplique ~50 lignes de logique identique entre RecipeCard et RecipeListRow. -**F2.3 SKIPPED:** ErrorAlert/LoadingSpinner/EmptyState/StatusBadge sont des one-liners JSX. Les wrapper dans des composants ajoute de l'indirection sans gain reel. -**F2.4:** Cree usePaginatedList hook, applique dans RecipesPageLoggedInView et CommunityRecipesList. Elimine ~30 lignes dupliquees par composant. -**F2.5:** Cree useConfirm hook, applique dans MembersList (3 confirms) et useRecipeActions (delete). Plus de window.confirm dans le code. -**F2.6 SKIPPED:** InviteCard et NotificationDropdown ont des callbacks trop differents (navigate vs filter list + navigate). Un hook partage ajouterait de la complexite sans benefice. - -**Tests mis a jour:** RecipeCard.test.tsx et MembersList.test.tsx adaptes au nouveau confirm dialog. -**Tests:** 332/332 backend + 167/167 frontend passent. - -### Session 7 - 2026-02-10 - -**F3 (Qualite) complete - 7/7:** - -**Fichiers crees:** -- `frontend/src/utils/communityEvents.ts` - module subscribe/notify (remplace window.dispatchEvent) -- `frontend/src/components/communities/CommunityEditForm.tsx` - formulaire edit extrait de CommunityDetailPage - -**F3.1:** ProposalsList accepte `refreshSignal` prop, RecipeDetailPage utilise `refreshSignal={proposalsRefresh}` au lieu de `key={proposalsKey}`. Plus de remount complet. -**F3.2:** Cree `communityEvents` module (subscribe/notify pattern). Remplace `window.dispatchEvent(new Event("community-updated"))` dans CommunityDetailPage et `window.addEventListener` dans Sidebar. -**F3.3:** IngredientList utilise `useRef` counter pour generer des IDs stables. `key={itemIds[index]}` remplace `key={index}`. -**F3.4:** Supprime 9 `console.error` dans 8 fichiers (AuthContext, AdminAuthContext, RecipeDetailPage, RecipeFormPage, RecipesPageLoggedInView, NavBarLoggedInView, CommunityRecipesList). ErrorBoundary conserve (last-resort logger). -**F3.5:** Extrait CommunityEditForm de CommunityDetailPage (4 useState + handler deplaces). CommunityDetailPage: 311 -> 238 lignes. -**F3.6:** RecipeDetailPage: 3 booleans `showProposeModal/showShareModal/showPublishModal` remplaces par `openModal: "propose" | "share" | "publish" | null`. -**F3.7:** Ajoute aria-labels: RecipeDetailPage (5 boutons), TagSelector (remove tag), IngredientList (remove ingredient). - -**Tests:** 332/332 backend + 167/167 frontend passent. - ---- diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 57a31fee..72218ca7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,8 @@ name: Build and Deploy +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [master] @@ -8,7 +11,7 @@ on: workflow_dispatch: inputs: skip_build: - description: 'Skip build (use existing images)' + description: "Skip build (use existing images)" required: false default: false type: boolean @@ -40,6 +43,27 @@ jobs: - 'docker-compose.prod.yml' - 'docker-compose.preprod.yml' + # ======================================== + # Format check + # ======================================== + format-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Prettier + run: npm install --save-dev prettier + + - name: Check formatting + run: npx prettier --check . + # ======================================== # Test Backend # ======================================== @@ -70,14 +94,18 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' + node-version: "22" + cache: "npm" cache-dependency-path: backend/package-lock.json - name: Install dependencies working-directory: ./backend run: npm ci + - name: Security audit + working-directory: ./backend + run: npm audit --audit-level=high + - name: Generate Prisma Client working-directory: ./backend run: npx prisma generate @@ -88,7 +116,11 @@ jobs: DATABASE_URL: postgresql://forestmanager:test_password@localhost:5432/forestmanager_test?schema=public run: npx prisma migrate deploy - - name: Run tests + - name: Lint + working-directory: ./backend + run: npx eslint . + + - name: Run tests with coverage working-directory: ./backend env: DATABASE_URL: postgresql://forestmanager:test_password@localhost:5432/forestmanager_test?schema=public @@ -96,7 +128,7 @@ jobs: PORT: 3001 SESSION_SECRET: test_session_secret ADMIN_SESSION_SECRET: test_admin_session_secret - run: npm test + run: npx vitest run --coverage # ======================================== # Test Frontend @@ -113,19 +145,27 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' + node-version: "22" + cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install dependencies working-directory: ./frontend run: npm ci - - name: Run tests + - name: Security audit + working-directory: ./frontend + run: npm audit --audit-level=high + + - name: Lint + working-directory: ./frontend + run: npx eslint . + + - name: Run tests with coverage working-directory: ./frontend env: VITE_BACKEND_URL: http://localhost:3001 - run: npm test + run: npx vitest run --coverage # ======================================== # Build Backend Image diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..07d3b4c6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +build +coverage +*.lock +package-lock.json +backend/prisma/migrations diff --git a/README.md b/README.md index 84ac1464..4c9e1cb9 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,27 @@ Application de gestion de communautes pour le partage de recettes. Communautes p ## Fonctionnalites - **Authentification** : inscription, connexion, sessions securisees, profil utilisateur -- **Recettes personnelles** : CRUD complet avec tags et ingredients (autocomplete) -- **Communautes privees** : creation, invitation par utilisateur, roles (Member / Moderator) +- **Recettes personnelles** : CRUD complet avec tags, ingredients, etapes numerotees, temps de preparation +- **Photos de recettes** : upload d'images (drag & drop, conversion WebP automatique, stockage MinIO) +- **Communautes privees** : creation, invitation par utilisateur, roles (Member / Moderator), avatar personnalise - **Recettes communautaires** : publication, edition collaborative, permissions par role - **Propositions & variantes** : suggestion de modifications, historique des variantes - **Partage inter-communautes** : fork de recettes entre communautes, tracabilite +- **Systeme de tags** : tags globaux et communautaires, suggestions de tags, moderation +- **Ingredients normalises** : base d'ingredients avec unites, autocomplete intelligent - **Feed d'activite** : activite communautaire et personnelle -- **Notifications** : invitations en attente, badge temps reel -- **SuperAdmin** : dashboard admin isole, authentification 2FA (TOTP), gestion tags/ingredients/communautes/features +- **Notifications temps reel** : 5 categories, preferences par communaute, WebSocket, groupement intelligent +- **SuperAdmin** : dashboard admin isole, authentification 2FA (TOTP), gestion tags/ingredients/unites/communautes/features ## Stack technique | Couche | Technologies | | --------------- | --------------------------------------------------------------------- | | Frontend | React 18, TypeScript, Vite, TailwindCSS, daisyUI, React Router, Axios | -| Backend | Node.js, Express, TypeScript, Prisma ORM | +| Backend | Node.js, Express, TypeScript, Prisma ORM, Socket.IO | | Base de donnees | PostgreSQL 15 | -| Tests | Vitest, Supertest, Testing Library, MSW | +| Stockage | MinIO (compatible S3) | +| Tests | Vitest, Supertest, Testing Library, MSW (~1150 tests) | | Infrastructure | Docker, GitHub Actions, Portainer, Traefik | ## Demarrage rapide @@ -62,6 +66,7 @@ npm run docker:up:build | ------------- | --------------------------------------------------- | | Frontend | http://localhost:3000 | | Backend API | http://localhost:3001 | +| MinIO Console | http://localhost:9001 | | Prisma Studio | http://localhost:5555 (via `npm run prisma:studio`) | ### Comptes de test @@ -284,13 +289,24 @@ Le script demande un username et un mot de passe, puis genere un QR code TOTP a ## Documentation -| 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 | +| Document | Description | +| -------------------------------------------------- | ---------------------------------- | +| [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 MVP](docs/mvp/DEVELOPMENT_ROADMAP.md) | Plan de developpement (archive) | + +### Features post-MVP + +| Feature | Spec | Description | +| ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------- | +| Tags Rework | [SPEC](docs/features/tags-rework/SPEC_TAGS_REWORK.md) | Systeme de tags scope-aware (global/community) | +| Ingredients Rework | [SPEC](docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md) | Base ingredients avec unites et moderation | +| Recipe Rework v2 | [SPEC](docs/features/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md) | Etapes numerotees, temps, portions | +| Notifications | [SPEC](docs/features/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md) | Notifications temps reel (WebSocket) | +| Input Validation | [SPEC](docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md) | Validation et securite renforcees | +| Photo Upload | [SPEC](docs/features/photo-upload/SPEC_PHOTO_UPLOAD.md) | Upload images via MinIO | ## Auteur diff --git a/backend/@types/session.d.ts b/backend/@types/session.d.ts index 13a08135..21e045f6 100644 --- a/backend/@types/session.d.ts +++ b/backend/@types/session.d.ts @@ -4,4 +4,4 @@ declare module "express-session" { interface SessionData { userId: prisma.Types.ObjectId; } -} \ No newline at end of file +} diff --git a/backend/README.md b/backend/README.md index 31faaa2e..2d016d15 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,37 +1,94 @@ -# Forest Manager backend +# Forest Manager - Backend -## Table of contents +API REST pour l'application Forest Manager. -- [General info](#general-info) -- [Technologies](#technologies) -- [Features](#features) -- [Todo](#Todo) -- [Sources](#sources) +![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) +![NodeJS](https://img.shields.io/badge/Node%20js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white) +![Express](https://img.shields.io/badge/Express%20js-000000?style=for-the-badge&logo=express&logoColor=white) +![Prisma](https://img.shields.io/badge/Prisma-3982CE?style=for-the-badge&logo=Prisma&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white) -## General info +## Stack -This is the back part of the Forest Manager app. +- **Node.js** + **Express** - Serveur HTTP +- **TypeScript** - Typage statique +- **Prisma** - ORM avec migrations +- **PostgreSQL** - Base de donnees +- **Socket.IO** - WebSocket pour notifications temps reel +- **MinIO** - Stockage S3-compatible pour images +- **Vitest** + **Supertest** - Tests -## Technologies +## Structure -![Javascript](https://img.shields.io/badge/JavaScript-323330?style=for-the-badge&logo=javascript&logoColor=F7DF1E) -![Typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) -![NodeJS](https://img.shields.io/badge/Node%20js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white) -![ExpressJS](https://img.shields.io/badge/Express%20js-000000?style=for-the-badge&logo=express&logoColor=white) -![Docker](https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white) -![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white) +``` +src/ + controllers/ # Handlers des endpoints + routes/ # Definitions des routes + middleware/ # Auth, roles, securite + services/ # Logique metier + admin/ # Module admin isole (controllers, routes, middleware) + config/ # Configuration (storage, etc.) + util/ # Utilitaires (validation, pagination, formatters) + jobs/ # Cron jobs (cleanup notifications, images orphelines) + types/ # Extensions TypeScript +prisma/ + schema.prisma # Schema de la base de donnees + seed.js # Donnees initiales + migrations/ # Migrations SQL +``` + +## API Endpoints + +| Module | Endpoints | Description | +| ------------- | ---------------------- | -------------------------------------- | +| Auth | `/api/auth/*` | Inscription, connexion, sessions | +| Recipes | `/api/recipes/*` | CRUD recettes, images, propositions | +| Communities | `/api/communities/*` | CRUD communautes, membres, invitations | +| Tags | `/api/tags/*` | Autocomplete tags scope-aware | +| Ingredients | `/api/ingredients/*` | Autocomplete + unite suggeree | +| Notifications | `/api/notifications/*` | CRUD, preferences, batch | +| Admin | `/api/admin/*` | Dashboard, gestion (2FA requis) | + +Voir `.claude/context/API_MAP.md` pour la liste complete des 99 endpoints. + +## Developpement + +```bash +# Depuis la racine du projet +npm run docker:up:build # Demarrer tous les services +npm run docker:logs # Voir les logs + +# Depuis ce dossier (requiert DB locale) +npm run dev # Mode developpement +npm test # Tests +``` + +## Tests -## Features +746 tests (integration + unit) couvrant : -- +- Authentification et sessions +- CRUD recettes/communautes +- Propositions et variantes +- Partage inter-communautes +- Notifications et WebSocket +- API Admin (2FA, tags, ingredients, unites) +- Upload images (presigned URLs, validation) -## Todo +```bash +npm test # Tous les tests +npm run test:watch # Mode watch +npm run test:coverage # Couverture +``` -Api manager error manager +## Securite -UpdateRecipe need to be change for check if the ID exist and response with the appropriate error --> same for delete +- Sessions isolees (user/admin) +- Rate limiting (auth, admin) +- Helmet + CORS +- Validation stricte des inputs +- 2FA TOTP obligatoire pour admin -## Sources +## Auteur -This app is created and made by [MatthiasBlc](https://github.com/MatthiasBlc). +Cree par [MatthiasBlc](https://github.com/MatthiasBlc) diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 51d83e14..894d89b1 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -1,10 +1,12 @@ import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import globals from "globals"; +import eslintConfigPrettier from "eslint-config-prettier"; export default [ eslint.configs.recommended, ...tseslint.configs.recommended, + eslintConfigPrettier, { languageOptions: { globals: { @@ -20,6 +22,15 @@ export default [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { allow: ["warn", "error"] }], + }, + }, + { + // Scripts CLI : console.log autorise + files: ["src/scripts/**", "prisma/**"], + rules: { + "no-console": "off", }, }, { diff --git a/backend/package-lock.json b/backend/package-lock.json index 540a0963..4f43cfbb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,10 +9,13 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.967.0", + "@aws-sdk/s3-request-presigner": "^3.967.0", "@prisma/client": "~6.19.0", "@quixo3/prisma-session-store": "^3.1.13", "@types/helmet": "^0.0.48", "bcrypt": "^6.0.0", + "cheerio": "^1.0.0", "cors": "^2.8.5", "envalid": "^8.0.0", "express": "^4.21.2", @@ -20,12 +23,14 @@ "express-session": "^1.18.1", "helmet": "^8.1.0", "http-errors": "^2.0.0", + "node-cron": "^4.2.1", "otplib": "^13.2.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "qrcode": "^1.5.4", "read": "^5.0.1", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -35,12 +40,14 @@ "@types/express-session": "^1.17.10", "@types/http-errors": "^2.0.3", "@types/node": "^22.15.3", + "@types/node-cron": "^3.0.11", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^8.55.0", "@typescript-eslint/parser": "^8.55.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "globals": "^17.3.0", "nodemon": "^3.0.1", "pino-pretty": "^13.1.3", @@ -78,1484 +85,3100 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", "dependencies": { - "@babel/types": "^7.28.6" + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "bin": { - "parser": "bin/babel-parser.js" + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@aws-sdk/client-s3": { + "version": "3.1006.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1006.0.tgz", + "integrity": "sha512-tm8R/LgWDC3zWPMCdD990owvBrmuIM2A39+OWKW/HyAomWi6ancPz/H1K/hmxf0bqdXAaRUHBQMAmzwb1aR33Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-node": "^3.972.19", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", + "@aws-sdk/middleware-expect-continue": "^3.972.7", + "@aws-sdk/middleware-flexible-checksums": "^3.973.5", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-location-constraint": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/middleware-ssec": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/eventstream-serde-config-resolver": "^4.3.11", + "@smithy/eventstream-serde-node": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-blob-browser": "^4.2.12", + "@smithy/hash-node": "^4.2.11", + "@smithy/hash-stream-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/md5-js": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", + "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", + "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", + "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", + "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.18.tgz", + "integrity": "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-login": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", + "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.19.tgz", + "integrity": "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-ini": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", + "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.18.tgz", + "integrity": "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/token-providers": "3.1005.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.18.tgz", + "integrity": "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz", + "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz", + "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz", + "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/crc64-nvme": "^3.972.4", + "@aws-sdk/types": "^3.973.5", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz", + "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz", + "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz", + "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", + "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.9", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", + "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1006.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1006.0.tgz", + "integrity": "sha512-azZTNllb6zq3hyGvTViBflfN5IeThmSQbYB+JJJqVGB9ZAqV9d6xOUG1BFCtxoKukDT9JnUZqQwQB0Y24gJAPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-format-url": "^3.972.7", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz", + "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/token-providers": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1005.0.tgz", + "integrity": "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/types": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "license": "Apache-2.0", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=20.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=20.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.0.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", + "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@aws-sdk/types": "^3.973.5", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.0.tgz", + "integrity": "sha512-9LJFand4bIoOjOF4x3wx0UZYiFZRo4oUauxQSiEX2dVg+5qeBOJSjp2SeWykIE6+6frCZ5wvWm2fGLK8D32aJw==", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "tslib": "^2.6.2" }, "engines": { - "node": "*" + "node": ">=18.0.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", + "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", "license": "Apache-2.0", "dependencies": { - "@types/json-schema": "^7.0.15" + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=20.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": "*" + "node": ">=6.0.0" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, - "funding": { - "url": "https://eslint.org/donate" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" + "@jridgewell/trace-mapping": "0.3.9" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=18" } }, - "node_modules/@otplib/core": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz", - "integrity": "sha512-IyfHvYNCyipDxhmJdcUUvUeT3Hz84/GgM6G2G6BTEmnAKPzNA7U0kYGkxKZWY9h23W94RJk4qiClJRJN5zKGvg==", - "license": "MIT" - }, - "node_modules/@otplib/hotp": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.2.1.tgz", - "integrity": "sha512-iRKqvj0TnemtXXtEswzBX50Z0yMNa0lH9PSdr5N4CJc1mDEuUmFFZQqnu3PfA3fPd3WeAU+mHgmK/xq18+K1QA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@otplib/core": "13.2.1", - "@otplib/uri": "13.2.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@otplib/plugin-base32-scure": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.2.1.tgz", - "integrity": "sha512-vnA2qqgJ/FbFbDNGOLAS8dKfCsJFXwFsZKYklE8yl2INkCOUR0vbVdJ2TVmufzC8R1RRZHW+cDR20ACgc9XFYg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@otplib/core": "13.2.1", - "@scure/base": "^2.0.0" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@otplib/plugin-crypto-noble": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.2.1.tgz", - "integrity": "sha512-Dxjmt4L+5eDWJf5EvbcMp+fxcliyKoB9N9sNQq/vuVAUvq+KiqpiiCQZ/wHyrN0ArB0NdevtK1KByyAq080ldg==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1", - "@otplib/core": "13.2.1" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=18" } }, - "node_modules/@otplib/totp": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.2.1.tgz", - "integrity": "sha512-LzDzAAK3w8rspF3urBnWjOlxso1SCGxX9Pnu/iy+HkC0y0HgiLsW7jhkr2hJ3u4cyBdL/tOKUhhELwsjyvunwQ==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@otplib/core": "13.2.1", - "@otplib/hotp": "13.2.1", - "@otplib/uri": "13.2.1" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@otplib/uri": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.2.1.tgz", - "integrity": "sha512-ssYnfiUrFTs/rPRUW8h59m0MVLYOC+UKk7tVGYgtG15lLaLBrNBQjM2YFanuzn9Jm4iv9JxiNG7TRkwcnyR09A==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@otplib/core": "13.2.1" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/@prisma/client": { - "version": "6.19.2", - "hasInstallScript": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.1.0" + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@prisma/config": { - "version": "6.19.2", - "devOptional": true, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "c12": "3.1.0", - "deepmerge-ts": "7.1.5", - "effect": "3.18.4", - "empathic": "2.0.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@prisma/debug": { - "version": "6.19.2", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "6.19.2", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", "dependencies": { - "@prisma/debug": "6.19.2", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.2", - "@prisma/get-platform": "6.19.2" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@prisma/engines-version": { - "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "devOptional": true, - "license": "Apache-2.0" + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } }, - "node_modules/@prisma/fetch-engine": { - "version": "6.19.2", - "devOptional": true, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.2" + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@prisma/get-platform": { - "version": "6.19.2", - "devOptional": true, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@quixo3/prisma-session-store": { - "version": "3.1.19", + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, "license": "MIT", "dependencies": { - "@paralleldrive/cuid2": "^2.2.0", - "ts-dedent": "^2.2.0", - "type-fest": "^5.3.1" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@prisma/client": ">=2.16.1", - "express-session": ">=1.17.1" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": ">= 4" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], + "node_modules/@noble/hashes": { + "version": "1.8.0", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz", + "integrity": "sha512-IyfHvYNCyipDxhmJdcUUvUeT3Hz84/GgM6G2G6BTEmnAKPzNA7U0kYGkxKZWY9h23W94RJk4qiClJRJN5zKGvg==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.2.1.tgz", + "integrity": "sha512-iRKqvj0TnemtXXtEswzBX50Z0yMNa0lH9PSdr5N4CJc1mDEuUmFFZQqnu3PfA3fPd3WeAU+mHgmK/xq18+K1QA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.2.1", + "@otplib/uri": "13.2.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.2.1.tgz", + "integrity": "sha512-vnA2qqgJ/FbFbDNGOLAS8dKfCsJFXwFsZKYklE8yl2INkCOUR0vbVdJ2TVmufzC8R1RRZHW+cDR20ACgc9XFYg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.2.1", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.2.1.tgz", + "integrity": "sha512-Dxjmt4L+5eDWJf5EvbcMp+fxcliyKoB9N9sNQq/vuVAUvq+KiqpiiCQZ/wHyrN0ArB0NdevtK1KByyAq080ldg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.2.1" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.2.1.tgz", + "integrity": "sha512-LzDzAAK3w8rspF3urBnWjOlxso1SCGxX9Pnu/iy+HkC0y0HgiLsW7jhkr2hJ3u4cyBdL/tOKUhhELwsjyvunwQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.2.1", + "@otplib/hotp": "13.2.1", + "@otplib/uri": "13.2.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.2.1.tgz", + "integrity": "sha512-ssYnfiUrFTs/rPRUW8h59m0MVLYOC+UKk7tVGYgtG15lLaLBrNBQjM2YFanuzn9Jm4iv9JxiNG7TRkwcnyR09A==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.2.1" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@quixo3/prisma-session-store": { + "version": "3.1.19", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.0", + "ts-dedent": "^2.2.0", + "type-fest": "^5.3.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.16.1", + "express-session": ">=1.17.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", + "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", + "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", + "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", + "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.12.tgz", + "integrity": "sha512-1wQE33DsxkM/waftAhCH9VtJbUGyt1PJ9YRDpOu+q9FUi73LLFUZ2fD8A61g2mT1UY9k7b99+V1xZ41Rz4SHRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.11.tgz", + "integrity": "sha512-hQsTjwPCRY8w9GK07w1RqJi3e+myh0UaOWBBhZ1UMSDgofH/Q1fEYzU1teaX6HkpX/eWDdm7tAGR0jBPlz9QEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.11.tgz", + "integrity": "sha512-350X4kGIrty0Snx2OWv7rPM6p6vM7RzryvFs6B/56Cux3w3sChOb3bymo5oidXJlPcP9fIRxGUCk7GqpiSOtng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@smithy/util-waiter": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.12.tgz", + "integrity": "sha512-ek5hyDrzS6mBFsNCEX8LpM+EWSLq6b9FdmPRlkpXXhiJE6aIZehKT9clC6+nFpZAA+i/Yg0xlaPeWGNbf5rzQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@socket.io/component-emitter": { @@ -1745,6 +3368,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -2251,9 +3881,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2451,6 +4081,18 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2632,6 +4274,48 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2827,6 +4511,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -2948,6 +4660,61 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "devOptional": true, @@ -3010,6 +4777,31 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3063,6 +4855,18 @@ "node": ">=10.0.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envalid": { "version": "8.1.1", "license": "MIT", @@ -3239,6 +5043,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -3304,9 +5124,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3465,12 +5285,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -3586,6 +5406,40 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz", + "integrity": "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.2" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3671,9 +5525,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -3941,6 +5795,25 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "license": "MIT", @@ -4018,9 +5891,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -4395,13 +6268,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4483,6 +6356,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "devOptional": true, @@ -4550,9 +6432,9 @@ } }, "node_modules/nodemon/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4585,6 +6467,18 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nypm": { "version": "0.6.4", "devOptional": true, @@ -4755,6 +6649,55 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -4771,6 +6714,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz", + "integrity": "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5099,7 +7057,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -5213,9 +7173,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5229,31 +7189,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -5668,6 +7628,18 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -6089,6 +8061,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6355,6 +8336,40 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6587,6 +8602,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 45a16dab..585c999a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,12 +32,14 @@ "@types/express-session": "^1.17.10", "@types/http-errors": "^2.0.3", "@types/node": "^22.15.3", + "@types/node-cron": "^3.0.11", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^8.55.0", "@typescript-eslint/parser": "^8.55.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "globals": "^17.3.0", "nodemon": "^3.0.1", "pino-pretty": "^13.1.3", @@ -50,10 +52,13 @@ "vitest": "^3.2.4" }, "dependencies": { + "@aws-sdk/client-s3": "^3.967.0", + "@aws-sdk/s3-request-presigner": "^3.967.0", "@prisma/client": "~6.19.0", "@quixo3/prisma-session-store": "^3.1.13", "@types/helmet": "^0.0.48", "bcrypt": "^6.0.0", + "cheerio": "^1.0.0", "cors": "^2.8.5", "envalid": "^8.0.0", "express": "^4.21.2", @@ -61,11 +66,13 @@ "express-session": "^1.18.1", "helmet": "^8.1.0", "http-errors": "^2.0.0", + "node-cron": "^4.2.1", "otplib": "^13.2.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "qrcode": "^1.5.4", "read": "^5.0.1", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "zod": "^4.3.6" } } 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/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/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/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/migrations/20260224093827_notifications_rework/migration.sql b/backend/prisma/migrations/20260224093827_notifications_rework/migration.sql new file mode 100644 index 00000000..119f39a2 --- /dev/null +++ b/backend/prisma/migrations/20260224093827_notifications_rework/migration.sql @@ -0,0 +1,93 @@ +-- CreateEnum +CREATE TYPE "NotificationCategory" AS ENUM ('INVITATION', 'RECIPE_PROPOSAL', 'TAG', 'INGREDIENT', 'MODERATION'); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "category" "NotificationCategory" NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "actionUrl" TEXT, + "metadata" JSONB, + "actorId" TEXT, + "communityId" TEXT, + "recipeId" TEXT, + "groupKey" TEXT, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "communityId" TEXT, + "category" "NotificationCategory" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id") +); + +-- MigrateData: ModeratorNotificationPreference -> NotificationPreference (category = TAG) +INSERT INTO "NotificationPreference" ("id", "userId", "communityId", "category", "enabled", "updatedAt") +SELECT + gen_random_uuid(), + "userId", + "communityId", + 'TAG'::"NotificationCategory", + "tagNotifications", + "updatedAt" +FROM "ModeratorNotificationPreference"; + +-- DropForeignKey +ALTER TABLE "ModeratorNotificationPreference" DROP CONSTRAINT "ModeratorNotificationPreference_communityId_fkey"; + +-- DropForeignKey +ALTER TABLE "ModeratorNotificationPreference" DROP CONSTRAINT "ModeratorNotificationPreference_userId_fkey"; + +-- AlterTable +ALTER TABLE "Ingredient" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Tag" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- DropTable +DROP TABLE "ModeratorNotificationPreference"; + +-- CreateIndex +CREATE INDEX "Notification_userId_readAt_createdAt_idx" ON "Notification"("userId", "readAt", "createdAt"); + +-- CreateIndex +CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Notification_userId_groupKey_createdAt_idx" ON "Notification"("userId", "groupKey", "createdAt"); + +-- CreateIndex +CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_communityId_category_key" ON "NotificationPreference"("userId", "communityId", "category"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260225093525_recipe_rework_v2/migration.sql b/backend/prisma/migrations/20260225093525_recipe_rework_v2/migration.sql new file mode 100644 index 00000000..932a7406 --- /dev/null +++ b/backend/prisma/migrations/20260225093525_recipe_rework_v2/migration.sql @@ -0,0 +1,61 @@ +-- CreateTable: RecipeStep +CREATE TABLE "RecipeStep" ( + "id" TEXT NOT NULL, + "recipeId" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "instruction" TEXT NOT NULL, + + CONSTRAINT "RecipeStep_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: ProposalStep +CREATE TABLE "ProposalStep" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "instruction" TEXT NOT NULL, + + CONSTRAINT "ProposalStep_pkey" PRIMARY KEY ("id") +); + +-- AlterTable: Recipe - add new columns +ALTER TABLE "Recipe" ADD COLUMN "servings" INTEGER NOT NULL DEFAULT 4; +ALTER TABLE "Recipe" ADD COLUMN "prepTime" INTEGER; +ALTER TABLE "Recipe" ADD COLUMN "cookTime" INTEGER; +ALTER TABLE "Recipe" ADD COLUMN "restTime" INTEGER; + +-- AlterTable: RecipeUpdateProposal - add new columns +ALTER TABLE "RecipeUpdateProposal" ADD COLUMN "proposedServings" INTEGER; +ALTER TABLE "RecipeUpdateProposal" ADD COLUMN "proposedPrepTime" INTEGER; +ALTER TABLE "RecipeUpdateProposal" ADD COLUMN "proposedCookTime" INTEGER; +ALTER TABLE "RecipeUpdateProposal" ADD COLUMN "proposedRestTime" INTEGER; + +-- DataMigration: Convert Recipe.content to RecipeStep (including soft-deleted) +INSERT INTO "RecipeStep" ("id", "recipeId", "order", "instruction") +SELECT gen_random_uuid(), "id", 0, "content" +FROM "Recipe" +WHERE "content" IS NOT NULL AND "content" != ''; + +-- DataMigration: Convert RecipeUpdateProposal.proposedContent to ProposalStep (including soft-deleted) +INSERT INTO "ProposalStep" ("id", "proposalId", "order", "instruction") +SELECT gen_random_uuid(), "id", 0, "proposedContent" +FROM "RecipeUpdateProposal" +WHERE "proposedContent" IS NOT NULL AND "proposedContent" != ''; + +-- AlterTable: Recipe - drop old column +ALTER TABLE "Recipe" DROP COLUMN "content"; + +-- AlterTable: RecipeUpdateProposal - drop old column +ALTER TABLE "RecipeUpdateProposal" DROP COLUMN "proposedContent"; + +-- AddForeignKey +ALTER TABLE "RecipeStep" ADD CONSTRAINT "RecipeStep_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProposalStep" ADD CONSTRAINT "ProposalStep_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "RecipeUpdateProposal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "RecipeStep_recipeId_order_idx" ON "RecipeStep"("recipeId", "order"); + +-- CreateIndex +CREATE INDEX "ProposalStep_proposalId_order_idx" ON "ProposalStep"("proposalId", "order"); diff --git a/backend/prisma/migrations/20260303120000_add_recipe_admin_action_types/migration.sql b/backend/prisma/migrations/20260303120000_add_recipe_admin_action_types/migration.sql new file mode 100644 index 00000000..cb5e23b3 --- /dev/null +++ b/backend/prisma/migrations/20260303120000_add_recipe_admin_action_types/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "AdminActionType" ADD VALUE 'RECIPE_UPDATED'; +ALTER TYPE "AdminActionType" ADD VALUE 'RECIPE_DELETED'; diff --git a/backend/prisma/migrations/20260305104825_photo_upload_image_key/migration.sql b/backend/prisma/migrations/20260305104825_photo_upload_image_key/migration.sql new file mode 100644 index 00000000..dbf7109b --- /dev/null +++ b/backend/prisma/migrations/20260305104825_photo_upload_image_key/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: rename imageUrl -> imageKey on Recipe +ALTER TABLE "Recipe" RENAME COLUMN "imageUrl" TO "imageKey"; + +-- AlterTable: add imageKey on Community +ALTER TABLE "Community" ADD COLUMN "imageKey" TEXT; diff --git a/backend/prisma/migrations/20260313135449_add_performance_indexes/migration.sql b/backend/prisma/migrations/20260313135449_add_performance_indexes/migration.sql new file mode 100644 index 00000000..b72bf0fa --- /dev/null +++ b/backend/prisma/migrations/20260313135449_add_performance_indexes/migration.sql @@ -0,0 +1,8 @@ +-- CreateIndex +CREATE INDEX "Recipe_creatorId_communityId_deletedAt_idx" ON "Recipe"("creatorId", "communityId", "deletedAt"); + +-- CreateIndex +CREATE INDEX "Recipe_communityId_deletedAt_isVariant_idx" ON "Recipe"("communityId", "deletedAt", "isVariant"); + +-- CreateIndex +CREATE INDEX "RecipeIngredient_ingredientId_idx" ON "RecipeIngredient"("ingredientId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46bda9be..08c77617 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 @@ -143,6 +150,10 @@ enum AdminActionType { FEATURE_GRANTED FEATURE_REVOKED + // Recipes + RECIPE_UPDATED + RECIPE_DELETED + // Admin auth ADMIN_LOGIN ADMIN_LOGOUT @@ -170,6 +181,13 @@ model User { recipeViews RecipeView[] invitesSent CommunityInvite[] @relation("InviteSender") invitesReceived CommunityInvite[] @relation("InviteReceiver") + createdTags Tag[] @relation("TagCreator") + createdIngredients Ingredient[] @relation("IngredientCreator") + tagSuggestions TagSuggestion[] + tagPreferences UserCommunityTagPreference[] + notifications Notification[] @relation("NotificationRecipient") + notificationsActed Notification[] @relation("NotificationActor") + notificationPrefs NotificationPreference[] @@index([email]) @@index([username]) @@ -185,6 +203,7 @@ model Community { name String description String? visibility Visibility @default(INVITE_ONLY) + imageKey String? // Cle relative MinIO (ex: "communities/{id}/avatar.webp") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -196,6 +215,10 @@ model Community { activities ActivityLog[] invites CommunityInvite[] features CommunityFeature[] // Briques attribuees a cette communaute + tags Tag[] + tagPreferences UserCommunityTagPreference[] + notifications Notification[] + notificationPrefs NotificationPreference[] @@index([deletedAt]) } @@ -276,8 +299,11 @@ enum InviteStatus { model Recipe { id String @id @default(uuid()) title String - content String // Markdown ou rich text - imageUrl String? // Optionnel - stockage differe a V1 + servings Int @default(4) + prepTime Int? + cookTime Int? + restTime Int? + imageKey String? // Cle relative MinIO (ex: "recipes/{id}/cover.webp") isVariant Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -301,17 +327,36 @@ model Recipe { sharedFromCommunity Community? @relation("SharedFromCommunity", fields: [sharedFromCommunityId], references: [id]) // Relations - Other + steps RecipeStep[] tags RecipeTag[] ingredients RecipeIngredient[] proposals RecipeUpdateProposal[] analytics RecipeAnalytics? views RecipeView[] activities ActivityLog[] + tagSuggestions TagSuggestion[] + notifications Notification[] @@index([creatorId]) @@index([communityId]) @@index([originRecipeId]) @@index([deletedAt]) + @@index([creatorId, communityId, deletedAt]) + @@index([communityId, deletedAt, isVariant]) +} + +// ============================================================================= +// RECIPE STEP (Etapes structurees d'une recette) +// ============================================================================= + +model RecipeStep { + id String @id @default(uuid()) + recipeId String + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + order Int + instruction String + + @@index([recipeId, order]) } // ============================================================================= @@ -320,13 +365,16 @@ model Recipe { // ============================================================================= model RecipeUpdateProposal { - id String @id @default(uuid()) - proposedTitle String - proposedContent String - status ProposalStatus @default(PENDING) - createdAt DateTime @default(now()) - decidedAt DateTime? - deletedAt DateTime? + id String @id @default(uuid()) + proposedTitle String + proposedServings Int? + proposedPrepTime Int? + proposedCookTime Int? + proposedRestTime Int? + status ProposalStatus @default(PENDING) + createdAt DateTime @default(now()) + decidedAt DateTime? + deletedAt DateTime? // Relations recipeId String @@ -335,12 +383,29 @@ model RecipeUpdateProposal { proposerId String proposer User @relation("ProposalProposer", fields: [proposerId], references: [id]) + proposedSteps ProposalStep[] + proposedIngredients ProposalIngredient[] + @@index([recipeId]) @@index([proposerId]) @@index([status]) @@index([deletedAt]) } +// ============================================================================= +// PROPOSAL STEP (Etapes structurees d'une proposition) +// ============================================================================= + +model ProposalStep { + id String @id @default(uuid()) + proposalId String + proposal RecipeUpdateProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + order Int + instruction String + + @@index([proposalId, order]) +} + enum ProposalStatus { PENDING ACCEPTED @@ -352,14 +417,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,34 +467,198 @@ 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]) +} + +// ============================================================================= +// NOTIFICATION (Notifications persistantes par utilisateur) +// ============================================================================= + +enum NotificationCategory { + INVITATION + RECIPE_PROPOSAL + TAG + INGREDIENT + MODERATION +} + +model Notification { + id String @id @default(uuid()) + userId String + type String + category NotificationCategory + title String + message String + actionUrl String? + metadata Json? + + // Contexte source + actorId String? + communityId String? + recipeId String? + + // Groupement (broadcasts uniquement) + groupKey String? + + // Etat + readAt DateTime? + createdAt DateTime @default(now()) + + // Relations + user User @relation("NotificationRecipient", fields: [userId], references: [id], onDelete: Cascade) + actor User? @relation("NotificationActor", fields: [actorId], references: [id], onDelete: SetNull) + community Community? @relation(fields: [communityId], references: [id], onDelete: Cascade) + recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: SetNull) + + @@index([userId, readAt, createdAt]) + @@index([userId, createdAt]) + @@index([userId, groupKey, createdAt]) + @@index([createdAt]) +} + +// ============================================================================= +// NOTIFICATION PREFERENCE (Preferences notifications par categorie) +// ============================================================================= + +model NotificationPreference { + id String @id @default(uuid()) + userId String + communityId String? + category NotificationCategory + enabled 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, category]) +} + +// ============================================================================= +// 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]) + @@index([ingredientId]) +} + +// ============================================================================= +// 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]) } // ============================================================================= @@ -453,6 +711,17 @@ enum ActivityType { INVITE_ACCEPTED INVITE_REJECTED INVITE_CANCELLED + + // Tags communaute + TAG_CREATED + TAG_UPDATED + TAG_DELETED + TAG_APPROVED + TAG_REJECTED + + // Tag suggestions + TAG_SUGGESTION_ACCEPTED + TAG_SUGGESTION_REJECTED } // ============================================================================= diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index b703b5f9..f3445e13 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -2,21 +2,50 @@ import { PrismaClient } from "@prisma/client"; import bcrypt from "bcrypt"; const prisma = new PrismaClient(); +const SEED_MODE = process.env.SEED_MODE || "full"; // "full" (preprod) | "prod" (reference data only) async function seed() { - const userCount = await prisma.user.count(); - - if (userCount > 0) { - console.log("Database already seeded, skipping..."); - return; - } + console.log(`Seed mode: ${SEED_MODE}`); - console.log("Seeding database..."); + // =========================================== + // 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: "piece", category: "COUNT", sortOrder: 1 }, + { name: "tranche", abbreviation: "tranche", category: "COUNT", sortOrder: 2 }, + { name: "gousse", abbreviation: "gousse", category: "COUNT", sortOrder: 3 }, + { name: "botte", abbreviation: "botte", category: "COUNT", sortOrder: 4 }, + { name: "feuille", abbreviation: "feuille", category: "COUNT", sortOrder: 5 }, + { name: "brin", abbreviation: "brin", 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 }, + ]; - const hashedPassword = await bcrypt.hash("password123", 10); + const units = {}; + for (const unit of unitData) { + units[unit.abbreviation] = 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); // =========================================== - // Feature MVP + // Feature MVP (always upsert - idempotent) // =========================================== const featureMvp = await prisma.feature.upsert({ where: { code: "MVP" }, @@ -30,6 +59,113 @@ async function seed() { }); console.log("Feature MVP:", featureMvp.code); + // =========================================== + // Tags (always upsert - idempotent) + // =========================================== + const tagNames = [ + "italien", + "francais", + "asiatique", + "mexicain", + "vegetarien", + "vegan", + "sans-gluten", + "dessert", + "entree", + "plat-principal", + "aperitif", + "rapide", + "facile", + "gastronomique", + "ete", + "hiver", + "comfort-food", + ]; + const tags = {}; + for (const name of tagNames) { + let tag = await prisma.tag.findFirst({ + where: { name, communityId: null }, + }); + if (!tag) { + tag = await prisma.tag.create({ data: { name } }); + } + tags[name] = tag; + } + console.log("Tags seeded:", tagNames.length); + + // =========================================== + // Ingredients (always upsert - idempotent) + // =========================================== + const ingredientNames = [ + "farine", + "beurre", + "oeufs", + "sucre", + "sel", + "poivre", + "huile d'olive", + "ail", + "oignon", + "tomate", + "mozzarella", + "parmesan", + "creme fraiche", + "lait", + "poulet", + "boeuf", + "saumon", + "riz", + "pates", + "pomme de terre", + "carotte", + "courgette", + "aubergine", + "poivron", + "basilic", + "thym", + "romarin", + "persil", + "coriandre", + "citron", + "pomme", + "chocolat", + "vanille", + "sauce soja", + "gingembre", + "piment", + ]; + const ingredients = {}; + for (const name of ingredientNames) { + ingredients[name] = await prisma.ingredient.upsert({ + where: { name }, + update: {}, + create: { name }, + }); + } + console.log("Ingredients seeded:", ingredientNames.length); + + // =========================================== + // Production mode stops here (reference data only) + // =========================================== + if (SEED_MODE === "prod") { + console.log("\nProd seed complete (units, features, tags, ingredients)."); + return; + } + + // =========================================== + // Test data (preprod/dev only) + // =========================================== + const userCount = await prisma.user.count(); + + if (userCount > 0) { + console.log("Database already seeded (users exist), skipping test data..."); + return; + } + + console.log("Seeding test data..."); + + const hashedPassword = await bcrypt.hash("password123", 10); + // =========================================== // Users // =========================================== @@ -50,53 +186,27 @@ async function seed() { }); console.log("Users created: 5"); - // =========================================== - // Tags - // =========================================== - const tagNames = [ - "italien", "francais", "asiatique", "mexicain", - "vegetarien", "vegan", "sans-gluten", - "dessert", "entree", "plat-principal", "aperitif", - "rapide", "facile", "gastronomique", - "ete", "hiver", "comfort-food", - ]; - const tags = {}; - for (const name of tagNames) { - tags[name] = await prisma.tag.create({ data: { name } }); - } - console.log("Tags created:", tagNames.length); - - // =========================================== - // Ingredients - // =========================================== - const ingredientNames = [ - "farine", "beurre", "oeufs", "sucre", "sel", "poivre", - "huile d'olive", "ail", "oignon", "tomate", - "mozzarella", "parmesan", "creme fraiche", "lait", - "poulet", "boeuf", "saumon", - "riz", "pates", "pomme de terre", - "carotte", "courgette", "aubergine", "poivron", - "basilic", "thym", "romarin", "persil", "coriandre", - "citron", "pomme", "chocolat", "vanille", - "sauce soja", "gingembre", "piment", - ]; - const ingredients = {}; - for (const name of ingredientNames) { - ingredients[name] = await prisma.ingredient.create({ data: { name } }); - } - console.log("Ingredients created:", ingredientNames.length); - // =========================================== // Communities // =========================================== const cuisineItalienne = await prisma.community.create({ - data: { name: "Cuisine Italienne", description: "Partagez vos meilleures recettes italiennes : pates, pizzas, risottos et plus encore !" }, + data: { + name: "Cuisine Italienne", + description: + "Partagez vos meilleures recettes italiennes : pates, pizzas, risottos et plus encore !", + }, }); const patisserieFine = await prisma.community.create({ - data: { name: "Patisserie Fine", description: "Pour les amateurs de patisserie, du classique au creatif." }, + data: { + name: "Patisserie Fine", + description: "Pour les amateurs de patisserie, du classique au creatif.", + }, }); const cuisineRapide = await prisma.community.create({ - data: { name: "Cuisine Rapide & Facile", description: "Des recettes simples pour le quotidien, prets en moins de 30 minutes." }, + data: { + name: "Cuisine Rapide & Facile", + description: "Des recettes simples pour le quotidien, prets en moins de 30 minutes.", + }, }); console.log("Communities created: 3"); @@ -185,8 +295,32 @@ async function seed() { const pizzaMargherita = await prisma.recipe.create({ data: { title: "Pizza Margherita", - content: "# Pizza Margherita\n\nLa classique napolitaine. Etaler la pate finement, napper de sauce tomate, disposer la mozzarella en morceaux. Cuire au four a 250C pendant 8-10 minutes. Ajouter le basilic frais a la sortie du four.", + servings: 4, + prepTime: 30, + cookTime: 10, + restTime: 60, creatorId: alice.id, + steps: { + create: [ + { + order: 0, + instruction: + "Melanger la farine, le sel et la levure. Ajouter l'eau tiede et petrir 10 minutes jusqu'a obtenir une pate lisse et elastique.", + }, + { order: 1, instruction: "Laisser reposer la pate 1h sous un linge humide." }, + { order: 2, instruction: "Etaler la pate finement sur un plan farine." }, + { + order: 3, + instruction: + "Napper de sauce tomate, disposer la mozzarella en morceaux et arroser d'un filet d'huile d'olive.", + }, + { + order: 4, + instruction: + "Cuire au four a 250C pendant 8-10 minutes. Ajouter le basilic frais et une pincee de sel a la sortie du four.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -198,20 +332,79 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: pizzaMargherita.id, ingredientId: ingredients["farine"].id, quantity: "300g", order: 1 }, - { recipeId: pizzaMargherita.id, ingredientId: ingredients["tomate"].id, quantity: "200g sauce", order: 2 }, - { recipeId: pizzaMargherita.id, ingredientId: ingredients["mozzarella"].id, quantity: "200g", order: 3 }, - { recipeId: pizzaMargherita.id, ingredientId: ingredients["basilic"].id, quantity: "quelques feuilles", order: 4 }, - { recipeId: pizzaMargherita.id, ingredientId: ingredients["huile d'olive"].id, quantity: "2 c.a.s", order: 5 }, - { recipeId: pizzaMargherita.id, ingredientId: ingredients["sel"].id, quantity: "1 pincee", order: 6 }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["farine"].id, + quantity: 300, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["tomate"].id, + quantity: 200, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["mozzarella"].id, + quantity: 200, + unitId: units["g"].id, + order: 3, + }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["basilic"].id, + quantity: 5, + unitId: units["feuille"].id, + order: 4, + }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["huile d'olive"].id, + quantity: 2, + unitId: units["cas"].id, + order: 5, + }, + { + recipeId: pizzaMargherita.id, + ingredientId: ingredients["sel"].id, + quantity: 1, + unitId: units["pincee"].id, + order: 6, + }, ], }); const risottoChampignons = await prisma.recipe.create({ data: { title: "Risotto aux champignons", - content: "# Risotto aux champignons\n\nFaire revenir l'oignon emince dans le beurre. Ajouter le riz et nacrer 2 minutes. Deglace au vin blanc. Ajouter le bouillon louche par louche en remuant. A mi-cuisson, ajouter les champignons sautes. Terminer avec le parmesan et une noix de beurre.", + servings: 4, + prepTime: 15, + cookTime: 30, creatorId: alice.id, + steps: { + create: [ + { + order: 0, + instruction: + "Faire revenir l'oignon emince dans le beurre a feu moyen jusqu'a ce qu'il soit translucide.", + }, + { order: 1, instruction: "Ajouter le riz et nacrer 2 minutes en remuant." }, + { order: 2, instruction: "Deglacer au vin blanc et laisser absorber." }, + { + order: 3, + instruction: + "Ajouter le bouillon louche par louche en remuant regulierement. A mi-cuisson, ajouter les champignons sautes.", + }, + { + order: 4, + instruction: + "Terminer avec le parmesan rape et une noix de beurre. Servir immediatement.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -224,18 +417,58 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: risottoChampignons.id, ingredientId: ingredients["riz"].id, quantity: "300g arborio", order: 1 }, - { recipeId: risottoChampignons.id, ingredientId: ingredients["oignon"].id, quantity: "1", order: 2 }, - { recipeId: risottoChampignons.id, ingredientId: ingredients["beurre"].id, quantity: "50g", order: 3 }, - { recipeId: risottoChampignons.id, ingredientId: ingredients["parmesan"].id, quantity: "80g", order: 4 }, + { + recipeId: risottoChampignons.id, + ingredientId: ingredients["riz"].id, + quantity: 300, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: risottoChampignons.id, + ingredientId: ingredients["oignon"].id, + quantity: 1, + unitId: units["piece"].id, + order: 2, + }, + { + recipeId: risottoChampignons.id, + ingredientId: ingredients["beurre"].id, + quantity: 50, + unitId: units["g"].id, + order: 3, + }, + { + recipeId: risottoChampignons.id, + ingredientId: ingredients["parmesan"].id, + quantity: 80, + unitId: units["g"].id, + order: 4, + }, ], }); const saladeCaprese = await prisma.recipe.create({ data: { title: "Salade Caprese", - content: "# Salade Caprese\n\nAlterner les tranches de tomates et de mozzarella dans un plat. Parsemer de feuilles de basilic. Arroser d'huile d'olive, saler et poivrer. Servir frais.", + servings: 2, + prepTime: 10, creatorId: alice.id, + steps: { + create: [ + { order: 0, instruction: "Couper les tomates et la mozzarella en tranches regulieres." }, + { + order: 1, + instruction: + "Alterner les tranches de tomates et de mozzarella dans un plat de service.", + }, + { + order: 2, + instruction: + "Parsemer de feuilles de basilic frais, arroser d'huile d'olive, saler et poivrer. Servir frais.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -249,10 +482,34 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: saladeCaprese.id, ingredientId: ingredients["tomate"].id, quantity: "4 grosses", order: 1 }, - { recipeId: saladeCaprese.id, ingredientId: ingredients["mozzarella"].id, quantity: "250g bufala", order: 2 }, - { recipeId: saladeCaprese.id, ingredientId: ingredients["basilic"].id, quantity: "1 bouquet", order: 3 }, - { recipeId: saladeCaprese.id, ingredientId: ingredients["huile d'olive"].id, quantity: "3 c.a.s", order: 4 }, + { + recipeId: saladeCaprese.id, + ingredientId: ingredients["tomate"].id, + quantity: 4, + unitId: units["piece"].id, + order: 1, + }, + { + recipeId: saladeCaprese.id, + ingredientId: ingredients["mozzarella"].id, + quantity: 250, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: saladeCaprese.id, + ingredientId: ingredients["basilic"].id, + quantity: 1, + unitId: units["botte"].id, + order: 3, + }, + { + recipeId: saladeCaprese.id, + ingredientId: ingredients["huile d'olive"].id, + quantity: 3, + unitId: units["cas"].id, + order: 4, + }, ], }); @@ -260,8 +517,37 @@ async function seed() { const painMaison = await prisma.recipe.create({ data: { title: "Pain maison", - content: "# Pain maison\n\nMelanger farine, sel et levure. Ajouter l'eau tiede et petrir 10 minutes. Laisser lever 1h. Faconner, laisser lever 30min. Cuire a 230C pendant 25 minutes avec un bol d'eau dans le four pour la croute.", + servings: 6, + prepTime: 20, + cookTime: 25, + restTime: 90, creatorId: bob.id, + steps: { + create: [ + { + order: 0, + instruction: "Melanger la farine, le sel et la levure dans un grand saladier.", + }, + { + order: 1, + instruction: + "Ajouter l'eau tiede et petrir 10 minutes jusqu'a obtenir une pate souple.", + }, + { + order: 2, + instruction: "Couvrir d'un linge et laisser lever 1h a temperature ambiante.", + }, + { + order: 3, + instruction: "Degazer, faconner le pain et laisser lever encore 30 minutes.", + }, + { + order: 4, + instruction: + "Cuire a 230C pendant 25 minutes avec un bol d'eau dans le four pour une belle croute.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -272,16 +558,49 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: painMaison.id, ingredientId: ingredients["farine"].id, quantity: "500g T65", order: 1 }, - { recipeId: painMaison.id, ingredientId: ingredients["sel"].id, quantity: "10g", order: 2 }, + { + recipeId: painMaison.id, + ingredientId: ingredients["farine"].id, + quantity: 500, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: painMaison.id, + ingredientId: ingredients["sel"].id, + quantity: 10, + unitId: units["g"].id, + order: 2, + }, ], }); const quicheLorraine = await prisma.recipe.create({ data: { title: "Quiche Lorraine", - content: "# Quiche Lorraine\n\nEtaler la pate dans un moule. Faire revenir les lardons. Battre les oeufs avec la creme et le lait, saler, poivrer, muscade. Disposer les lardons sur la pate, verser l'appareil. Cuire 35 minutes a 180C.", + servings: 6, + prepTime: 20, + cookTime: 35, creatorId: bob.id, + steps: { + create: [ + { order: 0, instruction: "Etaler la pate brisee dans un moule a tarte beurre." }, + { order: 1, instruction: "Faire revenir les lardons a la poele sans matiere grasse." }, + { + order: 2, + instruction: + "Battre les oeufs avec la creme fraiche et le lait. Saler, poivrer, ajouter une pincee de muscade.", + }, + { + order: 3, + instruction: "Disposer les lardons sur le fond de tarte, verser l'appareil par-dessus.", + }, + { + order: 4, + instruction: "Cuire 35 minutes a 180C jusqu'a ce que la quiche soit bien doree.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -293,11 +612,41 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: quicheLorraine.id, ingredientId: ingredients["oeufs"].id, quantity: "4", order: 1 }, - { recipeId: quicheLorraine.id, ingredientId: ingredients["creme fraiche"].id, quantity: "20cl", order: 2 }, - { recipeId: quicheLorraine.id, ingredientId: ingredients["lait"].id, quantity: "10cl", order: 3 }, - { recipeId: quicheLorraine.id, ingredientId: ingredients["farine"].id, quantity: "250g (pate)", order: 4 }, - { recipeId: quicheLorraine.id, ingredientId: ingredients["beurre"].id, quantity: "125g (pate)", order: 5 }, + { + recipeId: quicheLorraine.id, + ingredientId: ingredients["oeufs"].id, + quantity: 4, + unitId: units["piece"].id, + order: 1, + }, + { + recipeId: quicheLorraine.id, + ingredientId: ingredients["creme fraiche"].id, + quantity: 20, + unitId: units["cl"].id, + order: 2, + }, + { + recipeId: quicheLorraine.id, + ingredientId: ingredients["lait"].id, + quantity: 10, + unitId: units["cl"].id, + order: 3, + }, + { + recipeId: quicheLorraine.id, + ingredientId: ingredients["farine"].id, + quantity: 250, + unitId: units["g"].id, + order: 4, + }, + { + recipeId: quicheLorraine.id, + ingredientId: ingredients["beurre"].id, + quantity: 125, + unitId: units["g"].id, + order: 5, + }, ], }); @@ -305,8 +654,30 @@ async function seed() { const padThai = await prisma.recipe.create({ data: { title: "Pad Thai express", - content: "# Pad Thai express\n\nFaire tremper les nouilles de riz. Sauter le poulet emince avec l'ail et le gingembre. Ajouter les nouilles egouttees, la sauce soja, le jus de citron et le sucre. Servir avec cacahuetes concassees et coriandre.", + servings: 2, + prepTime: 10, + cookTime: 10, creatorId: charlie.id, + steps: { + create: [ + { + order: 0, + instruction: + "Faire tremper les nouilles de riz dans l'eau chaude selon les instructions du paquet.", + }, + { + order: 1, + instruction: + "Faire sauter le poulet emince avec l'ail et le gingembre dans un wok bien chaud.", + }, + { + order: 2, + instruction: + "Ajouter les nouilles egouttees, la sauce soja, le jus de citron et le sucre. Bien melanger.", + }, + { order: 3, instruction: "Servir avec cacahuetes concassees et coriandre fraiche." }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -318,21 +689,88 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: padThai.id, ingredientId: ingredients["poulet"].id, quantity: "200g", order: 1 }, - { recipeId: padThai.id, ingredientId: ingredients["riz"].id, quantity: "200g nouilles", order: 2 }, - { recipeId: padThai.id, ingredientId: ingredients["sauce soja"].id, quantity: "3 c.a.s", order: 3 }, - { recipeId: padThai.id, ingredientId: ingredients["citron"].id, quantity: "2", order: 4 }, - { recipeId: padThai.id, ingredientId: ingredients["ail"].id, quantity: "3 gousses", order: 5 }, - { recipeId: padThai.id, ingredientId: ingredients["gingembre"].id, quantity: "2cm", order: 6 }, - { recipeId: padThai.id, ingredientId: ingredients["coriandre"].id, quantity: "1 bouquet", order: 7 }, + { + recipeId: padThai.id, + ingredientId: ingredients["poulet"].id, + quantity: 200, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["riz"].id, + quantity: 200, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["sauce soja"].id, + quantity: 3, + unitId: units["cas"].id, + order: 3, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["citron"].id, + quantity: 2, + unitId: units["piece"].id, + order: 4, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["ail"].id, + quantity: 3, + unitId: units["gousse"].id, + order: 5, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["gingembre"].id, + quantity: 2, + unitId: units["piece"].id, + order: 6, + }, + { + recipeId: padThai.id, + ingredientId: ingredients["coriandre"].id, + quantity: 1, + unitId: units["botte"].id, + order: 7, + }, ], }); const ratatouilleExpress = await prisma.recipe.create({ data: { title: "Ratatouille express", - content: "# Ratatouille express\n\nCouper tous les legumes en des. Faire revenir l'oignon et l'ail dans l'huile d'olive. Ajouter aubergine et poivron, cuire 5min. Ajouter courgette et tomate. Assaisonner avec thym et romarin. Laisser mijoter 20 minutes.", + servings: 4, + prepTime: 15, + cookTime: 25, creatorId: charlie.id, + steps: { + create: [ + { order: 0, instruction: "Couper tous les legumes en des de taille reguliere." }, + { + order: 1, + instruction: "Faire revenir l'oignon et l'ail dans l'huile d'olive a feu moyen.", + }, + { + order: 2, + instruction: "Ajouter l'aubergine et le poivron, cuire 5 minutes en remuant.", + }, + { + order: 3, + instruction: + "Ajouter la courgette et la tomate. Assaisonner avec le thym et le romarin.", + }, + { + order: 4, + instruction: + "Laisser mijoter 20 minutes a feu doux. Rectifier l'assaisonnement avant de servir.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -346,14 +784,62 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["aubergine"].id, quantity: "1", order: 1 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["courgette"].id, quantity: "2", order: 2 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["poivron"].id, quantity: "1 rouge, 1 jaune", order: 3 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["tomate"].id, quantity: "4", order: 4 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["oignon"].id, quantity: "1", order: 5 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["ail"].id, quantity: "3 gousses", order: 6 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["thym"].id, quantity: "2 branches", order: 7 }, - { recipeId: ratatouilleExpress.id, ingredientId: ingredients["huile d'olive"].id, quantity: "3 c.a.s", order: 8 }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["aubergine"].id, + quantity: 1, + unitId: units["piece"].id, + order: 1, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["courgette"].id, + quantity: 2, + unitId: units["piece"].id, + order: 2, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["poivron"].id, + quantity: 2, + unitId: units["piece"].id, + order: 3, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["tomate"].id, + quantity: 4, + unitId: units["piece"].id, + order: 4, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["oignon"].id, + quantity: 1, + unitId: units["piece"].id, + order: 5, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["ail"].id, + quantity: 3, + unitId: units["gousse"].id, + order: 6, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["thym"].id, + quantity: 2, + unitId: units["brin"].id, + order: 7, + }, + { + recipeId: ratatouilleExpress.id, + ingredientId: ingredients["huile d'olive"].id, + quantity: 3, + unitId: units["cas"].id, + order: 8, + }, ], }); @@ -361,8 +847,39 @@ async function seed() { const tarteAuCitron = await prisma.recipe.create({ data: { title: "Tarte au citron meringuee", - content: "# Tarte au citron meringuee\n\nPreparer une pate sucree (farine, beurre, sucre, oeuf). Cuire a blanc 15min. Preparer la creme citron : jus de citron, sucre, oeufs, beurre au bain-marie. Verser sur le fond de tarte. Monter les blancs en neige ferme avec sucre, dresser a la poche. Carameliser au chalumeau.", + servings: 8, + prepTime: 40, + cookTime: 30, + restTime: 120, creatorId: diana.id, + steps: { + create: [ + { + order: 0, + instruction: + "Preparer la pate sucree : melanger farine, beurre pommade, sucre et oeuf. Fraiser et filmer, refrigerer 30 minutes.", + }, + { + order: 1, + instruction: "Etaler la pate, foncer le moule et cuire a blanc 15 minutes a 180C.", + }, + { + order: 2, + instruction: + "Preparer la creme citron : chauffer au bain-marie le jus de citron, le sucre, les oeufs et le beurre en remuant jusqu'a epaississement.", + }, + { + order: 3, + instruction: + "Verser la creme citron sur le fond de tarte. Laisser refroidir completement.", + }, + { + order: 4, + instruction: + "Monter les blancs en neige ferme avec le sucre, dresser a la poche sur la tarte et carameliser au chalumeau.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -374,19 +891,73 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: tarteAuCitron.id, ingredientId: ingredients["citron"].id, quantity: "4 (jus + zestes)", order: 1 }, - { recipeId: tarteAuCitron.id, ingredientId: ingredients["sucre"].id, quantity: "200g", order: 2 }, - { recipeId: tarteAuCitron.id, ingredientId: ingredients["oeufs"].id, quantity: "6 (3 entiers + 3 blancs)", order: 3 }, - { recipeId: tarteAuCitron.id, ingredientId: ingredients["beurre"].id, quantity: "150g", order: 4 }, - { recipeId: tarteAuCitron.id, ingredientId: ingredients["farine"].id, quantity: "250g", order: 5 }, + { + recipeId: tarteAuCitron.id, + ingredientId: ingredients["citron"].id, + quantity: 4, + unitId: units["piece"].id, + order: 1, + }, + { + recipeId: tarteAuCitron.id, + ingredientId: ingredients["sucre"].id, + quantity: 200, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: tarteAuCitron.id, + ingredientId: ingredients["oeufs"].id, + quantity: 6, + unitId: units["piece"].id, + order: 3, + }, + { + recipeId: tarteAuCitron.id, + ingredientId: ingredients["beurre"].id, + quantity: 150, + unitId: units["g"].id, + order: 4, + }, + { + recipeId: tarteAuCitron.id, + ingredientId: ingredients["farine"].id, + quantity: 250, + unitId: units["g"].id, + order: 5, + }, ], }); const fondantChocolat = await prisma.recipe.create({ data: { title: "Fondant au chocolat", - content: "# Fondant au chocolat\n\nFondre le chocolat avec le beurre. Battre les oeufs avec le sucre jusqu'a blanchiment. Incorporer le chocolat fondu puis la farine. Verser dans des moules beurres. Cuire 12 minutes a 200C. Le coeur doit rester coulant.", + servings: 4, + prepTime: 15, + cookTime: 12, creatorId: diana.id, + steps: { + create: [ + { + order: 0, + instruction: "Fondre le chocolat avec le beurre au bain-marie ou au micro-ondes.", + }, + { + order: 1, + instruction: "Battre les oeufs avec le sucre jusqu'a blanchiment du melange.", + }, + { + order: 2, + instruction: "Incorporer le chocolat fondu puis la farine tamisee delicatement.", + }, + { order: 3, instruction: "Verser dans des moules individuels beurres et farine." }, + { + order: 4, + instruction: + "Cuire 12 minutes a 200C. Le coeur doit rester coulant. Demouler aussitot.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -399,19 +970,80 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: fondantChocolat.id, ingredientId: ingredients["chocolat"].id, quantity: "200g noir 70%", order: 1 }, - { recipeId: fondantChocolat.id, ingredientId: ingredients["beurre"].id, quantity: "100g", order: 2 }, - { recipeId: fondantChocolat.id, ingredientId: ingredients["oeufs"].id, quantity: "4", order: 3 }, - { recipeId: fondantChocolat.id, ingredientId: ingredients["sucre"].id, quantity: "100g", order: 4 }, - { recipeId: fondantChocolat.id, ingredientId: ingredients["farine"].id, quantity: "50g", order: 5 }, + { + recipeId: fondantChocolat.id, + ingredientId: ingredients["chocolat"].id, + quantity: 200, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: fondantChocolat.id, + ingredientId: ingredients["beurre"].id, + quantity: 100, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: fondantChocolat.id, + ingredientId: ingredients["oeufs"].id, + quantity: 4, + unitId: units["piece"].id, + order: 3, + }, + { + recipeId: fondantChocolat.id, + ingredientId: ingredients["sucre"].id, + quantity: 100, + unitId: units["g"].id, + order: 4, + }, + { + recipeId: fondantChocolat.id, + ingredientId: ingredients["farine"].id, + quantity: 50, + unitId: units["g"].id, + order: 5, + }, ], }); const cremeBrulee = await prisma.recipe.create({ data: { title: "Creme brulee a la vanille", - content: "# Creme brulee a la vanille\n\nChauffer la creme avec la gousse de vanille fendue. Battre les jaunes d'oeufs avec le sucre. Verser la creme chaude sur les jaunes en fouettant. Repartir dans les ramequins. Cuire au bain-marie 45min a 150C. Refroidir puis carameliser le sucre au chalumeau.", + servings: 4, + prepTime: 15, + cookTime: 45, + restTime: 180, creatorId: diana.id, + steps: { + create: [ + { + order: 0, + instruction: + "Chauffer la creme avec la gousse de vanille fendue et grattee. Porter presque a ebullition puis retirer du feu.", + }, + { + order: 1, + instruction: + "Battre les jaunes d'oeufs avec le sucre jusqu'a ce que le melange blanchisse.", + }, + { + order: 2, + instruction: + "Verser la creme chaude sur les jaunes en fouettant energiquement. Filtrer la preparation.", + }, + { + order: 3, + instruction: "Repartir dans les ramequins. Cuire au bain-marie 45 minutes a 150C.", + }, + { + order: 4, + instruction: + "Laisser refroidir puis refrigerer au moins 3h. Avant de servir, saupoudrer de sucre et carameliser au chalumeau.", + }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -423,10 +1055,34 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: cremeBrulee.id, ingredientId: ingredients["creme fraiche"].id, quantity: "50cl", order: 1 }, - { recipeId: cremeBrulee.id, ingredientId: ingredients["oeufs"].id, quantity: "5 jaunes", order: 2 }, - { recipeId: cremeBrulee.id, ingredientId: ingredients["sucre"].id, quantity: "100g + pour carameliser", order: 3 }, - { recipeId: cremeBrulee.id, ingredientId: ingredients["vanille"].id, quantity: "1 gousse", order: 4 }, + { + recipeId: cremeBrulee.id, + ingredientId: ingredients["creme fraiche"].id, + quantity: 50, + unitId: units["cl"].id, + order: 1, + }, + { + recipeId: cremeBrulee.id, + ingredientId: ingredients["oeufs"].id, + quantity: 5, + unitId: units["piece"].id, + order: 2, + }, + { + recipeId: cremeBrulee.id, + ingredientId: ingredients["sucre"].id, + quantity: 100, + unitId: units["g"].id, + order: 3, + }, + { + recipeId: cremeBrulee.id, + ingredientId: ingredients["vanille"].id, + quantity: 1, + unitId: units["piece"].id, + order: 4, + }, ], }); @@ -434,8 +1090,30 @@ async function seed() { const bowlSaumon = await prisma.recipe.create({ data: { title: "Poke bowl au saumon", - content: "# Poke bowl au saumon\n\nCuire le riz et laisser refroidir. Couper le saumon frais en cubes. Preparer la marinade : sauce soja, huile de sesame, gingembre rape. Assembler : riz, saumon marine, avocat, edamame, carotte rapee, graines de sesame.", + servings: 2, + prepTime: 20, + cookTime: 15, creatorId: eve.id, + steps: { + create: [ + { + order: 0, + instruction: "Cuire le riz selon les instructions du paquet et laisser refroidir.", + }, + { order: 1, instruction: "Couper le saumon frais en cubes de 2 cm." }, + { + order: 2, + instruction: + "Preparer la marinade : melanger sauce soja, huile de sesame et gingembre rape. Mariner le saumon 10 minutes.", + }, + { + order: 3, + instruction: + "Assembler les bowls : repartir le riz, le saumon marine, la carotte rapee dans les bols.", + }, + { order: 4, instruction: "Parsemer de graines de sesame et servir frais." }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -448,19 +1126,66 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: bowlSaumon.id, ingredientId: ingredients["saumon"].id, quantity: "200g frais", order: 1 }, - { recipeId: bowlSaumon.id, ingredientId: ingredients["riz"].id, quantity: "150g", order: 2 }, - { recipeId: bowlSaumon.id, ingredientId: ingredients["sauce soja"].id, quantity: "2 c.a.s", order: 3 }, - { recipeId: bowlSaumon.id, ingredientId: ingredients["gingembre"].id, quantity: "1cm", order: 4 }, - { recipeId: bowlSaumon.id, ingredientId: ingredients["carotte"].id, quantity: "1", order: 5 }, + { + recipeId: bowlSaumon.id, + ingredientId: ingredients["saumon"].id, + quantity: 200, + unitId: units["g"].id, + order: 1, + }, + { + recipeId: bowlSaumon.id, + ingredientId: ingredients["riz"].id, + quantity: 150, + unitId: units["g"].id, + order: 2, + }, + { + recipeId: bowlSaumon.id, + ingredientId: ingredients["sauce soja"].id, + quantity: 2, + unitId: units["cas"].id, + order: 3, + }, + { + recipeId: bowlSaumon.id, + ingredientId: ingredients["gingembre"].id, + quantity: 1, + unitId: units["piece"].id, + order: 4, + }, + { + recipeId: bowlSaumon.id, + ingredientId: ingredients["carotte"].id, + quantity: 1, + unitId: units["piece"].id, + order: 5, + }, ], }); const gaspacho = await prisma.recipe.create({ data: { title: "Gaspacho andalou", - content: "# Gaspacho andalou\n\nMixer les tomates bien mures, le concombre, le poivron, l'ail et l'oignon. Ajouter l'huile d'olive, le vinaigre, sel et poivre. Mixer finement. Refrigerer au moins 2h. Servir tres frais avec des croutons.", + servings: 4, + prepTime: 15, + restTime: 120, creatorId: eve.id, + steps: { + create: [ + { + order: 0, + instruction: + "Laver et couper grossierement les tomates, le concombre, le poivron, l'ail et l'oignon.", + }, + { order: 1, instruction: "Mixer le tout finement au blender." }, + { + order: 2, + instruction: "Ajouter l'huile d'olive, le vinaigre, saler et poivrer. Mixer a nouveau.", + }, + { order: 3, instruction: "Refrigerer au moins 2h. Servir tres frais avec des croutons." }, + ], + }, }, }); await prisma.recipeTag.createMany({ @@ -474,11 +1199,41 @@ async function seed() { }); await prisma.recipeIngredient.createMany({ data: [ - { recipeId: gaspacho.id, ingredientId: ingredients["tomate"].id, quantity: "1kg bien mures", order: 1 }, - { recipeId: gaspacho.id, ingredientId: ingredients["poivron"].id, quantity: "1 vert", order: 2 }, - { recipeId: gaspacho.id, ingredientId: ingredients["ail"].id, quantity: "1 gousse", order: 3 }, - { recipeId: gaspacho.id, ingredientId: ingredients["oignon"].id, quantity: "1/2", order: 4 }, - { recipeId: gaspacho.id, ingredientId: ingredients["huile d'olive"].id, quantity: "4 c.a.s", order: 5 }, + { + recipeId: gaspacho.id, + ingredientId: ingredients["tomate"].id, + quantity: 1, + unitId: units["kg"].id, + order: 1, + }, + { + recipeId: gaspacho.id, + ingredientId: ingredients["poivron"].id, + quantity: 1, + unitId: units["piece"].id, + order: 2, + }, + { + recipeId: gaspacho.id, + ingredientId: ingredients["ail"].id, + quantity: 1, + unitId: units["gousse"].id, + order: 3, + }, + { + recipeId: gaspacho.id, + ingredientId: ingredients["oignon"].id, + quantity: 0.5, + unitId: units["piece"].id, + order: 4, + }, + { + recipeId: gaspacho.id, + ingredientId: ingredients["huile d'olive"].id, + quantity: 4, + unitId: units["cas"].id, + order: 5, + }, ], }); diff --git a/backend/src/__tests__/integration/activity.test.ts b/backend/src/__tests__/integration/activity.test.ts index 330abe9d..6b5013c3 100644 --- a/backend/src/__tests__/integration/activity.test.ts +++ b/backend/src/__tests__/integration/activity.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { extractSessionCookie } from "../setup/testHelpers"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Activity Feed API", () => { // ===================================== // Community Activity Feed @@ -24,11 +21,13 @@ describe("Activity Feed API", () => { const suffix = uniqueSuffix(); // Create creator (moderator) via signup - const creatorSignup = await request(app).post("/api/auth/signup").send({ - username: `actcreator_${suffix}`, - email: `actcreator_${suffix}@example.com`, - password: "Test123!Password", - }); + const creatorSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `actcreator_${suffix}`, + email: `actcreator_${suffix}@example.com`, + password: "Test123!Password", + }); creatorCookie = extractSessionCookie(creatorSignup)!; creator = (await testPrisma.user.findFirst({ where: { email: `actcreator_${suffix}@example.com` }, @@ -42,11 +41,13 @@ describe("Activity Feed API", () => { community = createRes.body; // Create member via signup - const memberSignup = await request(app).post("/api/auth/signup").send({ - username: `actmember_${suffix}`, - email: `actmember_${suffix}@example.com`, - password: "Test123!Password", - }); + const memberSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `actmember_${suffix}`, + email: `actmember_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memberSignup)!; member = (await testPrisma.user.findFirst({ where: { email: `actmember_${suffix}@example.com` }, @@ -62,11 +63,13 @@ describe("Activity Feed API", () => { }); // Create non-member - const nonMemberSignup = await request(app).post("/api/auth/signup").send({ - username: `actnonm_${suffix}`, - email: `actnonm_${suffix}@example.com`, - password: "Test123!Password", - }); + const nonMemberSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `actnonm_${suffix}`, + email: `actnonm_${suffix}@example.com`, + password: "Test123!Password", + }); nonMemberCookie = extractSessionCookie(nonMemberSignup)!; _nonMember = (await testPrisma.user.findFirst({ where: { email: `actnonm_${suffix}@example.com` }, @@ -80,7 +83,8 @@ describe("Activity Feed API", () => { .set("Cookie", creatorCookie) .send({ title: "Test Recipe for Activity", - content: "Recipe content", + servings: 4, + steps: [{ instruction: "Recipe content" }], }); const res = await request(app) @@ -107,7 +111,8 @@ describe("Activity Feed API", () => { .set("Cookie", creatorCookie) .send({ title: "Recipe With Full Info", - content: "Content here", + servings: 4, + steps: [{ instruction: "Content here" }], }); const res = await request(app) @@ -134,7 +139,8 @@ describe("Activity Feed API", () => { .set("Cookie", creatorCookie) .send({ title: "Recipe for Member View", - content: "Content", + servings: 4, + steps: [{ instruction: "Content" }], }); // Member should see the activity @@ -155,9 +161,7 @@ describe("Activity Feed API", () => { }); it("should reject unauthenticated requests", async () => { - const res = await request(app).get( - `/api/communities/${community.id}/activity` - ); + const res = await request(app).get(`/api/communities/${community.id}/activity`); expect(res.status).toBe(401); }); @@ -170,7 +174,8 @@ describe("Activity Feed API", () => { .set("Cookie", creatorCookie) .send({ title: `Recipe ${i} for Pagination`, - content: `Content ${i}`, + servings: 4, + steps: [{ instruction: `Content ${i}` }], }); } @@ -203,7 +208,8 @@ describe("Activity Feed API", () => { .set("Cookie", creatorCookie) .send({ title: `Recipe ${i} Sort Test`, - content: `Content ${i}`, + servings: 4, + steps: [{ instruction: `Content ${i}` }], }); } @@ -237,22 +243,26 @@ describe("Activity Feed API", () => { const suffix = uniqueSuffix(); // Create user1 (recipe creator) - const user1Signup = await request(app).post("/api/auth/signup").send({ - username: `myactuser1_${suffix}`, - email: `myactuser1_${suffix}@example.com`, - password: "Test123!Password", - }); + const user1Signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `myactuser1_${suffix}`, + email: `myactuser1_${suffix}@example.com`, + password: "Test123!Password", + }); user1Cookie = extractSessionCookie(user1Signup)!; user1 = (await testPrisma.user.findFirst({ where: { email: `myactuser1_${suffix}@example.com` }, }))!; // Create user2 (proposer) - const user2Signup = await request(app).post("/api/auth/signup").send({ - username: `myactuser2_${suffix}`, - email: `myactuser2_${suffix}@example.com`, - password: "Test123!Password", - }); + const user2Signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `myactuser2_${suffix}`, + email: `myactuser2_${suffix}@example.com`, + password: "Test123!Password", + }); user2Cookie = extractSessionCookie(user2Signup)!; user2 = (await testPrisma.user.findFirst({ where: { email: `myactuser2_${suffix}@example.com` }, @@ -280,15 +290,14 @@ describe("Activity Feed API", () => { .set("Cookie", user1Cookie) .send({ title: "User1 Recipe", - content: "Recipe content for testing", + servings: 4, + steps: [{ instruction: "Recipe content for testing" }], }); communityRecipeId = recipeRes.body.community.id; }); it("should return user's own activity", async () => { - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); expect(res.body.data).toBeDefined(); @@ -309,13 +318,11 @@ describe("Activity Feed API", () => { .set("Cookie", user2Cookie) .send({ proposedTitle: "Proposed Changes", - proposedContent: "Better content", + proposedSteps: [{ instruction: "Better content" }], }); // User1 should see this in their activity - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); @@ -334,20 +341,18 @@ describe("Activity Feed API", () => { .set("Cookie", user2Cookie) .send({ title: "User2 Own Recipe", - content: "User2 content", + servings: 4, + steps: [{ instruction: "User2 content" }], }); // User1's activity should NOT include user2's recipe creation - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); const otherUserRecipe = res.body.data.find( (a: { type: string; recipe?: { id: string } }) => - a.type === "RECIPE_CREATED" && - a.recipe?.id === user2RecipeRes.body.community.id + a.type === "RECIPE_CREATED" && a.recipe?.id === user2RecipeRes.body.community.id ); expect(otherUserRecipe).toBeUndefined(); }); @@ -366,7 +371,8 @@ describe("Activity Feed API", () => { .set("Cookie", user1Cookie) .send({ title: `My Recipe ${i}`, - content: `Content ${i}`, + servings: 4, + steps: [{ instruction: `Content ${i}` }], }); } @@ -380,9 +386,7 @@ describe("Activity Feed API", () => { }); it("should include community info in response", async () => { - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); @@ -402,13 +406,11 @@ describe("Activity Feed API", () => { .set("Cookie", user2Cookie) .send({ proposedTitle: "Proposal on User1 Recipe", - proposedContent: "This is a proposal", + proposedSteps: [{ instruction: "This is a proposal" }], }); // User1 should see VARIANT_PROPOSED in their activity - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); @@ -427,7 +429,7 @@ describe("Activity Feed API", () => { .set("Cookie", user2Cookie) .send({ proposedTitle: "Proposal to Accept", - proposedContent: "This will be accepted", + proposedSteps: [{ instruction: "This will be accepted" }], }); // User1 accepts the proposal @@ -436,9 +438,7 @@ describe("Activity Feed API", () => { .set("Cookie", user1Cookie); // User1 should see PROPOSAL_ACCEPTED in their activity - const res = await request(app) - .get("/api/users/me/activity") - .set("Cookie", user1Cookie); + const res = await request(app).get("/api/users/me/activity").set("Cookie", user1Cookie); expect(res.status).toBe(200); diff --git a/backend/src/__tests__/integration/adminActivity.test.ts b/backend/src/__tests__/integration/adminActivity.test.ts index 1fcf09fc..b8284ddd 100644 --- a/backend/src/__tests__/integration/adminActivity.test.ts +++ b/backend/src/__tests__/integration/adminActivity.test.ts @@ -1,12 +1,9 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; -import { - createTestAdmin, - loginAsAdmin, -} from '../setup/testHelpers'; - -describe('Admin Activity API', () => { +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestAdmin, loginAsAdmin } from "../setup/testHelpers"; + +describe("Admin Activity API", () => { let adminCookie: string; beforeEach(async () => { @@ -17,22 +14,20 @@ describe('Admin Activity API', () => { // ===================================== // GET /api/admin/activity // ===================================== - describe('GET /api/admin/activity', () => { - it('should return activity logs with pagination', async () => { + describe("GET /api/admin/activity", () => { + it("should return activity logs with pagination", async () => { // Creer quelques activites en faisant des operations admin await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({ name: 'activity_test_1' }); + .post("/api/admin/tags") + .set("Cookie", adminCookie) + .send({ name: "activity_test_1" }); await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({ name: 'activity_test_2' }); + .post("/api/admin/tags") + .set("Cookie", adminCookie) + .send({ name: "activity_test_2" }); - const res = await request(app) - .get('/api/admin/activity') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/activity").set("Cookie", adminCookie); expect(res.status).toBe(200); @@ -51,41 +46,43 @@ describe('Admin Activity API', () => { // Verifier la pagination expect(res.body.pagination).toBeDefined(); - expect(typeof res.body.pagination.total).toBe('number'); - expect(typeof res.body.pagination.limit).toBe('number'); - expect(typeof res.body.pagination.offset).toBe('number'); - expect(typeof res.body.pagination.hasMore).toBe('boolean'); + expect(typeof res.body.pagination.total).toBe("number"); + expect(typeof res.body.pagination.limit).toBe("number"); + expect(typeof res.body.pagination.offset).toBe("number"); + expect(typeof res.body.pagination.hasMore).toBe("boolean"); }); - it('should filter activity by type', async () => { + it("should filter activity by type", async () => { // Creer des activites de differents types await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({ name: 'filter_test_tag' }); + .post("/api/admin/tags") + .set("Cookie", adminCookie) + .send({ name: "filter_test_tag" }); const res = await request(app) - .get('/api/admin/activity?type=TAG_CREATED') - .set('Cookie', adminCookie); + .get("/api/admin/activity?type=TAG_CREATED") + .set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.activities.length).toBeGreaterThan(0); - expect(res.body.activities.every((a: { type: string }) => a.type === 'TAG_CREATED')).toBe(true); + expect(res.body.activities.every((a: { type: string }) => a.type === "TAG_CREATED")).toBe( + true + ); }); - it('should respect limit and offset parameters', async () => { + it("should respect limit and offset parameters", async () => { // Creer plusieurs activites for (let i = 0; i < 5; i++) { await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) + .post("/api/admin/tags") + .set("Cookie", adminCookie) .send({ name: `pagination_test_${i}` }); } // Tester le limit const resLimit = await request(app) - .get('/api/admin/activity?limit=2') - .set('Cookie', adminCookie); + .get("/api/admin/activity?limit=2") + .set("Cookie", adminCookie); expect(resLimit.status).toBe(200); expect(resLimit.body.activities.length).toBeLessThanOrEqual(2); @@ -93,16 +90,15 @@ describe('Admin Activity API', () => { // Tester l'offset const resOffset = await request(app) - .get('/api/admin/activity?limit=2&offset=2') - .set('Cookie', adminCookie); + .get("/api/admin/activity?limit=2&offset=2") + .set("Cookie", adminCookie); expect(resOffset.status).toBe(200); expect(resOffset.body.pagination.offset).toBe(2); }); - it('should return 401 without admin authentication', async () => { - const res = await request(app) - .get('/api/admin/activity'); + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/activity"); expect(res.status).toBe(401); }); diff --git a/backend/src/__tests__/integration/adminAuth.test.ts b/backend/src/__tests__/integration/adminAuth.test.ts index 0ed12f2e..20a45ac4 100644 --- a/backend/src/__tests__/integration/adminAuth.test.ts +++ b/backend/src/__tests__/integration/adminAuth.test.ts @@ -1,53 +1,49 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestAdminWithoutTotp, extractSessionCookie, generateTotpCode, -} from '../setup/testHelpers'; +} from "../setup/testHelpers"; -describe('Admin Auth API', () => { +describe("Admin Auth API", () => { // ===================================== // POST /api/admin/auth/login (Step 1) // ===================================== - describe('POST /api/admin/auth/login', () => { - it('should return TOTP required for admin with TOTP already configured', async () => { + describe("POST /api/admin/auth/login", () => { + it("should return TOTP required for admin with TOTP already configured", async () => { const admin = await createTestAdmin({ - email: 'admin@example.com', - password: 'AdminTest123!', + email: "admin@example.com", + password: "AdminTest123!", }); - const res = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); + const res = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); expect(res.status).toBe(200); expect(res.body.requiresTotpSetup).toBe(false); expect(res.body.qrCode).toBeUndefined(); - expect(res.body.message).toContain('TOTP'); + expect(res.body.message).toContain("TOTP"); - // Verifier que le cookie admin.sid est defini - const cookie = extractSessionCookie(res, 'admin.sid'); + // Verifier que le cookie forestmanager_admin_session est défini + const cookie = extractSessionCookie(res, "forestmanager_admin_session"); expect(cookie).not.toBeNull(); }); - it('should return QR code for admin without TOTP configured', async () => { + it("should return QR code for admin without TOTP configured", async () => { const admin = await createTestAdminWithoutTotp({ - email: 'newadmin@example.com', - password: 'AdminTest123!', + email: "newadmin@example.com", + password: "AdminTest123!", }); - const res = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); + const res = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); expect(res.status).toBe(200); expect(res.body.requiresTotpSetup).toBe(true); @@ -55,172 +51,181 @@ describe('Admin Auth API', () => { expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/); }); - it('should return 401 with invalid email', async () => { - const res = await request(app) - .post('/api/admin/auth/login') - .send({ - email: 'nonexistent@example.com', - password: 'AdminTest123!', - }); + it("should return 401 with invalid email", async () => { + const res = await request(app).post("/api/admin/auth/login").send({ + email: "nonexistent@example.com", + password: "AdminTest123!", + }); expect(res.status).toBe(401); - expect(res.body.error).toContain('ADMIN_004'); + expect(res.body.error).toContain("ADMIN_004"); }); - it('should return 401 with invalid password', async () => { + it("should return 401 with invalid password", async () => { const admin = await createTestAdmin(); - const res = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: 'wrongpassword', - }); + const res = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: "wrongpassword", + }); expect(res.status).toBe(401); - expect(res.body.error).toContain('ADMIN_004'); + expect(res.body.error).toContain("ADMIN_004"); + }); + + it("should return 400 when email or password missing", async () => { + const res = await request(app).post("/api/admin/auth/login").send({ + email: "admin@example.com", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_003"); + }); + + it("should return 400 when email is not a string", async () => { + const res = await request(app).post("/api/admin/auth/login").send({ + email: 123, + password: "Test123!Password", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_003"); }); - it('should return 400 when email or password missing', async () => { + it("should return 400 when password is not a string", async () => { const res = await request(app) - .post('/api/admin/auth/login') + .post("/api/admin/auth/login") .send({ - email: 'admin@example.com', + email: "admin@example.com", + password: { obj: true }, }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_003'); + expect(res.body.error).toContain("ADMIN_003"); }); }); // ===================================== // POST /api/admin/auth/totp/verify // ===================================== - describe('POST /api/admin/auth/totp/verify', () => { + describe("POST /api/admin/auth/totp/verify", () => { let admin: Awaited>; let sessionCookie: string | null; beforeEach(async () => { admin = await createTestAdmin({ - email: 'totpadmin@example.com', - password: 'AdminTest123!', + email: "totpadmin@example.com", + password: "AdminTest123!", }); // Login step 1 pour obtenir la session - const loginRes = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); + const loginRes = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); - sessionCookie = extractSessionCookie(loginRes, 'admin.sid'); + sessionCookie = extractSessionCookie(loginRes, "forestmanager_admin_session"); }); - it('should verify valid TOTP code and complete authentication', async () => { + it("should verify valid TOTP code and complete authentication", async () => { const validCode = generateTotpCode(admin.totpSecret); const res = await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) + .post("/api/admin/auth/totp/verify") + .set("Cookie", sessionCookie!) .send({ code: validCode, }); expect(res.status).toBe(200); - expect(res.body.message).toContain('successful'); + expect(res.body.message).toContain("successful"); expect(res.body.admin).toBeDefined(); expect(res.body.admin.email).toBe(admin.email); }); - it('should return 401 with invalid TOTP code', async () => { + it("should return 401 with invalid TOTP code", async () => { const res = await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) + .post("/api/admin/auth/totp/verify") + .set("Cookie", sessionCookie!) .send({ - code: '000000', + code: "000000", }); expect(res.status).toBe(401); - expect(res.body.error).toContain('ADMIN_007'); + expect(res.body.error).toContain("ADMIN_007"); }); - it('should return 401 without session (not authenticated)', async () => { + it("should return 401 without session (not authenticated)", async () => { const validCode = generateTotpCode(admin.totpSecret); - const res = await request(app) - .post('/api/admin/auth/totp/verify') - .send({ - code: validCode, - }); + const res = await request(app).post("/api/admin/auth/totp/verify").send({ + code: validCode, + }); expect(res.status).toBe(401); - expect(res.body.error).toContain('ADMIN_001'); + expect(res.body.error).toContain("ADMIN_001"); }); - it('should block after 3 failed TOTP attempts', async () => { + it("should block after 3 failed TOTP attempts", async () => { // 3 tentatives echouees for (let i = 0; i < 3; i++) { - await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) - .send({ - code: '000000', - }); + await request(app).post("/api/admin/auth/totp/verify").set("Cookie", sessionCookie!).send({ + code: "000000", + }); } // 4eme tentative devrait etre bloquee const res = await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) + .post("/api/admin/auth/totp/verify") + .set("Cookie", sessionCookie!) .send({ - code: '000000', + code: "000000", }); expect(res.status).toBe(429); - expect(res.body.error).toContain('ADMIN_006'); + expect(res.body.error).toContain("ADMIN_006"); }); - it('should return 400 when TOTP code is missing', async () => { + it("should return 400 when TOTP code is missing", async () => { const res = await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) + .post("/api/admin/auth/totp/verify") + .set("Cookie", sessionCookie!) .send({}); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_005'); + expect(res.body.error).toContain("VALIDATION_001"); }); }); // ===================================== // GET /api/admin/auth/me // ===================================== - describe('GET /api/admin/auth/me', () => { - it('should return admin info when fully authenticated', async () => { + describe("GET /api/admin/auth/me", () => { + it("should return admin info when fully authenticated", async () => { const admin = await createTestAdmin(); // Login complet (step 1 + step 2) - const loginRes = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); + const loginRes = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); - const sessionCookie = extractSessionCookie(loginRes, 'admin.sid'); + const sessionCookie = extractSessionCookie(loginRes, "forestmanager_admin_session"); const validCode = generateTotpCode(admin.totpSecret); - await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) + const totpRes = await request(app) + .post("/api/admin/auth/totp/verify") + .set("Cookie", sessionCookie!) .send({ code: validCode, }); + // Capturer le nouveau cookie apres session.regenerate() + const finalCookie = + extractSessionCookie(totpRes, "forestmanager_admin_session") || sessionCookie; + // Verifier /me - const res = await request(app) - .get('/api/admin/auth/me') - .set('Cookie', sessionCookie!); + const res = await request(app).get("/api/admin/auth/me").set("Cookie", finalCookie!); expect(res.status).toBe(200); expect(res.body.admin).toBeDefined(); @@ -228,9 +233,8 @@ describe('Admin Auth API', () => { expect(res.body.admin.password).toBeUndefined(); }); - it('should return 401 when not authenticated', async () => { - const res = await request(app) - .get('/api/admin/auth/me'); + it("should return 401 when not authenticated", async () => { + const res = await request(app).get("/api/admin/auth/me"); expect(res.status).toBe(401); }); @@ -239,40 +243,33 @@ describe('Admin Auth API', () => { // ===================================== // POST /api/admin/auth/logout // ===================================== - describe('POST /api/admin/auth/logout', () => { - it('should logout and destroy admin session', async () => { + describe("POST /api/admin/auth/logout", () => { + it("should logout and destroy admin session", async () => { const admin = await createTestAdmin(); // Login complet - const loginRes = await request(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); + const loginRes = await request(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); - const sessionCookie = extractSessionCookie(loginRes, 'admin.sid'); + const sessionCookie = extractSessionCookie(loginRes, "forestmanager_admin_session"); const validCode = generateTotpCode(admin.totpSecret); - await request(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie!) - .send({ - code: validCode, - }); + await request(app).post("/api/admin/auth/totp/verify").set("Cookie", sessionCookie!).send({ + code: validCode, + }); // Logout const logoutRes = await request(app) - .post('/api/admin/auth/logout') - .set('Cookie', sessionCookie!); + .post("/api/admin/auth/logout") + .set("Cookie", sessionCookie!); expect(logoutRes.status).toBe(200); - expect(logoutRes.body.message).toContain('Logged out'); + expect(logoutRes.body.message).toContain("Logged out"); // Verifier que la session est detruite - const meRes = await request(app) - .get('/api/admin/auth/me') - .set('Cookie', sessionCookie!); + const meRes = await request(app).get("/api/admin/auth/me").set("Cookie", sessionCookie!); expect(meRes.status).toBe(401); }); diff --git a/backend/src/__tests__/integration/adminCommunities.test.ts b/backend/src/__tests__/integration/adminCommunities.test.ts index cefcefc3..f858c989 100644 --- a/backend/src/__tests__/integration/adminCommunities.test.ts +++ b/backend/src/__tests__/integration/adminCommunities.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestUser, createTestCommunity, loginAsAdmin, -} from '../setup/testHelpers'; -import { testPrisma } from '../setup/globalSetup'; +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; -describe('Admin Communities API', () => { +describe("Admin Communities API", () => { let adminCookie: string; beforeEach(async () => { @@ -20,15 +20,13 @@ describe('Admin Communities API', () => { // ===================================== // GET /api/admin/communities // ===================================== - describe('GET /api/admin/communities', () => { - it('should return all communities with counts', async () => { + describe("GET /api/admin/communities", () => { + it("should return all communities with counts", async () => { const user = await createTestUser(); - await createTestCommunity(user.id, { name: 'Community A' }); - await createTestCommunity(user.id, { name: 'Community B' }); + await createTestCommunity(user.id, { name: "Community A" }); + await createTestCommunity(user.id, { name: "Community B" }); - const res = await request(app) - .get('/api/admin/communities') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/communities").set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.communities).toBeDefined(); @@ -36,32 +34,34 @@ describe('Admin Communities API', () => { expect(res.body.communities.length).toBeGreaterThanOrEqual(2); // Verifier la structure - const community = res.body.communities.find((c: { name: string }) => c.name === 'Community A'); + const community = res.body.communities.find( + (c: { name: string }) => c.name === "Community A" + ); expect(community).toBeDefined(); expect(community.id).toBeDefined(); - expect(community.name).toBe('Community A'); - expect(typeof community.memberCount).toBe('number'); - expect(typeof community.recipeCount).toBe('number'); + expect(community.name).toBe("Community A"); + expect(typeof community.memberCount).toBe("number"); + expect(typeof community.recipeCount).toBe("number"); expect(Array.isArray(community.features)).toBe(true); }); - it('should filter communities by search query', async () => { + it("should filter communities by search query", async () => { const user = await createTestUser(); - await createTestCommunity(user.id, { name: 'Alpha Team' }); - await createTestCommunity(user.id, { name: 'Beta Group' }); + await createTestCommunity(user.id, { name: "Alpha Team" }); + await createTestCommunity(user.id, { name: "Beta Group" }); const res = await request(app) - .get('/api/admin/communities?search=alpha') - .set('Cookie', adminCookie); + .get("/api/admin/communities?search=alpha") + .set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.communities.length).toBe(1); - expect(res.body.communities[0].name).toBe('Alpha Team'); + expect(res.body.communities[0].name).toBe("Alpha Team"); }); - it('should exclude deleted communities by default', async () => { + it("should exclude deleted communities by default", async () => { const user = await createTestUser(); - const community = await createTestCommunity(user.id, { name: 'Deleted Community' }); + const community = await createTestCommunity(user.id, { name: "Deleted Community" }); // Soft delete await testPrisma.community.update({ @@ -69,18 +69,15 @@ describe('Admin Communities API', () => { data: { deletedAt: new Date() }, }); - const res = await request(app) - .get('/api/admin/communities') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/communities").set("Cookie", adminCookie); expect(res.status).toBe(200); const found = res.body.communities.find((c: { id: string }) => c.id === community.id); expect(found).toBeUndefined(); }); - it('should return 401 without admin authentication', async () => { - const res = await request(app) - .get('/api/admin/communities'); + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/communities"); expect(res.status).toBe(401); }); @@ -89,99 +86,99 @@ describe('Admin Communities API', () => { // ===================================== // GET /api/admin/communities/:id // ===================================== - describe('GET /api/admin/communities/:id', () => { - it('should return community detail with members and features', async () => { + describe("GET /api/admin/communities/:id", () => { + it("should return community detail with members and features", async () => { const user = await createTestUser(); - const community = await createTestCommunity(user.id, { name: 'Detail Community' }); + const community = await createTestCommunity(user.id, { name: "Detail Community" }); const res = await request(app) .get(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.community).toBeDefined(); expect(res.body.community.id).toBe(community.id); - expect(res.body.community.name).toBe('Detail Community'); + expect(res.body.community.name).toBe("Detail Community"); expect(Array.isArray(res.body.community.members)).toBe(true); expect(Array.isArray(res.body.community.features)).toBe(true); // Verifier le membre (createur) const creator = res.body.community.members.find((m: { id: string }) => m.id === user.id); expect(creator).toBeDefined(); - expect(creator.role).toBe('MODERATOR'); + expect(creator.role).toBe("MODERATOR"); }); - it('should return 404 for non-existent community', async () => { + it("should return 404 for non-existent community", async () => { const res = await request(app) - .get('/api/admin/communities/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie); + .get("/api/admin/communities/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_COM_001'); + expect(res.body.error).toContain("ADMIN_COM_001"); }); }); // ===================================== // PATCH /api/admin/communities/:id // ===================================== - describe('PATCH /api/admin/communities/:id', () => { - it('should rename a community', async () => { + describe("PATCH /api/admin/communities/:id", () => { + it("should rename a community", async () => { const user = await createTestUser(); - const community = await createTestCommunity(user.id, { name: 'Old Name' }); + const community = await createTestCommunity(user.id, { name: "Old Name" }); const res = await request(app) .patch(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie) - .send({ name: 'New Name' }); + .set("Cookie", adminCookie) + .send({ name: "New Name" }); expect(res.status).toBe(200); - expect(res.body.community.name).toBe('New Name'); + expect(res.body.community.name).toBe("New Name"); // Verifier en DB const updated = await testPrisma.community.findUnique({ where: { id: community.id }, }); - expect(updated?.name).toBe('New Name'); + expect(updated?.name).toBe("New Name"); }); - it('should return 404 for non-existent community', async () => { + it("should return 404 for non-existent community", async () => { const res = await request(app) - .patch('/api/admin/communities/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie) - .send({ name: 'New Name' }); + .patch("/api/admin/communities/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie) + .send({ name: "New Name" }); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_COM_001'); + expect(res.body.error).toContain("ADMIN_COM_001"); }); - it('should return 400 when name is empty', async () => { + it("should return 400 when name is empty", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); const res = await request(app) .patch(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie) - .send({ name: '' }); + .set("Cookie", adminCookie) + .send({ name: "" }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_COM_002'); + expect(res.body.error).toContain("ADMIN_COM_002"); }); }); // ===================================== // DELETE /api/admin/communities/:id // ===================================== - describe('DELETE /api/admin/communities/:id', () => { - it('should soft delete a community', async () => { + describe("DELETE /api/admin/communities/:id", () => { + it("should soft delete a community", async () => { const user = await createTestUser(); - const community = await createTestCommunity(user.id, { name: 'To Delete' }); + const community = await createTestCommunity(user.id, { name: "To Delete" }); const res = await request(app) .delete(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.message).toContain('deleted'); + expect(res.body.message).toContain("deleted"); // Verifier en DB (soft delete) const deleted = await testPrisma.community.findUnique({ @@ -191,31 +188,31 @@ describe('Admin Communities API', () => { expect(deleted?.deletedAt).not.toBeNull(); }); - it('should return 404 for non-existent community', async () => { + it("should return 404 for non-existent community", async () => { const res = await request(app) - .delete('/api/admin/communities/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie); + .delete("/api/admin/communities/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_COM_001'); + expect(res.body.error).toContain("ADMIN_COM_001"); }); - it('should return 400 when community already deleted', async () => { + it("should return 400 when community already deleted", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); // Premier delete await request(app) .delete(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); // Deuxieme delete const res = await request(app) .delete(`/api/admin/communities/${community.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_COM_003'); + expect(res.body.error).toContain("ADMIN_COM_003"); }); }); }); diff --git a/backend/src/__tests__/integration/adminDashboard.test.ts b/backend/src/__tests__/integration/adminDashboard.test.ts index 0d07a642..0de918d6 100644 --- a/backend/src/__tests__/integration/adminDashboard.test.ts +++ b/backend/src/__tests__/integration/adminDashboard.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestUser, createTestRecipe, createTestCommunity, loginAsAdmin, -} from '../setup/testHelpers'; +} from "../setup/testHelpers"; -describe('Admin Dashboard API', () => { +describe("Admin Dashboard API", () => { let adminCookie: string; beforeEach(async () => { @@ -20,40 +20,38 @@ describe('Admin Dashboard API', () => { // ===================================== // GET /api/admin/dashboard/stats // ===================================== - describe('GET /api/admin/dashboard/stats', () => { - it('should return dashboard statistics', async () => { + describe("GET /api/admin/dashboard/stats", () => { + it("should return dashboard statistics", async () => { // Creer quelques donnees de test const user = await createTestUser(); await createTestRecipe(user.id); await createTestCommunity(user.id); - const res = await request(app) - .get('/api/admin/dashboard/stats') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/dashboard/stats").set("Cookie", adminCookie); expect(res.status).toBe(200); // Verifier la structure totals expect(res.body.totals).toBeDefined(); - expect(typeof res.body.totals.users).toBe('number'); - expect(typeof res.body.totals.communities).toBe('number'); - expect(typeof res.body.totals.recipes).toBe('number'); - expect(typeof res.body.totals.tags).toBe('number'); - expect(typeof res.body.totals.ingredients).toBe('number'); - expect(typeof res.body.totals.features).toBe('number'); + expect(typeof res.body.totals.users).toBe("number"); + expect(typeof res.body.totals.communities).toBe("number"); + expect(typeof res.body.totals.recipes).toBe("number"); + expect(typeof res.body.totals.tags).toBe("number"); + expect(typeof res.body.totals.ingredients).toBe("number"); + expect(typeof res.body.totals.features).toBe("number"); // Verifier la structure lastWeek expect(res.body.lastWeek).toBeDefined(); - expect(typeof res.body.lastWeek.newUsers).toBe('number'); - expect(typeof res.body.lastWeek.newCommunities).toBe('number'); - expect(typeof res.body.lastWeek.newRecipes).toBe('number'); + expect(typeof res.body.lastWeek.newUsers).toBe("number"); + expect(typeof res.body.lastWeek.newCommunities).toBe("number"); + expect(typeof res.body.lastWeek.newRecipes).toBe("number"); // Verifier la structure topCommunities expect(res.body.topCommunities).toBeDefined(); expect(Array.isArray(res.body.topCommunities)).toBe(true); }); - it('should return correct counts', async () => { + it("should return correct counts", async () => { // Creer des donnees const user1 = await createTestUser(); const user2 = await createTestUser(); @@ -62,9 +60,7 @@ describe('Admin Dashboard API', () => { await createTestRecipe(user2.id); await createTestCommunity(user1.id); - const res = await request(app) - .get('/api/admin/dashboard/stats') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/dashboard/stats").set("Cookie", adminCookie); expect(res.status).toBe(200); @@ -74,29 +70,26 @@ describe('Admin Dashboard API', () => { expect(res.body.totals.communities).toBeGreaterThanOrEqual(1); }); - it('should return 401 without admin authentication', async () => { - const res = await request(app) - .get('/api/admin/dashboard/stats'); + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/dashboard/stats"); expect(res.status).toBe(401); }); - it('should return 401 with user authentication (not admin)', async () => { + it("should return 401 with user authentication (not admin)", async () => { const user = await createTestUser(); // Login en tant que user normal - const loginRes = await request(app) - .post('/api/auth/login') - .send({ - username: user.username, - password: user.password, - }); + const loginRes = await request(app).post("/api/auth/login").send({ + username: user.username, + password: user.password, + }); - const userCookie = loginRes.headers['set-cookie']?.[0]?.split(';')[0]; + const userCookie = loginRes.headers["set-cookie"]?.[0]?.split(";")[0]; const res = await request(app) - .get('/api/admin/dashboard/stats') - .set('Cookie', userCookie || ''); + .get("/api/admin/dashboard/stats") + .set("Cookie", userCookie || ""); expect(res.status).toBe(401); }); diff --git a/backend/src/__tests__/integration/adminFeatures.test.ts b/backend/src/__tests__/integration/adminFeatures.test.ts index ef0a19f2..6a80c133 100644 --- a/backend/src/__tests__/integration/adminFeatures.test.ts +++ b/backend/src/__tests__/integration/adminFeatures.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestUser, createTestCommunity, createTestFeature, loginAsAdmin, -} from '../setup/testHelpers'; -import { testPrisma } from '../setup/globalSetup'; +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; -describe('Admin Features API', () => { +describe("Admin Features API", () => { let adminCookie: string; beforeEach(async () => { @@ -21,14 +21,12 @@ describe('Admin Features API', () => { // ===================================== // GET /api/admin/features // ===================================== - describe('GET /api/admin/features', () => { - it('should return all features with community counts', async () => { - await createTestFeature({ code: 'FEATURE_A', name: 'Feature A' }); - await createTestFeature({ code: 'FEATURE_B', name: 'Feature B' }); + describe("GET /api/admin/features", () => { + it("should return all features with community counts", async () => { + await createTestFeature({ code: "FEATURE_A", name: "Feature A" }); + await createTestFeature({ code: "FEATURE_B", name: "Feature B" }); - const res = await request(app) - .get('/api/admin/features') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/features").set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.features).toBeDefined(); @@ -36,18 +34,17 @@ describe('Admin Features API', () => { expect(res.body.features.length).toBeGreaterThanOrEqual(2); // Verifier la structure - const feature = res.body.features.find((f: { code: string }) => f.code === 'FEATURE_A'); + const feature = res.body.features.find((f: { code: string }) => f.code === "FEATURE_A"); expect(feature).toBeDefined(); expect(feature.id).toBeDefined(); - expect(feature.code).toBe('FEATURE_A'); - expect(feature.name).toBe('Feature A'); - expect(typeof feature.communityCount).toBe('number'); - expect(typeof feature.isDefault).toBe('boolean'); + expect(feature.code).toBe("FEATURE_A"); + expect(feature.name).toBe("Feature A"); + expect(typeof feature.communityCount).toBe("number"); + expect(typeof feature.isDefault).toBe("boolean"); }); - it('should return 401 without admin authentication', async () => { - const res = await request(app) - .get('/api/admin/features'); + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/features"); expect(res.status).toBe(401); }); @@ -56,118 +53,115 @@ describe('Admin Features API', () => { // ===================================== // POST /api/admin/features // ===================================== - describe('POST /api/admin/features', () => { - it('should create a new feature', async () => { - const res = await request(app) - .post('/api/admin/features') - .set('Cookie', adminCookie) - .send({ - code: 'NEW_FEATURE', - name: 'New Feature', - description: 'A test feature', - isDefault: false, - }); + describe("POST /api/admin/features", () => { + it("should create a new feature", async () => { + const res = await request(app).post("/api/admin/features").set("Cookie", adminCookie).send({ + code: "NEW_FEATURE", + name: "New Feature", + description: "A test feature", + isDefault: false, + }); expect(res.status).toBe(201); expect(res.body.feature).toBeDefined(); - expect(res.body.feature.code).toBe('NEW_FEATURE'); - expect(res.body.feature.name).toBe('New Feature'); - expect(res.body.feature.description).toBe('A test feature'); + expect(res.body.feature.code).toBe("NEW_FEATURE"); + expect(res.body.feature.name).toBe("New Feature"); + expect(res.body.feature.description).toBe("A test feature"); expect(res.body.feature.isDefault).toBe(false); // Verifier en DB const feature = await testPrisma.feature.findUnique({ - where: { code: 'NEW_FEATURE' }, + where: { code: "NEW_FEATURE" }, }); expect(feature).not.toBeNull(); }); - it('should return 400 when code is missing', async () => { + it("should return 400 when code is missing", async () => { const res = await request(app) - .post('/api/admin/features') - .set('Cookie', adminCookie) - .send({ name: 'Feature without code' }); + .post("/api/admin/features") + .set("Cookie", adminCookie) + .send({ name: "Feature without code" }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_FEAT_001'); + expect(res.body.error).toContain("ADMIN_FEAT_001"); }); - it('should return 400 when name is missing', async () => { + it("should return 400 when name is missing", async () => { const res = await request(app) - .post('/api/admin/features') - .set('Cookie', adminCookie) - .send({ code: 'CODE_ONLY' }); + .post("/api/admin/features") + .set("Cookie", adminCookie) + .send({ code: "CODE_ONLY" }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_FEAT_002'); + expect(res.body.error).toContain("ADMIN_FEAT_002"); }); - it('should return 409 when feature code already exists', async () => { - await createTestFeature({ code: 'EXISTING', name: 'Existing' }); + it("should return 409 when feature code already exists", async () => { + await createTestFeature({ code: "EXISTING", name: "Existing" }); const res = await request(app) - .post('/api/admin/features') - .set('Cookie', adminCookie) - .send({ code: 'existing', name: 'New Name' }); // Different case + .post("/api/admin/features") + .set("Cookie", adminCookie) + .send({ code: "existing", name: "New Name" }); // Different case expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_FEAT_003'); + expect(res.body.error).toContain("ADMIN_FEAT_003"); }); }); // ===================================== // PATCH /api/admin/features/:id // ===================================== - describe('PATCH /api/admin/features/:id', () => { - it('should update a feature', async () => { + describe("PATCH /api/admin/features/:id", () => { + it("should update a feature", async () => { const feature = await createTestFeature({ - code: 'TO_UPDATE', - name: 'Old Name', + code: "TO_UPDATE", + name: "Old Name", }); const res = await request(app) .patch(`/api/admin/features/${feature.id}`) - .set('Cookie', adminCookie) - .send({ name: 'New Name', isDefault: true }); + .set("Cookie", adminCookie) + .send({ name: "New Name", isDefault: true }); expect(res.status).toBe(200); - expect(res.body.feature.name).toBe('New Name'); + expect(res.body.feature.name).toBe("New Name"); expect(res.body.feature.isDefault).toBe(true); // Verifier en DB const updated = await testPrisma.feature.findUnique({ where: { id: feature.id }, }); - expect(updated?.name).toBe('New Name'); + expect(updated?.name).toBe("New Name"); expect(updated?.isDefault).toBe(true); }); - it('should return 404 for non-existent feature', async () => { + it("should return 404 for non-existent feature", async () => { const res = await request(app) - .patch('/api/admin/features/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie) - .send({ name: 'New Name' }); + .patch("/api/admin/features/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie) + .send({ name: "New Name" }); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_FEAT_004'); + expect(res.body.error).toContain("ADMIN_FEAT_004"); }); }); // ===================================== // POST /api/admin/communities/:communityId/features/:featureId (grant) // ===================================== - describe('POST /api/admin/communities/:communityId/features/:featureId', () => { - it('should grant a feature to a community', async () => { + describe("POST /api/admin/communities/:communityId/features/:featureId", () => { + it("should grant a feature to a community", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); - const feature = await createTestFeature({ code: 'GRANT_TEST', name: 'Grant Test' }); + const feature = await createTestFeature({ code: "GRANT_TEST", name: "Grant Test" }); const res = await request(app) .post(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.message).toContain('granted'); + expect(res.body.message).toContain("granted"); // Verifier en DB const communityFeature = await testPrisma.communityFeature.findUnique({ @@ -182,58 +176,58 @@ describe('Admin Features API', () => { expect(communityFeature?.revokedAt).toBeNull(); }); - it('should return 404 for non-existent community', async () => { - const feature = await createTestFeature({ code: 'GRANT_404', name: 'Grant 404' }); + it("should return 404 for non-existent community", async () => { + const feature = await createTestFeature({ code: "GRANT_404", name: "Grant 404" }); const res = await request(app) - .post(`/api/admin/communities/00000000-0000-0000-0000-000000000000/features/${feature.id}`) - .set('Cookie', adminCookie); + .post(`/api/admin/communities/00000000-0000-4000-8000-000000000000/features/${feature.id}`) + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_COM_001'); + expect(res.body.error).toContain("ADMIN_COM_001"); }); - it('should return 409 when feature already granted', async () => { + it("should return 409 when feature already granted", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); - const feature = await createTestFeature({ code: 'ALREADY_GRANTED', name: 'Already Granted' }); + const feature = await createTestFeature({ code: "ALREADY_GRANTED", name: "Already Granted" }); // Grant d'abord await request(app) .post(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); // Essayer de re-grant const res = await request(app) .post(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_FEAT_005'); + expect(res.body.error).toContain("ADMIN_FEAT_005"); }); }); // ===================================== // DELETE /api/admin/communities/:communityId/features/:featureId (revoke) // ===================================== - describe('DELETE /api/admin/communities/:communityId/features/:featureId', () => { - it('should revoke a feature from a community', async () => { + describe("DELETE /api/admin/communities/:communityId/features/:featureId", () => { + it("should revoke a feature from a community", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); - const feature = await createTestFeature({ code: 'REVOKE_TEST', name: 'Revoke Test' }); + const feature = await createTestFeature({ code: "REVOKE_TEST", name: "Revoke Test" }); // Grant d'abord await request(app) .post(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); // Revoke const res = await request(app) .delete(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.message).toContain('revoked'); + expect(res.body.message).toContain("revoked"); // Verifier en DB (soft revoke) const communityFeature = await testPrisma.communityFeature.findUnique({ @@ -248,17 +242,17 @@ describe('Admin Features API', () => { expect(communityFeature?.revokedAt).not.toBeNull(); }); - it('should return 404 when feature not granted to community', async () => { + it("should return 404 when feature not granted to community", async () => { const user = await createTestUser(); const community = await createTestCommunity(user.id); - const feature = await createTestFeature({ code: 'NOT_GRANTED', name: 'Not Granted' }); + const feature = await createTestFeature({ code: "NOT_GRANTED", name: "Not Granted" }); const res = await request(app) .delete(`/api/admin/communities/${community.id}/features/${feature.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_FEAT_006'); + expect(res.body.error).toContain("ADMIN_FEAT_006"); }); }); }); diff --git a/backend/src/__tests__/integration/adminIngredients.test.ts b/backend/src/__tests__/integration/adminIngredients.test.ts index 70ed50be..9d38b3c6 100644 --- a/backend/src/__tests__/integration/adminIngredients.test.ts +++ b/backend/src/__tests__/integration/adminIngredients.test.ts @@ -1,16 +1,18 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestIngredient, createTestUser, createTestRecipe, + createTestUnit, loginAsAdmin, -} from '../setup/testHelpers'; -import { testPrisma } from '../setup/globalSetup'; +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; +import appEvents from "../../services/eventEmitter"; -describe('Admin Ingredients API', () => { +describe("Admin Ingredients API", () => { let adminCookie: string; beforeEach(async () => { @@ -21,48 +23,86 @@ describe('Admin Ingredients API', () => { // ===================================== // GET /api/admin/ingredients // ===================================== - describe('GET /api/admin/ingredients', () => { - it('should return all ingredients with recipe counts', async () => { - await createTestIngredient('sugar'); - await createTestIngredient('flour'); - await createTestIngredient('butter'); + describe("GET /api/admin/ingredients", () => { + it("should return all ingredients with recipe counts and enriched fields", async () => { + await createTestIngredient("sugar"); + await createTestIngredient("flour"); + await createTestIngredient("butter"); - const res = await request(app) - .get('/api/admin/ingredients') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/ingredients").set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.ingredients).toBeDefined(); 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'); + 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(typeof ingredient.recipeCount).toBe('number'); + 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 () => { - await createTestIngredient('chocolate'); - await createTestIngredient('cheese'); - await createTestIngredient('vanilla'); + it("should filter ingredients by search query", async () => { + await createTestIngredient("chocolate"); + await createTestIngredient("cheese"); + await createTestIngredient("vanilla"); const res = await request(app) - .get('/api/admin/ingredients?search=ch') - .set('Cookie', adminCookie); + .get("/api/admin/ingredients?search=ch") + .set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.ingredients.every((i: { name: string }) => - i.name.toLowerCase().includes('ch') - )).toBe(true); + expect( + res.body.ingredients.every((i: { name: string }) => i.name.toLowerCase().includes("ch")) + ).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); }); }); @@ -70,154 +110,198 @@ describe('Admin Ingredients API', () => { // ===================================== // POST /api/admin/ingredients // ===================================== - describe('POST /api/admin/ingredients', () => { - it('should create a new ingredient', async () => { + describe("POST /api/admin/ingredients", () => { + it("should create a new ingredient as APPROVED", async () => { const res = await request(app) - .post('/api/admin/ingredients') - .set('Cookie', adminCookie) - .send({ name: 'New Test Ingredient' }); + .post("/api/admin/ingredients") + .set("Cookie", adminCookie) + .send({ name: "New Test Ingredient" }); 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' }, + it("should create ingredient with defaultUnitId", async () => { + const unit = await createTestUnit({ + name: "create_unit", + abbreviation: "cru", + category: "WEIGHT", }); - expect(ingredient).not.toBeNull(); + + 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 () => { + it("should return 400 when name is missing", async () => { const res = await request(app) - .post('/api/admin/ingredients') - .set('Cookie', adminCookie) + .post("/api/admin/ingredients") + .set("Cookie", adminCookie) .send({}); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_ING_001'); + expect(res.body.error).toContain("ADMIN_ING_001"); }); - it('should return 409 when ingredient already exists', async () => { - await createTestIngredient('existing'); + it("should return 409 when ingredient already exists", async () => { + await createTestIngredient("existing"); const res = await request(app) - .post('/api/admin/ingredients') - .set('Cookie', adminCookie) - .send({ name: 'Existing' }); // Different case, same ingredient + .post("/api/admin/ingredients") + .set("Cookie", adminCookie) + .send({ name: "Existing" }); expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_ING_002'); + 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-4000-8000-000000000000" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_ING_007"); }); }); // ===================================== // PATCH /api/admin/ingredients/:id // ===================================== - describe('PATCH /api/admin/ingredients/:id', () => { - it('should rename an ingredient', async () => { - const ingredient = await createTestIngredient('oldname'); + describe("PATCH /api/admin/ingredients/:id", () => { + it("should rename an ingredient", async () => { + const ingredient = await createTestIngredient("oldname"); const res = await request(app) .patch(`/api/admin/ingredients/${ingredient.id}`) - .set('Cookie', adminCookie) - .send({ name: 'newname' }); + .set("Cookie", adminCookie) + .send({ name: "newname" }); expect(res.status).toBe(200); - expect(res.body.ingredient.name).toBe('newname'); + expect(res.body.ingredient.name).toBe("newname"); + }); - // Verifier en DB - const updated = await testPrisma.ingredient.findUnique({ - where: { id: ingredient.id }, + 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", }); - expect(updated?.name).toBe('newname'); + 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 () => { + it("should return 404 for non-existent ingredient", async () => { const res = await request(app) - .patch('/api/admin/ingredients/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie) - .send({ name: 'newname' }); + .patch("/api/admin/ingredients/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie) + .send({ name: "newname" }); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_ING_003'); + expect(res.body.error).toContain("ADMIN_ING_003"); }); - it('should return 409 when renaming to existing name', async () => { - const ing1 = await createTestIngredient('first'); - await createTestIngredient('second'); + it("should return 409 when renaming to existing name", async () => { + const ing1 = await createTestIngredient("first"); + await createTestIngredient("second"); const res = await request(app) .patch(`/api/admin/ingredients/${ing1.id}`) - .set('Cookie', adminCookie) - .send({ name: 'second' }); + .set("Cookie", adminCookie) + .send({ name: "second" }); expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_ING_002'); + expect(res.body.error).toContain("ADMIN_ING_002"); }); }); // ===================================== // DELETE /api/admin/ingredients/:id // ===================================== - describe('DELETE /api/admin/ingredients/:id', () => { - it('should delete an ingredient', async () => { - const ingredient = await createTestIngredient('todelete'); + describe("DELETE /api/admin/ingredients/:id", () => { + it("should delete an ingredient", async () => { + const ingredient = await createTestIngredient("todelete"); const res = await request(app) .delete(`/api/admin/ingredients/${ingredient.id}`) - .set('Cookie', adminCookie); + .set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.message).toContain('deleted'); + expect(res.body.message).toContain("deleted"); - // Verifier en DB const deleted = await testPrisma.ingredient.findUnique({ where: { id: ingredient.id }, }); expect(deleted).toBeNull(); }); - it('should return 404 for non-existent ingredient', async () => { + it("should return 404 for non-existent ingredient", async () => { const res = await request(app) - .delete('/api/admin/ingredients/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie); + .delete("/api/admin/ingredients/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_ING_003'); + expect(res.body.error).toContain("ADMIN_ING_003"); }); }); // ===================================== // POST /api/admin/ingredients/:id/merge // ===================================== - describe('POST /api/admin/ingredients/:id/merge', () => { - it('should merge source ingredient into target ingredient', async () => { + describe("POST /api/admin/ingredients/:id/merge", () => { + it("should merge source ingredient into target ingredient", async () => { const user = await createTestUser(); - const sourceIng = await createTestIngredient('source_ing'); - const targetIng = await createTestIngredient('target_ing'); + 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: '100g' }], + ingredients: [{ name: "source_ing", quantity: 100 }], }); const res = await request(app) .post(`/api/admin/ingredients/${sourceIng.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({ targetId: targetIng.id }); expect(res.status).toBe(200); - expect(res.body.message).toContain('merged'); + 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,28 +309,380 @@ describe('Admin Ingredients API', () => { expect(targetWithRecipes?.recipes.length).toBeGreaterThan(0); }); - it('should return 400 when merging ingredient into itself', async () => { - const ingredient = await createTestIngredient('selfmerge'); + 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", + servings: 4, + creatorId: user.id, + steps: { create: [{ order: 0, instruction: "Test" }] }, + }, + }); + const proposal = await testPrisma.recipeUpdateProposal.create({ + data: { + recipeId: recipe.id, + proposerId: user.id, + proposedTitle: "Updated", + proposedSteps: { create: [{ order: 0, instruction: "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"); const res = await request(app) .post(`/api/admin/ingredients/${ingredient.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({ targetId: ingredient.id }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_ING_005'); + expect(res.body.error).toContain("ADMIN_ING_005"); }); - it('should return 400 when targetId is missing', async () => { - const ingredient = await createTestIngredient('notarget'); + it("should return 400 when targetId is missing", async () => { + const ingredient = await createTestIngredient("notarget"); const res = await request(app) .post(`/api/admin/ingredients/${ingredient.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({}); expect(res.status).toBe(400); - 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", + servings: 4, + creatorId: user.id, + steps: { create: [{ order: 0, instruction: "Test" }] }, + }, + }); + 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"); + }); + }); + + // ===================================== + // 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/adminRecipes.test.ts b/backend/src/__tests__/integration/adminRecipes.test.ts new file mode 100644 index 00000000..aed3ccda --- /dev/null +++ b/backend/src/__tests__/integration/adminRecipes.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { + createTestAdmin, + createTestTag, + createTestUser, + createTestRecipe, + loginAsAdmin, +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +describe("Admin Recipes API", () => { + let adminCookie: string; + + beforeEach(async () => { + const admin = await createTestAdmin(); + adminCookie = await loginAsAdmin(admin); + }); + + // ===================================== + // GET /api/admin/tags/:id/recipes + // ===================================== + describe("GET /api/admin/tags/:id/recipes", () => { + it("should return recipes for a tag", async () => { + const user = await createTestUser(); + const tag = await createTestTag("tagrecipes"); + await createTestRecipe(user.id, { title: "Recipe A", tags: ["tagrecipes"] }); + await createTestRecipe(user.id, { title: "Recipe B", tags: ["tagrecipes"] }); + + const res = await request(app) + .get(`/api/admin/tags/${tag.id}/recipes`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.recipes).toBeDefined(); + expect(res.body.recipes.length).toBe(2); + expect(res.body.pagination).toBeDefined(); + expect(res.body.pagination.total).toBe(2); + + const recipe = res.body.recipes[0]; + expect(recipe.id).toBeDefined(); + expect(recipe.title).toBeDefined(); + expect(recipe.creator).toBeDefined(); + expect(recipe.creator.username).toBeDefined(); + }); + + it("should filter out deleted recipes by default", async () => { + const user = await createTestUser(); + await createTestTag("tagfilter"); + const recipe = await createTestRecipe(user.id, { + title: "Active Recipe", + tags: ["tagfilter"], + }); + const deletedRecipe = await createTestRecipe(user.id, { + title: "Deleted Recipe", + tags: ["tagfilter"], + }); + + // Soft delete one recipe + await testPrisma.recipe.update({ + where: { id: deletedRecipe.id }, + data: { deletedAt: new Date() }, + }); + + const tag = await testPrisma.tag.findFirst({ where: { name: "tagfilter" } }); + + const res = await request(app) + .get(`/api/admin/tags/${tag!.id}/recipes`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.recipes.length).toBe(1); + expect(res.body.recipes[0].id).toBe(recipe.id); + }); + + it("should include deleted recipes when includeDeleted=true", async () => { + const user = await createTestUser(); + await createTestTag("tagdeleted"); + await createTestRecipe(user.id, { title: "Active2", tags: ["tagdeleted"] }); + const deletedRecipe = await createTestRecipe(user.id, { + title: "Deleted2", + tags: ["tagdeleted"], + }); + + await testPrisma.recipe.update({ + where: { id: deletedRecipe.id }, + data: { deletedAt: new Date() }, + }); + + const tag = await testPrisma.tag.findFirst({ where: { name: "tagdeleted" } }); + + const res = await request(app) + .get(`/api/admin/tags/${tag!.id}/recipes?includeDeleted=true`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.recipes.length).toBe(2); + }); + + it("should return 404 for non-existent tag", async () => { + const res = await request(app) + .get("/api/admin/tags/00000000-0000-4000-8000-000000000000/recipes") + .set("Cookie", adminCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("ADMIN_REC_001"); + }); + + it("should return 401 without admin authentication", async () => { + const res = await request(app).get( + "/api/admin/tags/00000000-0000-4000-8000-000000000000/recipes" + ); + + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // GET /api/admin/recipes/:recipeId + // ===================================== + describe("GET /api/admin/recipes/:recipeId", () => { + it("should return full recipe detail", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { + title: "Detail Recipe", + tags: ["detailtag"], + ingredients: [{ name: "flour", quantity: 200 }], + steps: [{ instruction: "Mix" }, { instruction: "Bake" }], + }); + + const res = await request(app) + .get(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.recipe).toBeDefined(); + expect(res.body.recipe.title).toBe("Detail Recipe"); + expect(res.body.recipe.creator.username).toBeDefined(); + expect(res.body.recipe.tags).toBeDefined(); + expect(res.body.recipe.tags.length).toBeGreaterThanOrEqual(1); + expect(res.body.recipe.ingredients).toBeDefined(); + expect(res.body.recipe.ingredients.length).toBe(1); + expect(res.body.recipe.ingredients[0].ingredient.name).toBe("flour"); + expect(res.body.recipe.steps).toBeDefined(); + expect(res.body.recipe.steps.length).toBe(2); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .get("/api/admin/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("ADMIN_REC_002"); + }); + + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/recipes/00000000-0000-4000-8000-000000000000"); + + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // PATCH /api/admin/recipes/:recipeId + // ===================================== + describe("PATCH /api/admin/recipes/:recipeId", () => { + it("should update recipe scalar fields", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Old Title", servings: 4 }); + + const res = await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ title: "New Title", servings: 6, prepTime: 15 }); + + expect(res.status).toBe(200); + expect(res.body.recipe.title).toBe("New Title"); + expect(res.body.recipe.servings).toBe(6); + expect(res.body.recipe.prepTime).toBe(15); + + // Verify in DB + const updated = await testPrisma.recipe.findUnique({ where: { id: recipe.id } }); + expect(updated?.title).toBe("New Title"); + expect(updated?.servings).toBe(6); + expect(updated?.prepTime).toBe(15); + }); + + it("should create audit log entry", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Audit Recipe" }); + + await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ title: "Updated Audit Recipe" }); + + const log = await testPrisma.adminActivityLog.findFirst({ + where: { targetId: recipe.id, type: "RECIPE_UPDATED" }, + }); + expect(log).not.toBeNull(); + expect(log?.targetType).toBe("Recipe"); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .patch("/api/admin/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie) + .send({ title: "Test" }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("ADMIN_REC_002"); + }); + + it("should return 400 when title is not a string", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Valid" }); + + const res = await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ title: 123 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_003"); + }); + + it("should return 400 when title is too long", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Valid" }); + + const res = await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ title: "a".repeat(201) }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_003"); + }); + + it("should return 400 when servings is invalid", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Valid" }); + + const res = await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ servings: 0 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_006"); + }); + + it("should return 400 when prepTime is not a number", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Valid" }); + + const res = await request(app) + .patch(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie) + .send({ prepTime: "abc" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_008"); + }); + + it("should return 401 without admin authentication", async () => { + const res = await request(app) + .patch("/api/admin/recipes/00000000-0000-4000-8000-000000000000") + .send({ title: "Test" }); + + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // DELETE /api/admin/recipes/:recipeId + // ===================================== + describe("DELETE /api/admin/recipes/:recipeId", () => { + it("should soft delete a recipe", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "To Delete" }); + + const res = await request(app) + .delete(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.message).toContain("deleted"); + + // Verify in DB + const deleted = await testPrisma.recipe.findUnique({ where: { id: recipe.id } }); + expect(deleted?.deletedAt).not.toBeNull(); + }); + + it("should create audit log entry", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Delete Audit" }); + + await request(app).delete(`/api/admin/recipes/${recipe.id}`).set("Cookie", adminCookie); + + const log = await testPrisma.adminActivityLog.findFirst({ + where: { targetId: recipe.id, type: "RECIPE_DELETED" }, + }); + expect(log).not.toBeNull(); + expect(log?.targetType).toBe("Recipe"); + }); + + it("should return 400 if recipe already deleted", async () => { + const user = await createTestUser(); + const recipe = await createTestRecipe(user.id, { title: "Already Deleted" }); + + // Soft delete first + await testPrisma.recipe.update({ + where: { id: recipe.id }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .delete(`/api/admin/recipes/${recipe.id}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_REC_003"); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .delete("/api/admin/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("ADMIN_REC_002"); + }); + + it("should return 401 without admin authentication", async () => { + const res = await request(app).delete( + "/api/admin/recipes/00000000-0000-4000-8000-000000000000" + ); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/integration/adminTags.test.ts b/backend/src/__tests__/integration/adminTags.test.ts index 2a90cb1e..1831a852 100644 --- a/backend/src/__tests__/integration/adminTags.test.ts +++ b/backend/src/__tests__/integration/adminTags.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestAdmin, createTestTag, createTestUser, createTestRecipe, loginAsAdmin, -} from '../setup/testHelpers'; -import { testPrisma } from '../setup/globalSetup'; +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; -describe('Admin Tags API', () => { +describe("Admin Tags API", () => { let adminCookie: string; beforeEach(async () => { @@ -21,16 +21,14 @@ describe('Admin Tags API', () => { // ===================================== // GET /api/admin/tags // ===================================== - describe('GET /api/admin/tags', () => { - it('should return all tags with recipe counts', async () => { + describe("GET /api/admin/tags", () => { + it("should return all tags with recipe counts", async () => { // Creer des tags de test - await createTestTag('dessert'); - await createTestTag('dinner'); - await createTestTag('breakfast'); + await createTestTag("dessert"); + await createTestTag("dinner"); + await createTestTag("breakfast"); - const res = await request(app) - .get('/api/admin/tags') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/tags").set("Cookie", adminCookie); expect(res.status).toBe(200); expect(res.body.tags).toBeDefined(); @@ -38,173 +36,222 @@ describe('Admin Tags API', () => { expect(res.body.tags.length).toBeGreaterThanOrEqual(3); // Verifier la structure - const tag = res.body.tags.find((t: { name: string }) => t.name === 'dessert'); + const tag = res.body.tags.find((t: { name: string }) => t.name === "dessert"); expect(tag).toBeDefined(); expect(tag.id).toBeDefined(); - expect(tag.name).toBe('dessert'); - expect(typeof tag.recipeCount).toBe('number'); + expect(tag.name).toBe("dessert"); + expect(typeof tag.recipeCount).toBe("number"); }); - it('should filter tags by search query', async () => { - await createTestTag('chocolate'); - await createTestTag('cheese'); - await createTestTag('vanilla'); + it("should filter tags by search query", async () => { + await createTestTag("chocolate"); + await createTestTag("cheese"); + await createTestTag("vanilla"); - const res = await request(app) - .get('/api/admin/tags?search=ch') - .set('Cookie', adminCookie); + const res = await request(app).get("/api/admin/tags?search=ch").set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.tags.every((t: { name: string }) => - t.name.toLowerCase().includes('ch') - )).toBe(true); + expect( + res.body.tags.every((t: { name: string }) => t.name.toLowerCase().includes("ch")) + ).toBe(true); }); - it('should return 401 without admin authentication', async () => { - const res = await request(app) - .get('/api/admin/tags'); + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/tags"); 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(); + }); }); // ===================================== // POST /api/admin/tags // ===================================== - describe('POST /api/admin/tags', () => { - it('should create a new tag', async () => { + describe("POST /api/admin/tags", () => { + it("should create a new tag", async () => { const res = await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({ name: 'New Test Tag' }); + .post("/api/admin/tags") + .set("Cookie", adminCookie) + .send({ name: "New Test Tag" }); expect(res.status).toBe(201); expect(res.body.tag).toBeDefined(); - expect(res.body.tag.name).toBe('new test tag'); // Normalized to lowercase + expect(res.body.tag.name).toBe("new test tag"); // Normalized to lowercase 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(); }); - it('should return 400 when name is missing', async () => { - const res = await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({}); + it("should return 400 when name is missing", async () => { + const res = await request(app).post("/api/admin/tags").set("Cookie", adminCookie).send({}); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_TAG_001'); + expect(res.body.error).toContain("ADMIN_TAG_001"); }); - it('should return 409 when tag already exists', async () => { - await createTestTag('existing'); + it("should return 409 when tag already exists", async () => { + await createTestTag("existing"); const res = await request(app) - .post('/api/admin/tags') - .set('Cookie', adminCookie) - .send({ name: 'Existing' }); // Different case, same tag + .post("/api/admin/tags") + .set("Cookie", adminCookie) + .send({ name: "Existing" }); // Different case, same tag expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_TAG_002'); + expect(res.body.error).toContain("ADMIN_TAG_002"); }); }); // ===================================== // PATCH /api/admin/tags/:id // ===================================== - describe('PATCH /api/admin/tags/:id', () => { - it('should rename a tag', async () => { - const tag = await createTestTag('oldname'); + describe("PATCH /api/admin/tags/:id", () => { + it("should rename a tag", async () => { + const tag = await createTestTag("oldname"); const res = await request(app) .patch(`/api/admin/tags/${tag.id}`) - .set('Cookie', adminCookie) - .send({ name: 'newname' }); + .set("Cookie", adminCookie) + .send({ name: "newname" }); expect(res.status).toBe(200); - expect(res.body.tag.name).toBe('newname'); + expect(res.body.tag.name).toBe("newname"); // Verifier en DB const updated = await testPrisma.tag.findUnique({ where: { id: tag.id } }); - expect(updated?.name).toBe('newname'); + expect(updated?.name).toBe("newname"); }); - it('should return 404 for non-existent tag', async () => { + it("should return 404 for non-existent tag", async () => { const res = await request(app) - .patch('/api/admin/tags/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie) - .send({ name: 'newname' }); + .patch("/api/admin/tags/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie) + .send({ name: "newname" }); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_TAG_003'); + expect(res.body.error).toContain("ADMIN_TAG_003"); }); - it('should return 409 when renaming to existing name', async () => { - const tag1 = await createTestTag('first'); - await createTestTag('second'); + it("should return 409 when renaming to existing name", async () => { + const tag1 = await createTestTag("first"); + await createTestTag("second"); const res = await request(app) .patch(`/api/admin/tags/${tag1.id}`) - .set('Cookie', adminCookie) - .send({ name: 'second' }); + .set("Cookie", adminCookie) + .send({ name: "second" }); expect(res.status).toBe(409); - expect(res.body.error).toContain('ADMIN_TAG_002'); + expect(res.body.error).toContain("ADMIN_TAG_002"); }); }); // ===================================== // DELETE /api/admin/tags/:id // ===================================== - describe('DELETE /api/admin/tags/:id', () => { - it('should delete a tag', async () => { - const tag = await createTestTag('todelete'); + describe("DELETE /api/admin/tags/:id", () => { + it("should delete a tag", async () => { + const tag = await createTestTag("todelete"); - const res = await request(app) - .delete(`/api/admin/tags/${tag.id}`) - .set('Cookie', adminCookie); + const res = await request(app).delete(`/api/admin/tags/${tag.id}`).set("Cookie", adminCookie); expect(res.status).toBe(200); - expect(res.body.message).toContain('deleted'); + expect(res.body.message).toContain("deleted"); // Verifier en DB const deleted = await testPrisma.tag.findUnique({ where: { id: tag.id } }); expect(deleted).toBeNull(); }); - it('should return 404 for non-existent tag', async () => { + it("should return 404 for non-existent tag", async () => { const res = await request(app) - .delete('/api/admin/tags/00000000-0000-0000-0000-000000000000') - .set('Cookie', adminCookie); + .delete("/api/admin/tags/00000000-0000-4000-8000-000000000000") + .set("Cookie", adminCookie); expect(res.status).toBe(404); - expect(res.body.error).toContain('ADMIN_TAG_003'); + expect(res.body.error).toContain("ADMIN_TAG_003"); }); }); // ===================================== // POST /api/admin/tags/:id/merge // ===================================== - describe('POST /api/admin/tags/:id/merge', () => { - it('should merge source tag into target tag', async () => { + describe("POST /api/admin/tags/:id/merge", () => { + it("should merge source tag into target tag", async () => { const user = await createTestUser(); - const sourceTag = await createTestTag('source'); - const targetTag = await createTestTag('target'); + const sourceTag = await createTestTag("source"); + const targetTag = await createTestTag("target"); // Creer une recette avec le tag source - await createTestRecipe(user.id, { tags: ['source'] }); + await createTestRecipe(user.id, { tags: ["source"] }); const res = await request(app) .post(`/api/admin/tags/${sourceTag.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({ targetId: targetTag.id }); expect(res.status).toBe(200); - expect(res.body.message).toContain('merged'); + expect(res.body.message).toContain("merged"); // Verifier que le source est supprime const deletedSource = await testPrisma.tag.findUnique({ @@ -220,28 +267,28 @@ describe('Admin Tags API', () => { expect(targetWithRecipes?.recipes.length).toBeGreaterThan(0); }); - it('should return 400 when merging tag into itself', async () => { - const tag = await createTestTag('selfmerge'); + it("should return 400 when merging tag into itself", async () => { + const tag = await createTestTag("selfmerge"); const res = await request(app) .post(`/api/admin/tags/${tag.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({ targetId: tag.id }); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_TAG_005'); + expect(res.body.error).toContain("ADMIN_TAG_005"); }); - it('should return 400 when targetId is missing', async () => { - const tag = await createTestTag('notarget'); + it("should return 400 when targetId is missing", async () => { + const tag = await createTestTag("notarget"); const res = await request(app) .post(`/api/admin/tags/${tag.id}/merge`) - .set('Cookie', adminCookie) + .set("Cookie", adminCookie) .send({}); expect(res.status).toBe(400); - expect(res.body.error).toContain('ADMIN_TAG_004'); + expect(res.body.error).toContain("VALIDATION_001"); }); }); }); diff --git a/backend/src/__tests__/integration/adminUnits.test.ts b/backend/src/__tests__/integration/adminUnits.test.ts new file mode 100644 index 00000000..63e1dd73 --- /dev/null +++ b/backend/src/__tests__/integration/adminUnits.test.ts @@ -0,0 +1,470 @@ +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"); + }); + + it("should reject name too long (> 50 chars)", async () => { + const res = await request(app) + .post("/api/admin/units") + .set("Cookie", adminCookie) + .send({ name: "a".repeat(51), abbreviation: "tl", category: "WEIGHT" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_UNIT_001"); + }); + + it("should reject abbreviation too long (> 10 chars)", async () => { + const res = await request(app) + .post("/api/admin/units") + .set("Cookie", adminCookie) + .send({ name: "valid_name", abbreviation: "a".repeat(11), category: "WEIGHT" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("ADMIN_UNIT_002"); + }); + + it("should reject non-integer sortOrder", async () => { + const res = await request(app) + .post("/api/admin/units") + .set("Cookie", adminCookie) + .send({ name: "sort_test", abbreviation: "st", category: "WEIGHT", sortOrder: "abc" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + }); + + // ===================================== + // 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-4000-8000-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", + servings: 4, + creatorId: user.id, + steps: { create: [{ order: 0, instruction: "Test" }] }, + }, + }); + 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-4000-8000-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/auth.test.ts b/backend/src/__tests__/integration/auth.test.ts index 0dc80363..d2960af2 100644 --- a/backend/src/__tests__/integration/auth.test.ts +++ b/backend/src/__tests__/integration/auth.test.ts @@ -1,26 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; -import { createTestUser, extractSessionCookie } from '../setup/testHelpers'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, extractSessionCookie } from "../setup/testHelpers"; -describe('Auth API', () => { +describe("Auth API", () => { // ===================================== // POST /api/auth/signup // ===================================== - describe('POST /api/auth/signup', () => { - it('should create a new user with valid data', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'newuser', - email: 'newuser@example.com', - password: 'Test123!Password', - }); + describe("POST /api/auth/signup", () => { + it("should create a new user with valid data", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "newuser", + email: "newuser@example.com", + password: "Test123!Password", + }); expect(res.status).toBe(201); expect(res.body.user).toBeDefined(); - expect(res.body.user.username).toBe('newuser'); - expect(res.body.user.email).toBe('newuser@example.com'); + expect(res.body.user.username).toBe("newuser"); + expect(res.body.user.email).toBe("newuser@example.com"); expect(res.body.user.password).toBeUndefined(); // Verifier que le cookie de session est defini @@ -28,135 +26,166 @@ describe('Auth API', () => { expect(cookie).not.toBeNull(); }); - it('should return 400 when username is missing', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - email: 'test@example.com', - password: 'Test123!Password', - }); + it("should return 400 when username is missing", async () => { + const res = await request(app).post("/api/auth/signup").send({ + email: "test@example.com", + password: "Test123!Password", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_002'); + expect(res.body.error).toContain("AUTH_002"); }); - it('should return 400 when email is missing', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'testuser', - password: 'Test123!Password', - }); + it("should return 400 when email is missing", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "testuser", + password: "Test123!Password", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_002'); + expect(res.body.error).toContain("AUTH_002"); }); - it('should return 400 when password is missing', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'testuser', - email: 'test@example.com', - }); + it("should return 400 when password is missing", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "testuser", + email: "test@example.com", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_002'); + expect(res.body.error).toContain("AUTH_002"); }); - it('should return 400 when email format is invalid', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'testuser', - email: 'invalid-email', - password: 'Test123!Password', - }); + it("should return 400 when email format is invalid", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "testuser", + email: "invalid-email", + password: "Test123!Password", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_003'); + expect(res.body.error).toContain("AUTH_003"); }); - it('should return 400 when username is too short', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'ab', - email: 'test@example.com', - password: 'Test123!Password', - }); + it("should return 400 when username is too short", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "ab", + email: "test@example.com", + password: "Test123!Password", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_004'); + expect(res.body.error).toContain("AUTH_004"); }); - it('should return 400 when password is too short', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'testuser', - email: 'test@example.com', - password: 'short', - }); + it("should return 400 when password is too short", async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "testuser", + email: "test@example.com", + password: "short", + }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_005'); + expect(res.body.error).toContain("AUTH_005"); }); - it('should return 409 when username already exists', async () => { + it("should return 409 when username already exists", async () => { // Creer un utilisateur existant - await createTestUser({ username: 'existinguser' }); + await createTestUser({ username: "existinguser" }); - const res = await request(app) - .post('/api/auth/signup') - .send({ - username: 'existinguser', - email: 'new@example.com', - password: 'Test123!Password', - }); + const res = await request(app).post("/api/auth/signup").send({ + username: "existinguser", + email: "new@example.com", + password: "Test123!Password", + }); expect(res.status).toBe(409); - expect(res.body.error).toContain('AUTH_006'); + expect(res.body.error).toContain("AUTH_006"); }); - it('should return 409 when email already exists', async () => { + it("should return 409 when email already exists", async () => { // Creer un utilisateur existant - await createTestUser({ email: 'existing@example.com' }); + await createTestUser({ email: "existing@example.com" }); + + const res = await request(app).post("/api/auth/signup").send({ + username: "newuser", + email: "existing@example.com", + password: "Test123!Password", + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("AUTH_007"); + }); + + it("should return 400 when username is not a string", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ username: 123, email: "test@example.com", password: "Test123!Password" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when email is not a string", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ username: "testuser", email: { invalid: true }, password: "Test123!Password" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when password is not a string", async () => { const res = await request(app) - .post('/api/auth/signup') + .post("/api/auth/signup") + .send({ username: "testuser", email: "test@example.com", password: ["array"] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when username is too long", async () => { + const res = await request(app) + .post("/api/auth/signup") .send({ - username: 'newuser', - email: 'existing@example.com', - password: 'Test123!Password', + username: "a".repeat(31), + email: "test@example.com", + password: "Test123!Password", }); - expect(res.status).toBe(409); - expect(res.body.error).toContain('AUTH_007'); + expect(res.status).toBe(400); + expect(res.body.error).toContain("AUTH_004"); + }); + + it("should return 400 when password is too long", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ username: "testuser", email: "test@example.com", password: "a".repeat(129) }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("AUTH_005"); }); }); // ===================================== // POST /api/auth/login // ===================================== - describe('POST /api/auth/login', () => { + describe("POST /api/auth/login", () => { let testUser: Awaited>; beforeEach(async () => { testUser = await createTestUser({ - username: 'loginuser', - email: 'login@example.com', - password: 'Test123!Password', + username: "loginuser", + email: "login@example.com", + password: "Test123!Password", }); }); - it('should login with valid credentials', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({ - username: testUser.username, - password: testUser.password, - }); + it("should login with valid credentials", async () => { + const res = await request(app).post("/api/auth/login").send({ + username: testUser.username, + password: testUser.password, + }); expect(res.status).toBe(200); expect(res.body.user).toBeDefined(); @@ -168,102 +197,103 @@ describe('Auth API', () => { expect(cookie).not.toBeNull(); }); - it('should return 401 with invalid password', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({ - username: testUser.username, - password: 'wrongpassword', - }); + it("should return 401 with invalid password", async () => { + const res = await request(app).post("/api/auth/login").send({ + username: testUser.username, + password: "wrongpassword", + }); expect(res.status).toBe(401); - expect(res.body.error).toContain('AUTH_008'); + expect(res.body.error).toContain("AUTH_008"); }); - it('should return 401 with non-existent username', async () => { - const res = await request(app) - .post('/api/auth/login') - .send({ - username: 'nonexistent', - password: 'Test123!Password', - }); + it("should return 401 with non-existent username", async () => { + const res = await request(app).post("/api/auth/login").send({ + username: "nonexistent", + password: "Test123!Password", + }); expect(res.status).toBe(401); - expect(res.body.error).toContain('AUTH_008'); + expect(res.body.error).toContain("AUTH_008"); + }); + + it("should return 400 when credentials are missing", async () => { + const res = await request(app).post("/api/auth/login").send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("AUTH_002"); + }); + + it("should return 400 when username is not a string", async () => { + const res = await request(app) + .post("/api/auth/login") + .send({ username: 123, password: "Test123!Password" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); }); - it('should return 400 when credentials are missing', async () => { + it("should return 400 when password is not a string", async () => { const res = await request(app) - .post('/api/auth/login') - .send({}); + .post("/api/auth/login") + .send({ username: "testuser", password: { obj: true } }); expect(res.status).toBe(400); - expect(res.body.error).toContain('AUTH_002'); + expect(res.body.error).toContain("VALIDATION_001"); }); }); // ===================================== // GET /api/auth/me // ===================================== - describe('GET /api/auth/me', () => { - it('should return user info when authenticated', async () => { + describe("GET /api/auth/me", () => { + it("should return user info when authenticated", async () => { // Creer un utilisateur et obtenir le cookie de session - const signupRes = await request(app) - .post('/api/auth/signup') - .send({ - username: 'meuser', - email: 'me@example.com', - password: 'Test123!Password', - }); + const signupRes = await request(app).post("/api/auth/signup").send({ + username: "meuser", + email: "me@example.com", + password: "Test123!Password", + }); const cookie = extractSessionCookie(signupRes); - const res = await request(app) - .get('/api/auth/me') - .set('Cookie', cookie!); + const res = await request(app).get("/api/auth/me").set("Cookie", cookie!); expect(res.status).toBe(200); expect(res.body.user).toBeDefined(); - expect(res.body.user.username).toBe('meuser'); + expect(res.body.user.username).toBe("meuser"); }); - it('should return 401 when not authenticated', async () => { - const res = await request(app) - .get('/api/auth/me'); + it("should return 401 when not authenticated", async () => { + const res = await request(app).get("/api/auth/me"); expect(res.status).toBe(401); - expect(res.body.error).toContain('AUTH_001'); + expect(res.body.error).toContain("AUTH_001"); }); }); // ===================================== // POST /api/auth/logout // ===================================== - describe('POST /api/auth/logout', () => { - it('should logout and destroy session', async () => { + describe("POST /api/auth/logout", () => { + it("should logout and destroy session", async () => { // Creer un utilisateur et obtenir le cookie de session - const signupRes = await request(app) - .post('/api/auth/signup') - .send({ - username: 'logoutuser', - email: 'logout@example.com', - password: 'Test123!Password', - }); + const signupRes = await request(app).post("/api/auth/signup").send({ + username: "logoutuser", + email: "logout@example.com", + password: "Test123!Password", + }); const cookie = extractSessionCookie(signupRes); // Logout - const logoutRes = await request(app) - .post('/api/auth/logout') - .set('Cookie', cookie!); + const logoutRes = await request(app).post("/api/auth/logout").set("Cookie", cookie!); expect(logoutRes.status).toBe(200); - expect(logoutRes.body.message).toBe('Logged out successfully'); + expect(logoutRes.body.message).toBe("Logged out successfully"); // Verifier que la session est detruite - const meRes = await request(app) - .get('/api/auth/me') - .set('Cookie', cookie!); + const meRes = await request(app).get("/api/auth/me").set("Cookie", cookie!); expect(meRes.status).toBe(401); }); diff --git a/backend/src/__tests__/integration/communities.test.ts b/backend/src/__tests__/integration/communities.test.ts index 00e44a32..e3fab913 100644 --- a/backend/src/__tests__/integration/communities.test.ts +++ b/backend/src/__tests__/integration/communities.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { - extractSessionCookie, -} from "../setup/testHelpers"; +import { extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; describe("Communities API", () => { @@ -23,13 +21,10 @@ describe("Communities API", () => { }); it("should create a new community with valid data", async () => { - const res = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "Test Community", - description: "A test community description", - }); + const res = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "Test Community", + description: "A test community description", + }); expect(res.status).toBe(201); expect(res.body.id).toBeDefined(); @@ -39,12 +34,9 @@ describe("Communities API", () => { }); it("should create a community without description", async () => { - const res = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "No Description Community", - }); + const res = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "No Description Community", + }); expect(res.status).toBe(201); expect(res.body.name).toBe("No Description Community"); @@ -52,12 +44,9 @@ describe("Communities API", () => { }); it("should add creator as MODERATOR", async () => { - const res = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "Creator Test Community", - }); + const res = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "Creator Test Community", + }); expect(res.status).toBe(201); @@ -73,21 +62,15 @@ describe("Communities API", () => { }); it("should return 400 when name is missing", async () => { - const res = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({}); + const res = await request(app).post("/api/communities").set("Cookie", userCookie).send({}); expect(res.status).toBe(400); }); it("should return 400 when name is too short", async () => { - const res = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "AB", - }); + const res = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "AB", + }); expect(res.status).toBe(400); expect(res.body.error).toContain("at least 3"); @@ -143,9 +126,7 @@ describe("Communities API", () => { }); it("should return empty list when user has no communities", async () => { - const res = await request(app) - .get("/api/communities") - .set("Cookie", userCookie); + const res = await request(app).get("/api/communities").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data).toEqual([]); @@ -153,17 +134,12 @@ describe("Communities API", () => { it("should return user's communities with role and counts", async () => { // Create a community - const createRes = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "My Community", - description: "My community description", - }); + const createRes = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "My Community", + description: "My community description", + }); - const res = await request(app) - .get("/api/communities") - .set("Cookie", userCookie); + const res = await request(app).get("/api/communities").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); @@ -188,9 +164,7 @@ describe("Communities API", () => { data: { deletedAt: new Date() }, }); - const res = await request(app) - .get("/api/communities") - .set("Cookie", userCookie); + const res = await request(app).get("/api/communities").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(0); @@ -229,13 +203,10 @@ describe("Communities API", () => { it("should return community details for a member", async () => { // Create a community - const createRes = await request(app) - .post("/api/communities") - .set("Cookie", userCookie) - .send({ - name: "Detail Community", - description: "Community for detail tests", - }); + const createRes = await request(app).post("/api/communities").set("Cookie", userCookie).send({ + name: "Detail Community", + description: "Community for detail tests", + }); const res = await request(app) .get(`/api/communities/${createRes.body.id}`) @@ -269,7 +240,7 @@ describe("Communities API", () => { it("should return 404 for non-existent community", async () => { const res = await request(app) - .get("/api/communities/00000000-0000-0000-0000-000000000000") + .get("/api/communities/00000000-0000-4000-8000-000000000000") .set("Cookie", userCookie); expect(res.status).toBe(404); @@ -295,9 +266,7 @@ describe("Communities API", () => { }); it("should return 401 when not authenticated", async () => { - const res = await request(app).get( - "/api/communities/00000000-0000-0000-0000-000000000000" - ); + const res = await request(app).get("/api/communities/00000000-0000-4000-8000-000000000000"); expect(res.status).toBe(401); }); @@ -478,7 +447,7 @@ describe("Communities API", () => { it("should return 401 when not authenticated", async () => { const res = await request(app) - .patch("/api/communities/00000000-0000-0000-0000-000000000000") + .patch("/api/communities/00000000-0000-4000-8000-000000000000") .send({ name: "Test" }); expect(res.status).toBe(401); diff --git a/backend/src/__tests__/integration/communityImage.test.ts b/backend/src/__tests__/integration/communityImage.test.ts new file mode 100644 index 00000000..e9c793bb --- /dev/null +++ b/backend/src/__tests__/integration/communityImage.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, createTestCommunity, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +// Mock storageService (pas de MinIO en CI) +vi.mock("../../services/storageService", () => ({ + generatePresignedUploadUrl: vi.fn().mockResolvedValue("https://minio.test/presigned"), + validateUploadedFile: vi.fn().mockResolvedValue(null), + deleteObject: vi.fn().mockResolvedValue(undefined), + headObject: vi.fn().mockResolvedValue({ contentType: "image/webp", contentLength: 50000 }), +})); + +import { validateUploadedFile } from "../../services/storageService"; + +const NOT_FOUND_UUID = "00000000-0000-4000-8000-000000000000"; + +describe("Community Image API", () => { + let moderator: Awaited>; + let moderatorCookie: string | null; + let member: Awaited>; + let memberCookie: string | null; + let nonMember: Awaited>; + let nonMemberCookie: string | null; + let community: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Moderateur (createur de la communaute) + moderator = await createTestUser(); + const modLogin = await request(app) + .post("/api/auth/login") + .send({ username: moderator.username, password: moderator.password }); + moderatorCookie = extractSessionCookie(modLogin); + + // Membre simple + member = await createTestUser(); + const memLogin = await request(app) + .post("/api/auth/login") + .send({ username: member.username, password: member.password }); + memberCookie = extractSessionCookie(memLogin); + + // Non-membre + nonMember = await createTestUser(); + const nmLogin = await request(app) + .post("/api/auth/login") + .send({ username: nonMember.username, password: nonMember.password }); + nonMemberCookie = extractSessionCookie(nmLogin); + + // Communaute avec moderateur + community = await createTestCommunity(moderator.id); + + // Ajouter le membre simple + await testPrisma.userCommunity.create({ + data: { + userId: member.id, + communityId: community.id, + role: "MEMBER", + }, + }); + }); + + // ===================================== + // POST /api/communities/:communityId/upload-url + // ===================================== + describe("POST /api/communities/:communityId/upload-url", () => { + it("should return presigned URL for moderator", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/upload-url`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(200); + expect(res.body.uploadUrl).toBe("https://minio.test/presigned"); + expect(res.body.imageKey).toBe(`communities/${community.id}/avatar.webp`); + }); + + it("should return 403 for simple member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/upload-url`) + .set("Cookie", memberCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/upload-url`) + .set("Cookie", nonMemberCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent community", async () => { + const res = await request(app) + .post(`/api/communities/${NOT_FOUND_UUID}/upload-url`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(404); + }); + + it("should return 401 without auth", async () => { + const res = await request(app).post(`/api/communities/${community.id}/upload-url`); + + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // POST /api/communities/:communityId/confirm-upload + // ===================================== + describe("POST /api/communities/:communityId/confirm-upload", () => { + it("should confirm upload and save imageKey", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/confirm-upload`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(200); + expect(res.body.imageKey).toBe(`communities/${community.id}/avatar.webp`); + expect(res.body.imageUrl).toBeDefined(); + }); + + it("should return 400 when file validation fails", async () => { + vi.mocked(validateUploadedFile).mockResolvedValueOnce("Invalid file type"); + + const res = await request(app) + .post(`/api/communities/${community.id}/confirm-upload`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("COMMUNITY_006"); + }); + + it("should return 403 for simple member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/confirm-upload`) + .set("Cookie", memberCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent community", async () => { + const res = await request(app) + .post(`/api/communities/${NOT_FOUND_UUID}/confirm-upload`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(404); + }); + }); + + // ===================================== + // DELETE /api/communities/:communityId/image + // ===================================== + describe("DELETE /api/communities/:communityId/image", () => { + it("should delete image and return 204", async () => { + // Confirmer un upload d'abord + await request(app) + .post(`/api/communities/${community.id}/confirm-upload`) + .set("Cookie", moderatorCookie!); + + const res = await request(app) + .delete(`/api/communities/${community.id}/image`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(204); + }); + + it("should return 204 even if no image exists (idempotent)", async () => { + const res = await request(app) + .delete(`/api/communities/${community.id}/image`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(204); + }); + + it("should return 403 for simple member", async () => { + const res = await request(app) + .delete(`/api/communities/${community.id}/image`) + .set("Cookie", memberCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent community", async () => { + const res = await request(app) + .delete(`/api/communities/${NOT_FOUND_UUID}/image`) + .set("Cookie", moderatorCookie!); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/__tests__/integration/communityRecipes.test.ts b/backend/src/__tests__/integration/communityRecipes.test.ts index 5a924413..b449532d 100644 --- a/backend/src/__tests__/integration/communityRecipes.test.ts +++ b/backend/src/__tests__/integration/communityRecipes.test.ts @@ -1,14 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { - extractSessionCookie, -} from "../setup/testHelpers"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Community Recipes API", () => { let moderator: { id: string; username: string; email: string }; let moderatorCookie: string; @@ -22,11 +17,13 @@ describe("Community Recipes API", () => { const suffix = uniqueSuffix(); // Create moderator via signup - const modSignup = await request(app).post("/api/auth/signup").send({ - username: `crmod_${suffix}`, - email: `crmod_${suffix}@example.com`, - password: "Test123!Password", - }); + const modSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `crmod_${suffix}`, + email: `crmod_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(modSignup)!; moderator = (await testPrisma.user.findFirst({ where: { email: `crmod_${suffix}@example.com` }, @@ -40,11 +37,13 @@ describe("Community Recipes API", () => { community = createRes.body; // Create member via signup, add to community - const memSignup = await request(app).post("/api/auth/signup").send({ - username: `crmem_${suffix}`, - email: `crmem_${suffix}@example.com`, - password: "Test123!Password", - }); + const memSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `crmem_${suffix}`, + email: `crmem_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memSignup)!; member = (await testPrisma.user.findFirst({ where: { email: `crmem_${suffix}@example.com` }, @@ -60,11 +59,13 @@ describe("Community Recipes API", () => { }); // Create non-member via signup - const nonMemSignup = await request(app).post("/api/auth/signup").send({ - username: `crnonm_${suffix}`, - email: `crnonm_${suffix}@example.com`, - password: "Test123!Password", - }); + const nonMemSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `crnonm_${suffix}`, + email: `crnonm_${suffix}@example.com`, + password: "Test123!Password", + }); nonMemberCookie = extractSessionCookie(nonMemSignup)!; _nonMember = (await testPrisma.user.findFirst({ where: { email: `crnonm_${suffix}@example.com` }, @@ -81,7 +82,8 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Tarte aux pommes", - content: "Faire une tarte avec des pommes", + servings: 4, + steps: [{ instruction: "Faire une tarte avec des pommes" }], }); expect(res.status).toBe(201); @@ -106,11 +108,12 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Recette complete", - content: "Contenu detaille", + servings: 6, + steps: [{ instruction: "Contenu detaille" }], tags: ["dessert", "rapide"], ingredients: [ - { name: "sucre", quantity: "100g" }, - { name: "farine", quantity: "200g" }, + { name: "sucre", quantity: 100 }, + { name: "farine", quantity: 200 }, ], }); @@ -131,7 +134,8 @@ describe("Community Recipes API", () => { .set("Cookie", moderatorCookie) .send({ title: "Recette du mod", - content: "Contenu du mod", + servings: 4, + steps: [{ instruction: "Contenu du mod" }], }); expect(res.status).toBe(201); @@ -143,23 +147,38 @@ describe("Community Recipes API", () => { .post(`/api/communities/${community.id}/recipes`) .set("Cookie", memberCookie) .send({ - content: "Contenu", + servings: 4, + steps: [{ instruction: "Contenu" }], }); expect(res.status).toBe(400); expect(res.body.error).toContain("RECIPE_003"); }); - it("should return 400 when content is missing", async () => { + it("should return 400 when steps is missing", async () => { const res = await request(app) .post(`/api/communities/${community.id}/recipes`) .set("Cookie", memberCookie) .send({ title: "Titre", + servings: 4, }); expect(res.status).toBe(400); - expect(res.body.error).toContain("RECIPE_004"); + expect(res.body.error).toContain("RECIPE_007"); + }); + + it("should return 400 when servings is missing", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie) + .send({ + title: "Titre", + steps: [{ instruction: "Step" }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_006"); }); it("should return 403 for non-member", async () => { @@ -168,7 +187,8 @@ describe("Community Recipes API", () => { .set("Cookie", nonMemberCookie) .send({ title: "Recette", - content: "Contenu", + servings: 4, + steps: [{ instruction: "Contenu" }], }); expect(res.status).toBe(403); @@ -179,7 +199,8 @@ describe("Community Recipes API", () => { .post(`/api/communities/${community.id}/recipes`) .send({ title: "Recette", - content: "Contenu", + servings: 4, + steps: [{ instruction: "Contenu" }], }); expect(res.status).toBe(401); @@ -191,7 +212,8 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Recette avec log", - content: "Contenu", + servings: 4, + steps: [{ instruction: "Contenu" }], }); expect(res.status).toBe(201); @@ -209,6 +231,143 @@ 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", + servings: 4, + steps: [{ instruction: "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", + servings: 4, + steps: [{ instruction: "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", + servings: 4, + steps: [{ instruction: "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 reutilise le meme tag COMMUNITY PENDING (pas de doublon global) + const personalTags = res.body.personal.tags; + expect(personalTags).toHaveLength(1); + expect(personalTags[0].name).toBe("brand_new_tag"); + expect(personalTags[0].scope).toBe("COMMUNITY"); + expect(personalTags[0].status).toBe("PENDING"); + }); + + 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", + servings: 4, + steps: [{ instruction: "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", servings: 4, steps: [{ instruction: "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", servings: 4, steps: [{ instruction: "c" }], tags }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("TAG_003"); + }); + }); + // ===================================== // GET /api/communities/:communityId/recipes // ===================================== @@ -218,14 +377,15 @@ describe("Community Recipes API", () => { await request(app) .post(`/api/communities/${community.id}/recipes`) .set("Cookie", memberCookie) - .send({ title: "Recette 1", content: "Contenu 1" }); + .send({ title: "Recette 1", servings: 4, steps: [{ instruction: "Contenu 1" }] }); await request(app) .post(`/api/communities/${community.id}/recipes`) .set("Cookie", memberCookie) .send({ title: "Recette 2", - content: "Contenu 2", + servings: 4, + steps: [{ instruction: "Contenu 2" }], tags: ["dessert"], }); @@ -234,7 +394,8 @@ describe("Community Recipes API", () => { .set("Cookie", moderatorCookie) .send({ title: "Gateau chocolat", - content: "Contenu 3", + servings: 4, + steps: [{ instruction: "Contenu 3" }], tags: ["dessert", "chocolat"], }); }); @@ -263,6 +424,19 @@ describe("Community Recipes API", () => { expect(recipe.creator.username).toBeDefined(); }); + it("should include servings and times in list response", async () => { + const res = await request(app) + .get(`/api/communities/${community.id}/recipes`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + const recipe = res.body.data[0]; + expect(recipe).toHaveProperty("servings"); + expect(recipe).toHaveProperty("prepTime"); + expect(recipe).toHaveProperty("cookTime"); + expect(recipe).toHaveProperty("restTime"); + }); + it("should respect limit parameter", async () => { const res = await request(app) .get(`/api/communities/${community.id}/recipes?limit=2`) @@ -302,8 +476,7 @@ describe("Community Recipes API", () => { }); it("should return 401 when not authenticated", async () => { - const res = await request(app) - .get(`/api/communities/${community.id}/recipes`); + const res = await request(app).get(`/api/communities/${community.id}/recipes`); expect(res.status).toBe(401); }); @@ -321,9 +494,10 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Recette detail", - content: "Contenu detail", + servings: 4, + steps: [{ instruction: "Contenu detail" }], tags: ["tag1"], - ingredients: [{ name: "ingredient1", quantity: "50g" }], + ingredients: [{ name: "ingredient1", quantity: 50 }], }); communityRecipeId = createRes.body.community.id; }); @@ -373,7 +547,8 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Recette a modifier", - content: "Contenu original", + servings: 4, + steps: [{ instruction: "Contenu original" }], }); communityRecipeId = createRes.body.community.id; }); @@ -439,7 +614,8 @@ describe("Community Recipes API", () => { .set("Cookie", memberCookie) .send({ title: "Recette a supprimer", - content: "Contenu", + servings: 4, + steps: [{ instruction: "Contenu" }], }); communityRecipeId = createRes.body.community.id; personalRecipeId = createRes.body.personal.id; @@ -460,9 +636,7 @@ describe("Community Recipes API", () => { }); it("should NOT delete personal recipe when deleting community recipe", async () => { - await request(app) - .delete(`/api/recipes/${communityRecipeId}`) - .set("Cookie", memberCookie); + await request(app).delete(`/api/recipes/${communityRecipeId}`).set("Cookie", memberCookie); // Personal recipe should still exist const getRes = await request(app) @@ -473,9 +647,7 @@ describe("Community Recipes API", () => { }); it("should NOT delete community recipe when deleting personal recipe", async () => { - await request(app) - .delete(`/api/recipes/${personalRecipeId}`) - .set("Cookie", memberCookie); + await request(app).delete(`/api/recipes/${personalRecipeId}`).set("Cookie", memberCookie); // Community recipe should still exist const getRes = await request(app) @@ -501,17 +673,13 @@ describe("Community Recipes API", () => { }); it("should not appear in community recipes list after delete", async () => { - await request(app) - .delete(`/api/recipes/${communityRecipeId}`) - .set("Cookie", memberCookie); + await request(app).delete(`/api/recipes/${communityRecipeId}`).set("Cookie", memberCookie); const listRes = await request(app) .get(`/api/communities/${community.id}/recipes`) .set("Cookie", memberCookie); - const found = listRes.body.data.find( - (r: { id: string }) => r.id === communityRecipeId - ); + const found = listRes.body.data.find((r: { id: string }) => r.id === communityRecipeId); expect(found).toBeUndefined(); }); }); diff --git a/backend/src/__tests__/integration/communityTags.test.ts b/backend/src/__tests__/integration/communityTags.test.ts new file mode 100644 index 00000000..651273b5 --- /dev/null +++ b/backend/src/__tests__/integration/communityTags.test.ts @@ -0,0 +1,712 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +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-4000-8000-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", servings: 4, steps: [{ instruction: "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", servings: 4, steps: [{ instruction: "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); + }); + }); + + // ===================================== + // T13.2 - Moderateur ne peut pas toucher aux tags globaux + // ===================================== + describe("T13.2 - Moderator cannot modify global tags", () => { + it("should return 403 TAG_005 when renaming a global tag", async () => { + const globalTag = await testPrisma.tag.create({ + data: { name: "dessert", scope: "GLOBAL", status: "APPROVED" }, + }); + + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/${globalTag.id}`) + .set("Cookie", moderatorCookie) + .send({ name: "renamed_dessert" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("TAG_005"); + + // Verifier que le tag n'a pas ete modifie + const unchanged = await testPrisma.tag.findUnique({ where: { id: globalTag.id } }); + expect(unchanged?.name).toBe("dessert"); + }); + + it("should return 403 TAG_005 when deleting a global tag", async () => { + const globalTag = await testPrisma.tag.create({ + data: { name: "global_nodelete", scope: "GLOBAL", status: "APPROVED" }, + }); + + const res = await request(app) + .delete(`/api/communities/${community.id}/tags/${globalTag.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("TAG_005"); + + // Verifier que le tag existe toujours + const stillExists = await testPrisma.tag.findUnique({ where: { id: globalTag.id } }); + expect(stillExists).not.toBeNull(); + }); + }); + + // ===================================== + // T13.3 - Moderateur ne peut agir que sur sa communaute + // ===================================== + describe("T13.3 - Moderator cannot manage tags of another community", () => { + let otherModeratorCookie: string; + let communityTag: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Creer un autre utilisateur moderateur d'une autre communaute + const otherModSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `othermod_${suffix}`, + email: `othermod_${suffix}@example.com`, + password: "Test123!Password", + }); + otherModeratorCookie = extractSessionCookie(otherModSignup)!; + + await request(app) + .post("/api/communities") + .set("Cookie", otherModeratorCookie) + .send({ name: `Other Community ${suffix}` }); + // Creer un tag dans la communaute principale + communityTag = await testPrisma.tag.create({ + data: { + name: `tag_${suffix}`, + scope: "COMMUNITY", + status: "PENDING", + communityId: community.id, + }, + }); + }); + + it("should return 403 when listing tags of a community where not a member", async () => { + const res = await request(app) + .get(`/api/communities/${community.id}/tags`) + .set("Cookie", otherModeratorCookie); + + expect(res.status).toBe(403); + }); + + it("should return 403 when creating a tag in a community where not a member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags`) + .set("Cookie", otherModeratorCookie) + .send({ name: "hijack_tag" }); + + expect(res.status).toBe(403); + }); + + it("should return 403 when renaming a tag in a community where not a member", async () => { + const res = await request(app) + .patch(`/api/communities/${community.id}/tags/${communityTag.id}`) + .set("Cookie", otherModeratorCookie) + .send({ name: "hijack_rename" }); + + expect(res.status).toBe(403); + }); + + it("should return 403 when deleting a tag in a community where not a member", async () => { + const res = await request(app) + .delete(`/api/communities/${community.id}/tags/${communityTag.id}`) + .set("Cookie", otherModeratorCookie); + + expect(res.status).toBe(403); + }); + + it("should return 403 when approving a tag in a community where not a member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${communityTag.id}/approve`) + .set("Cookie", otherModeratorCookie); + + expect(res.status).toBe(403); + }); + + it("should return 403 when rejecting a tag in a community where not a member", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/tags/${communityTag.id}/reject`) + .set("Cookie", otherModeratorCookie); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/backend/src/__tests__/integration/imageCleanup.test.ts b/backend/src/__tests__/integration/imageCleanup.test.ts new file mode 100644 index 00000000..1c0de52f --- /dev/null +++ b/backend/src/__tests__/integration/imageCleanup.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createTestUser, createTestRecipe, createTestCommunity } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +// Mock storageService +vi.mock("../../services/storageService", () => ({ + deleteObject: vi.fn().mockResolvedValue(undefined), +})); + +import { deleteObject } from "../../services/storageService"; +import { cleanupOrphanImages } from "../../jobs/imageCleanup"; + +describe("Image Cleanup Job", () => { + let user: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + user = await createTestUser(); + }); + + it("should delete images from recipes soft-deleted > 7 days", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + const recipe = await createTestRecipe(user.id, { imageKey: "recipes/abc/cover.webp" }); + await testPrisma.recipe.update({ + where: { id: recipe.id }, + data: { deletedAt: oldDate, imageKey: "recipes/abc/cover.webp" }, + }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(1); + expect(deleteObject).toHaveBeenCalledWith("recipes/abc/cover.webp"); + + const updated = await testPrisma.recipe.findUnique({ where: { id: recipe.id } }); + expect(updated!.imageKey).toBeNull(); + }); + + it("should delete images from communities soft-deleted > 7 days", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + const community = await createTestCommunity(user.id); + await testPrisma.community.update({ + where: { id: community.id }, + data: { deletedAt: oldDate, imageKey: "communities/abc/avatar.webp" }, + }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(1); + expect(deleteObject).toHaveBeenCalledWith("communities/abc/avatar.webp"); + + const updated = await testPrisma.community.findUnique({ where: { id: community.id } }); + expect(updated!.imageKey).toBeNull(); + }); + + it("should NOT delete images from recently soft-deleted items (< 7 days)", async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 3); + + const recipe = await createTestRecipe(user.id, { imageKey: "recipes/recent/cover.webp" }); + await testPrisma.recipe.update({ + where: { id: recipe.id }, + data: { deletedAt: recentDate, imageKey: "recipes/recent/cover.webp" }, + }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(0); + expect(deleteObject).not.toHaveBeenCalled(); + }); + + it("should NOT delete images from active (non-deleted) items", async () => { + await createTestRecipe(user.id, { imageKey: "recipes/active/cover.webp" }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(0); + expect(deleteObject).not.toHaveBeenCalled(); + }); + + it("should skip items without imageKey", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + const recipe = await createTestRecipe(user.id); + await testPrisma.recipe.update({ + where: { id: recipe.id }, + data: { deletedAt: oldDate }, + }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(0); + expect(deleteObject).not.toHaveBeenCalled(); + }); + + it("should return 0 when nothing to clean", async () => { + const deleted = await cleanupOrphanImages(); + expect(deleted).toBe(0); + }); + + it("should handle both recipes and communities in one run", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + const recipe = await createTestRecipe(user.id, { imageKey: "recipes/r1/cover.webp" }); + await testPrisma.recipe.update({ + where: { id: recipe.id }, + data: { deletedAt: oldDate, imageKey: "recipes/r1/cover.webp" }, + }); + + const community = await createTestCommunity(user.id); + await testPrisma.community.update({ + where: { id: community.id }, + data: { deletedAt: oldDate, imageKey: "communities/c1/avatar.webp" }, + }); + + const deleted = await cleanupOrphanImages(); + + expect(deleted).toBe(2); + expect(deleteObject).toHaveBeenCalledTimes(2); + }); + + it("should continue on individual item failure", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + const recipe1 = await createTestRecipe(user.id, { imageKey: "recipes/fail/cover.webp" }); + await testPrisma.recipe.update({ + where: { id: recipe1.id }, + data: { deletedAt: oldDate, imageKey: "recipes/fail/cover.webp" }, + }); + + const recipe2 = await createTestRecipe(user.id, { imageKey: "recipes/ok/cover.webp" }); + await testPrisma.recipe.update({ + where: { id: recipe2.id }, + data: { deletedAt: oldDate, imageKey: "recipes/ok/cover.webp" }, + }); + + // Premiere suppression echoue, deuxieme reussit + vi.mocked(deleteObject) + .mockRejectedValueOnce(new Error("S3 error")) + .mockResolvedValueOnce(undefined); + + const deleted = await cleanupOrphanImages(); + + // Seulement 1 succes (l'autre a echoue mais n'a pas stoppe le job) + expect(deleted).toBe(1); + expect(deleteObject).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/__tests__/integration/ingredients.test.ts b/backend/src/__tests__/integration/ingredients.test.ts index c345073f..8018ad0a 100644 --- a/backend/src/__tests__/integration/ingredients.test.ts +++ b/backend/src/__tests__/integration/ingredients.test.ts @@ -1,13 +1,9 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; -import { - createTestUser, - createTestRecipe, - extractSessionCookie, -} from '../setup/testHelpers'; - -describe('Ingredients API', () => { +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, createTestRecipe, extractSessionCookie } from "../setup/testHelpers"; + +describe("Ingredients API", () => { let userCookie: string; let userId: string; @@ -16,12 +12,10 @@ describe('Ingredients API', () => { userId = user.id; // Login - const loginRes = await request(app) - .post('/api/auth/login') - .send({ - username: user.username, - password: user.password, - }); + const loginRes = await request(app).post("/api/auth/login").send({ + username: user.username, + password: user.password, + }); userCookie = extractSessionCookie(loginRes)!; }); @@ -29,57 +23,49 @@ describe('Ingredients API', () => { // ===================================== // GET /api/ingredients // ===================================== - describe('GET /api/ingredients', () => { - it('should return ingredients with recipe counts for authenticated user', async () => { + describe("GET /api/ingredients", () => { + it("should return ingredients with recipe counts for authenticated user", async () => { // 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 }, ], }); - const res = await request(app) - .get('/api/ingredients') - .set('Cookie', userCookie); + const res = await request(app).get("/api/ingredients").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data).toBeDefined(); expect(Array.isArray(res.body.data)).toBe(true); // Verifier la structure - const flourIngredient = res.body.data.find((i: { name: string }) => i.name === 'flour'); + const flourIngredient = res.body.data.find((i: { name: string }) => i.name === "flour"); expect(flourIngredient).toBeDefined(); expect(flourIngredient.id).toBeDefined(); expect(flourIngredient.recipeCount).toBe(2); // 2 recettes avec cet ingredient }); - it('should filter ingredients by search query', async () => { + it("should filter ingredients by search query", async () => { await createTestRecipe(userId, { - ingredients: [ - { name: 'chocolate' }, - { name: 'cheese' }, - { name: 'vanilla' }, - ], + ingredients: [{ name: "chocolate" }, { name: "cheese" }, { name: "vanilla" }], }); - const res = await request(app) - .get('/api/ingredients?search=ch') - .set('Cookie', userCookie); + const res = await request(app).get("/api/ingredients?search=ch").set("Cookie", userCookie); expect(res.status).toBe(200); - expect(res.body.data.every((i: { name: string }) => - i.name.toLowerCase().includes('ch') - )).toBe(true); + expect( + res.body.data.every((i: { name: string }) => i.name.toLowerCase().includes("ch")) + ).toBe(true); }); - it('should respect limit parameter', async () => { + it("should respect limit parameter", async () => { // Creer plusieurs ingredients const ingredients = []; for (let i = 0; i < 10; i++) { @@ -87,38 +73,35 @@ describe('Ingredients API', () => { } await createTestRecipe(userId, { ingredients }); - const res = await request(app) - .get('/api/ingredients?limit=5') - .set('Cookie', userCookie); + const res = await request(app).get("/api/ingredients?limit=5").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data.length).toBeLessThanOrEqual(5); }); - it('should only count recipes owned by the authenticated user', async () => { + it("should only count recipes owned by the authenticated user", async () => { const otherUser = await createTestUser(); // Creer des recettes avec le meme ingredient par differents users await createTestRecipe(userId, { - ingredients: [{ name: 'shared_ingredient' }], + ingredients: [{ name: "shared_ingredient" }], }); await createTestRecipe(otherUser.id, { - ingredients: [{ name: 'shared_ingredient' }], + ingredients: [{ name: "shared_ingredient" }], }); - const res = await request(app) - .get('/api/ingredients') - .set('Cookie', userCookie); + const res = await request(app).get("/api/ingredients").set("Cookie", userCookie); expect(res.status).toBe(200); - const sharedIngredient = res.body.data.find((i: { name: string }) => i.name === 'shared_ingredient'); + const sharedIngredient = res.body.data.find( + (i: { name: string }) => i.name === "shared_ingredient" + ); expect(sharedIngredient).toBeDefined(); expect(sharedIngredient.recipeCount).toBe(1); // Seulement la recette de l'user connecte }); - it('should return 401 without authentication', async () => { - const res = await request(app) - .get('/api/ingredients'); + it("should return 401 without authentication", async () => { + const res = await request(app).get("/api/ingredients"); expect(res.status).toBe(401); }); diff --git a/backend/src/__tests__/integration/invitations.test.ts b/backend/src/__tests__/integration/invitations.test.ts index 98f9fed9..8d142127 100644 --- a/backend/src/__tests__/integration/invitations.test.ts +++ b/backend/src/__tests__/integration/invitations.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; import { + uniqueSuffix, createTestUser, createTestCommunity, createTestInvite, @@ -9,9 +10,6 @@ import { } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -// Helper to generate unique suffix -const uniqueSuffix = () => `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Invitations API", () => { // ===================================== // POST /api/communities/:communityId/invites @@ -28,11 +26,13 @@ describe("Invitations API", () => { const suffix = uniqueSuffix(); // Create moderator and get cookie - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `invitemoderator_${suffix}`, - email: `invitemoderator_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `invitemoderator_${suffix}`, + email: `invitemoderator_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(signupRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `invitemoderator_${suffix}@example.com` }, @@ -46,11 +46,13 @@ describe("Invitations API", () => { community = createRes.body; // Create member user - const memberSignupRes = await request(app).post("/api/auth/signup").send({ - username: `invitemember_${suffix}`, - email: `invitemember_${suffix}@example.com`, - password: "Test123!Password", - }); + const memberSignupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `invitemember_${suffix}`, + email: `invitemember_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memberSignupRes)!; member = (await testPrisma.user.findFirst({ where: { email: `invitemember_${suffix}@example.com` }, @@ -132,11 +134,13 @@ describe("Invitations API", () => { it("should return 403 when user is not a member", async () => { const suffix2 = uniqueSuffix(); - const outsiderRes = await request(app).post("/api/auth/signup").send({ - username: `outsider_${suffix2}`, - email: `outsider_${suffix2}@example.com`, - password: "Test123!Password", - }); + const outsiderRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `outsider_${suffix2}`, + email: `outsider_${suffix2}@example.com`, + password: "Test123!Password", + }); const outsiderCookie = extractSessionCookie(outsiderRes)!; const res = await request(app) @@ -212,6 +216,16 @@ describe("Invitations API", () => { expect(res.status).toBe(400); }); + + it("should return 400 when email format is invalid", async () => { + const res = await request(app) + .post(`/api/communities/${community.id}/invites`) + .set("Cookie", moderatorCookie) + .send({ email: "not-an-email" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("AUTH_003"); + }); }); // ===================================== @@ -228,11 +242,13 @@ describe("Invitations API", () => { const suffix = uniqueSuffix(); // Create moderator - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `listmoderator_${suffix}`, - email: `listmoderator_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `listmoderator_${suffix}`, + email: `listmoderator_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(signupRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `listmoderator_${suffix}@example.com` }, @@ -246,11 +262,13 @@ describe("Invitations API", () => { community = createRes.body; // Create member - const memberRes = await request(app).post("/api/auth/signup").send({ - username: `listmember_${suffix}`, - email: `listmember_${suffix}@example.com`, - password: "Test123!Password", - }); + const memberRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `listmember_${suffix}`, + email: `listmember_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memberRes)!; const member = (await testPrisma.user.findFirst({ where: { email: `listmember_${suffix}@example.com` }, @@ -348,11 +366,13 @@ describe("Invitations API", () => { const suffix = uniqueSuffix(); // Create moderator - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `cancelmoderator_${suffix}`, - email: `cancelmoderator_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `cancelmoderator_${suffix}`, + email: `cancelmoderator_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(signupRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `cancelmoderator_${suffix}@example.com` }, @@ -408,7 +428,7 @@ describe("Invitations API", () => { it("should return 404 when invite not found (INVITE_001)", async () => { const res = await request(app) - .delete(`/api/communities/${community.id}/invites/00000000-0000-0000-0000-000000000000`) + .delete(`/api/communities/${community.id}/invites/00000000-0000-4000-8000-000000000000`) .set("Cookie", moderatorCookie); expect(res.status).toBe(404); @@ -451,11 +471,13 @@ describe("Invitations API", () => { }); // Create invitee and login - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `myinvitesinvitee_${suffix}`, - email: `myinvitesinvitee_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `myinvitesinvitee_${suffix}`, + email: `myinvitesinvitee_${suffix}@example.com`, + password: "Test123!Password", + }); inviteeCookie = extractSessionCookie(signupRes)!; invitee = (await testPrisma.user.findFirst({ where: { email: `myinvitesinvitee_${suffix}@example.com` }, @@ -465,9 +487,7 @@ describe("Invitations API", () => { it("should return PENDING invites by default", async () => { await createTestInvite(community.id, moderator.id, invitee.id, "PENDING"); - const res = await request(app) - .get("/api/users/me/invites") - .set("Cookie", inviteeCookie); + const res = await request(app).get("/api/users/me/invites").set("Cookie", inviteeCookie); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); @@ -497,9 +517,7 @@ describe("Invitations API", () => { data: { deletedAt: new Date() }, }); - const res = await request(app) - .get("/api/users/me/invites") - .set("Cookie", inviteeCookie); + const res = await request(app).get("/api/users/me/invites").set("Cookie", inviteeCookie); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(0); @@ -537,22 +555,26 @@ describe("Invitations API", () => { }); // Create invitee and login - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `acceptinvitee_${suffix}`, - email: `acceptinvitee_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `acceptinvitee_${suffix}`, + email: `acceptinvitee_${suffix}@example.com`, + password: "Test123!Password", + }); inviteeCookie = extractSessionCookie(signupRes)!; invitee = (await testPrisma.user.findFirst({ where: { email: `acceptinvitee_${suffix}@example.com` }, }))!; // Create other user - const otherRes = await request(app).post("/api/auth/signup").send({ - username: `acceptotheruser_${suffix}`, - email: `acceptotheruser_${suffix}@example.com`, - password: "Test123!Password", - }); + const otherRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `acceptotheruser_${suffix}`, + email: `acceptotheruser_${suffix}@example.com`, + password: "Test123!Password", + }); otherUserCookie = extractSessionCookie(otherRes)!; }); @@ -587,9 +609,7 @@ describe("Invitations API", () => { it("should log INVITE_ACCEPTED and USER_JOINED activities", async () => { const invite = await createTestInvite(community.id, moderator.id, invitee.id, "PENDING"); - await request(app) - .post(`/api/invites/${invite.id}/accept`) - .set("Cookie", inviteeCookie); + await request(app).post(`/api/invites/${invite.id}/accept`).set("Cookie", inviteeCookie); const acceptedActivity = await testPrisma.activityLog.findFirst({ where: { @@ -622,7 +642,7 @@ describe("Invitations API", () => { it("should return 404 when invite not found (INVITE_001)", async () => { const res = await request(app) - .post("/api/invites/00000000-0000-0000-0000-000000000000/accept") + .post("/api/invites/00000000-0000-4000-8000-000000000000/accept") .set("Cookie", inviteeCookie); expect(res.status).toBe(404); @@ -691,22 +711,26 @@ describe("Invitations API", () => { }); // Create invitee and login - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `rejectinvitee_${suffix}`, - email: `rejectinvitee_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `rejectinvitee_${suffix}`, + email: `rejectinvitee_${suffix}@example.com`, + password: "Test123!Password", + }); inviteeCookie = extractSessionCookie(signupRes)!; invitee = (await testPrisma.user.findFirst({ where: { email: `rejectinvitee_${suffix}@example.com` }, }))!; // Create other user - const otherRes = await request(app).post("/api/auth/signup").send({ - username: `rejectotheruser_${suffix}`, - email: `rejectotheruser_${suffix}@example.com`, - password: "Test123!Password", - }); + const otherRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `rejectotheruser_${suffix}`, + email: `rejectotheruser_${suffix}@example.com`, + password: "Test123!Password", + }); otherUserCookie = extractSessionCookie(otherRes)!; }); @@ -739,9 +763,7 @@ describe("Invitations API", () => { it("should log INVITE_REJECTED activity", async () => { const invite = await createTestInvite(community.id, moderator.id, invitee.id, "PENDING"); - await request(app) - .post(`/api/invites/${invite.id}/reject`) - .set("Cookie", inviteeCookie); + await request(app).post(`/api/invites/${invite.id}/reject`).set("Cookie", inviteeCookie); const activity = await testPrisma.activityLog.findFirst({ where: { diff --git a/backend/src/__tests__/integration/members.test.ts b/backend/src/__tests__/integration/members.test.ts index 470fbd03..5c1802e1 100644 --- a/backend/src/__tests__/integration/members.test.ts +++ b/backend/src/__tests__/integration/members.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; import { + uniqueSuffix, createTestUser, createTestCommunity, createTestInvite, @@ -9,10 +10,6 @@ import { } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -// Helper to generate unique suffix -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Members API", () => { // ===================================== // GET /api/communities/:communityId/members @@ -28,11 +25,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator and get cookie - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `getmembermod_${suffix}`, - email: `getmembermod_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `getmembermod_${suffix}`, + email: `getmembermod_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(signupRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `getmembermod_${suffix}@example.com` }, @@ -76,12 +75,8 @@ describe("Members API", () => { expect(res.body.data).toHaveLength(2); // Ordered by joinedAt asc, moderator first - const modEntry = res.body.data.find( - (m: { id: string }) => m.id === moderator.id - ); - const memEntry = res.body.data.find( - (m: { id: string }) => m.id === member.id - ); + const modEntry = res.body.data.find((m: { id: string }) => m.id === moderator.id); + const memEntry = res.body.data.find((m: { id: string }) => m.id === member.id); expect(modEntry.role).toBe("MODERATOR"); expect(modEntry.username).toBe(moderator.username); @@ -109,11 +104,13 @@ describe("Members API", () => { it("should return 403 for non-member", async () => { const suffix2 = uniqueSuffix(); - const outsiderRes = await request(app).post("/api/auth/signup").send({ - username: `outsider_${suffix2}`, - email: `outsider_${suffix2}@example.com`, - password: "Test123!Password", - }); + const outsiderRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `outsider_${suffix2}`, + email: `outsider_${suffix2}@example.com`, + password: "Test123!Password", + }); const outsiderCookie = extractSessionCookie(outsiderRes)!; const res = await request(app) @@ -125,9 +122,7 @@ describe("Members API", () => { }); it("should return 401 without authentication", async () => { - const res = await request(app).get( - `/api/communities/${community.id}/members` - ); + const res = await request(app).get(`/api/communities/${community.id}/members`); expect(res.status).toBe(401); }); @@ -147,11 +142,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator - const signupRes = await request(app).post("/api/auth/signup").send({ - username: `promotemod_${suffix}`, - email: `promotemod_${suffix}@example.com`, - password: "Test123!Password", - }); + const signupRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `promotemod_${suffix}`, + email: `promotemod_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(signupRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `promotemod_${suffix}@example.com` }, @@ -217,9 +214,7 @@ describe("Members API", () => { expect(activity).not.toBeNull(); expect(activity!.userId).toBe(moderator.id); - expect((activity!.metadata as { promotedUserId: string }).promotedUserId).toBe( - member.id - ); + expect((activity!.metadata as { promotedUserId: string }).promotedUserId).toBe(member.id); }); it("should return 400 when role is missing", async () => { @@ -257,9 +252,7 @@ describe("Members API", () => { it("should return 404 when target is not a member", async () => { const res = await request(app) - .patch( - `/api/communities/${community.id}/members/00000000-0000-0000-0000-000000000000` - ) + .patch(`/api/communities/${community.id}/members/00000000-0000-4000-8000-000000000000`) .set("Cookie", moderatorCookie) .send({ role: "MODERATOR" }); @@ -298,11 +291,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator - const modRes = await request(app).post("/api/auth/signup").send({ - username: `leavemod_${suffix}`, - email: `leavemod_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `leavemod_${suffix}`, + email: `leavemod_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; const _moderator = (await testPrisma.user.findFirst({ where: { email: `leavemod_${suffix}@example.com` }, @@ -316,11 +311,13 @@ describe("Members API", () => { const community = createRes.body; // Create member - const memRes = await request(app).post("/api/auth/signup").send({ - username: `leavemem_${suffix}`, - email: `leavemem_${suffix}@example.com`, - password: "Test123!Password", - }); + const memRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `leavemem_${suffix}`, + email: `leavemem_${suffix}@example.com`, + password: "Test123!Password", + }); const memCookie = extractSessionCookie(memRes)!; const member = (await testPrisma.user.findFirst({ where: { email: `leavemem_${suffix}@example.com` }, @@ -362,11 +359,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create first moderator - const mod1Res = await request(app).post("/api/auth/signup").send({ - username: `leavemod1_${suffix}`, - email: `leavemod1_${suffix}@example.com`, - password: "Test123!Password", - }); + const mod1Res = await request(app) + .post("/api/auth/signup") + .send({ + username: `leavemod1_${suffix}`, + email: `leavemod1_${suffix}@example.com`, + password: "Test123!Password", + }); const mod1Cookie = extractSessionCookie(mod1Res)!; const mod1 = (await testPrisma.user.findFirst({ where: { email: `leavemod1_${suffix}@example.com` }, @@ -404,11 +403,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator - const modRes = await request(app).post("/api/auth/signup").send({ - username: `lastmod_${suffix}`, - email: `lastmod_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `lastmod_${suffix}`, + email: `lastmod_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; const moderator = (await testPrisma.user.findFirst({ where: { email: `lastmod_${suffix}@example.com` }, @@ -446,11 +447,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator (sole member) - const modRes = await request(app).post("/api/auth/signup").send({ - username: `solemem_${suffix}`, - email: `solemem_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `solemem_${suffix}`, + email: `solemem_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; const moderator = (await testPrisma.user.findFirst({ where: { email: `solemem_${suffix}@example.com` }, @@ -480,11 +483,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator (sole member) - const modRes = await request(app).post("/api/auth/signup").send({ - username: `invcancel_${suffix}`, - email: `invcancel_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `invcancel_${suffix}`, + email: `invcancel_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; const moderator = (await testPrisma.user.findFirst({ where: { email: `invcancel_${suffix}@example.com` }, @@ -502,12 +507,7 @@ describe("Members API", () => { username: `invcancelinv_${suffix}`, email: `invcancelinv_${suffix}@example.com`, }); - const invite = await createTestInvite( - community.id, - moderator.id, - invitee.id, - "PENDING" - ); + const invite = await createTestInvite(community.id, moderator.id, invitee.id, "PENDING"); // Last member leaves await request(app) @@ -525,11 +525,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator (sole member) - const modRes = await request(app).post("/api/auth/signup").send({ - username: `recdelete_${suffix}`, - email: `recdelete_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `recdelete_${suffix}`, + email: `recdelete_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; const moderator = (await testPrisma.user.findFirst({ where: { email: `recdelete_${suffix}@example.com` }, @@ -546,9 +548,10 @@ describe("Members API", () => { const recipe = await testPrisma.recipe.create({ data: { title: `Community Recipe ${suffix}`, - content: "Test content", + servings: 4, creatorId: moderator.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Test content" }] }, }, }); @@ -579,11 +582,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator - const modRes = await request(app).post("/api/auth/signup").send({ - username: `kickmod_${suffix}`, - email: `kickmod_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `kickmod_${suffix}`, + email: `kickmod_${suffix}@example.com`, + password: "Test123!Password", + }); moderatorCookie = extractSessionCookie(modRes)!; moderator = (await testPrisma.user.findFirst({ where: { email: `kickmod_${suffix}@example.com` }, @@ -597,11 +602,13 @@ describe("Members API", () => { community = createRes.body; // Create member - const memRes = await request(app).post("/api/auth/signup").send({ - username: `kickmem_${suffix}`, - email: `kickmem_${suffix}@example.com`, - password: "Test123!Password", - }); + const memRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `kickmem_${suffix}`, + email: `kickmem_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memRes)!; member = (await testPrisma.user.findFirst({ where: { email: `kickmem_${suffix}@example.com` }, @@ -639,9 +646,7 @@ describe("Members API", () => { }); expect(activity).not.toBeNull(); expect(activity!.userId).toBe(moderator.id); - expect( - (activity!.metadata as { kickedUserId: string }).kickedUserId - ).toBe(member.id); + expect((activity!.metadata as { kickedUserId: string }).kickedUserId).toBe(member.id); }); it("should return 403 when trying to kick a MODERATOR (COMMUNITY_006)", async () => { @@ -692,9 +697,7 @@ describe("Members API", () => { it("should return 404 when target is not a member", async () => { const res = await request(app) - .delete( - `/api/communities/${community.id}/members/00000000-0000-0000-0000-000000000000` - ) + .delete(`/api/communities/${community.id}/members/00000000-0000-4000-8000-000000000000`) .set("Cookie", moderatorCookie); expect(res.status).toBe(404); @@ -718,11 +721,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator (recipe owner who will leave) - const ownerRes = await request(app).post("/api/auth/signup").send({ - username: `orphanowner_${suffix}`, - email: `orphanowner_${suffix}@example.com`, - password: "Test123!Password", - }); + const ownerRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `orphanowner_${suffix}`, + email: `orphanowner_${suffix}@example.com`, + password: "Test123!Password", + }); const ownerCookie = extractSessionCookie(ownerRes)!; const owner = (await testPrisma.user.findFirst({ where: { email: `orphanowner_${suffix}@example.com` }, @@ -765,9 +770,10 @@ describe("Members API", () => { const recipe = await testPrisma.recipe.create({ data: { title: `Orphan Recipe ${suffix}`, - content: "Original content", + servings: 4, creatorId: owner.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Original content" }] }, }, }); @@ -775,10 +781,10 @@ describe("Members API", () => { const proposal = await testPrisma.recipeUpdateProposal.create({ data: { proposedTitle: `Modified Title ${suffix}`, - proposedContent: "Modified content", status: "PENDING", recipeId: recipe.id, proposerId: proposer.id, + proposedSteps: { create: [{ order: 0, instruction: "Modified content" }] }, }, }); @@ -806,7 +812,6 @@ describe("Members API", () => { }); expect(variant).not.toBeNull(); expect(variant!.title).toBe(`Modified Title ${suffix}`); - expect(variant!.content).toBe("Modified content"); expect(variant!.communityId).toBe(community.id); // Verify ActivityLog VARIANT_CREATED @@ -826,11 +831,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create owner - const ownerRes = await request(app).post("/api/auth/signup").send({ - username: `multiorphan_${suffix}`, - email: `multiorphan_${suffix}@example.com`, - password: "Test123!Password", - }); + const ownerRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `multiorphan_${suffix}`, + email: `multiorphan_${suffix}@example.com`, + password: "Test123!Password", + }); const ownerCookie = extractSessionCookie(ownerRes)!; const owner = (await testPrisma.user.findFirst({ where: { email: `multiorphan_${suffix}@example.com` }, @@ -876,45 +883,49 @@ describe("Members API", () => { const recipe1 = await testPrisma.recipe.create({ data: { title: `Recipe 1 ${suffix}`, - content: "Content 1", + servings: 4, creatorId: owner.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Content 1" }] }, }, }); const recipe2 = await testPrisma.recipe.create({ data: { title: `Recipe 2 ${suffix}`, - content: "Content 2", + servings: 4, creatorId: owner.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Content 2" }] }, }, }); // Create proposals - await testPrisma.recipeUpdateProposal.createMany({ - data: [ - { - proposedTitle: "Prop 1", - proposedContent: "Prop content 1", - status: "PENDING", - recipeId: recipe1.id, - proposerId: proposer1.id, - }, - { - proposedTitle: "Prop 2", - proposedContent: "Prop content 2", - status: "PENDING", - recipeId: recipe1.id, - proposerId: proposer2.id, - }, - { - proposedTitle: "Prop 3", - proposedContent: "Prop content 3", - status: "PENDING", - recipeId: recipe2.id, - proposerId: proposer1.id, - }, - ], + await testPrisma.recipeUpdateProposal.create({ + data: { + proposedTitle: "Prop 1", + status: "PENDING", + recipeId: recipe1.id, + proposerId: proposer1.id, + proposedSteps: { create: [{ order: 0, instruction: "Prop content 1" }] }, + }, + }); + await testPrisma.recipeUpdateProposal.create({ + data: { + proposedTitle: "Prop 2", + status: "PENDING", + recipeId: recipe1.id, + proposerId: proposer2.id, + proposedSteps: { create: [{ order: 0, instruction: "Prop content 2" }] }, + }, + }); + await testPrisma.recipeUpdateProposal.create({ + data: { + proposedTitle: "Prop 3", + status: "PENDING", + recipeId: recipe2.id, + proposerId: proposer1.id, + proposedSteps: { create: [{ order: 0, instruction: "Prop content 3" }] }, + }, }); // Owner leaves @@ -945,11 +956,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create owner - const ownerRes = await request(app).post("/api/auth/signup").send({ - username: `decidedorphan_${suffix}`, - email: `decidedorphan_${suffix}@example.com`, - password: "Test123!Password", - }); + const ownerRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `decidedorphan_${suffix}`, + email: `decidedorphan_${suffix}@example.com`, + password: "Test123!Password", + }); const ownerCookie = extractSessionCookie(ownerRes)!; const owner = (await testPrisma.user.findFirst({ where: { email: `decidedorphan_${suffix}@example.com` }, @@ -992,9 +1005,10 @@ describe("Members API", () => { const recipe = await testPrisma.recipe.create({ data: { title: `Decided Recipe ${suffix}`, - content: "Original", + servings: 4, creatorId: owner.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Original" }] }, }, }); @@ -1002,11 +1016,11 @@ describe("Members API", () => { const acceptedProposal = await testPrisma.recipeUpdateProposal.create({ data: { proposedTitle: "Accepted", - proposedContent: "Accepted content", status: "ACCEPTED", decidedAt: new Date(), recipeId: recipe.id, proposerId: proposer.id, + proposedSteps: { create: [{ order: 0, instruction: "Accepted content" }] }, }, }); @@ -1037,11 +1051,13 @@ describe("Members API", () => { const suffix = uniqueSuffix(); // Create moderator - const modRes = await request(app).post("/api/auth/signup").send({ - username: `kickorphanmod_${suffix}`, - email: `kickorphanmod_${suffix}@example.com`, - password: "Test123!Password", - }); + const modRes = await request(app) + .post("/api/auth/signup") + .send({ + username: `kickorphanmod_${suffix}`, + email: `kickorphanmod_${suffix}@example.com`, + password: "Test123!Password", + }); const modCookie = extractSessionCookie(modRes)!; // Create community @@ -1081,9 +1097,10 @@ describe("Members API", () => { const recipe = await testPrisma.recipe.create({ data: { title: `Kick Orphan Recipe ${suffix}`, - content: "Original content", + servings: 4, creatorId: member.id, communityId: community.id, + steps: { create: [{ order: 0, instruction: "Original content" }] }, }, }); @@ -1091,10 +1108,10 @@ describe("Members API", () => { const proposal = await testPrisma.recipeUpdateProposal.create({ data: { proposedTitle: `Kick Modified ${suffix}`, - proposedContent: "Kick modified content", status: "PENDING", recipeId: recipe.id, proposerId: proposer.id, + proposedSteps: { create: [{ order: 0, instruction: "Kick modified content" }] }, }, }); diff --git a/backend/src/__tests__/integration/notificationCleanup.test.ts b/backend/src/__tests__/integration/notificationCleanup.test.ts new file mode 100644 index 00000000..d53fecea --- /dev/null +++ b/backend/src/__tests__/integration/notificationCleanup.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; +import { cleanupReadNotifications } from "../../jobs/notificationCleanup"; + +describe("Notification Cleanup Job", () => { + let user: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + const signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `cleanup_${suffix}`, + email: `cleanup_${suffix}@example.com`, + password: "Test123!Password", + }); + extractSessionCookie(signup); + user = (await testPrisma.user.findFirst({ + where: { email: `cleanup_${suffix}@example.com` }, + }))!; + }); + + it("should delete read notifications older than 30 days", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 35); + + // Creer une notification lue il y a 35 jours + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Old notification", + message: "Old read notification", + readAt: oldDate, + createdAt: oldDate, + }, + }); + + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(1); + + const remaining = await testPrisma.notification.count({ + where: { userId: user.id }, + }); + expect(remaining).toBe(0); + }); + + it("should NOT delete unread notifications even if old", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 60); + + // Notification non-lue mais vieille + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Old unread", + message: "Old unread notification", + readAt: null, + createdAt: oldDate, + }, + }); + + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(0); + + const remaining = await testPrisma.notification.count({ + where: { userId: user.id }, + }); + expect(remaining).toBe(1); + }); + + it("should NOT delete recently read notifications", async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 10); + + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Recent read", + message: "Recently read notification", + readAt: recentDate, + createdAt: recentDate, + }, + }); + + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(0); + + const remaining = await testPrisma.notification.count({ + where: { userId: user.id }, + }); + expect(remaining).toBe(1); + }); + + it("should handle batch deletion correctly", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 40); + + // Creer 5 vieilles notifications lues + const data = Array.from({ length: 5 }, (_, i) => ({ + userId: user.id, + type: "USER_JOINED", + category: "MODERATION" as const, + title: `Batch ${i}`, + message: `Batch notification ${i}`, + readAt: oldDate, + createdAt: oldDate, + })); + + await testPrisma.notification.createMany({ data }); + + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(5); + }); + + it("should return 0 when nothing to delete", async () => { + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(0); + }); + + it("should delete old read but keep old unread and recent read", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 35); + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 5); + + // 1. Vieille lue -> doit etre supprimee + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Old read", + message: "Should be deleted", + readAt: oldDate, + createdAt: oldDate, + }, + }); + + // 2. Vieille non-lue -> doit rester + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Old unread", + message: "Should stay", + readAt: null, + createdAt: oldDate, + }, + }); + + // 3. Recente lue -> doit rester + await testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Recent read", + message: "Should stay too", + readAt: recentDate, + createdAt: recentDate, + }, + }); + + const deleted = await cleanupReadNotifications(); + expect(deleted).toBe(1); + + const remaining = await testPrisma.notification.count({ + where: { userId: user.id }, + }); + expect(remaining).toBe(2); + }); +}); diff --git a/backend/src/__tests__/integration/notificationService.test.ts b/backend/src/__tests__/integration/notificationService.test.ts new file mode 100644 index 00000000..67fa5698 --- /dev/null +++ b/backend/src/__tests__/integration/notificationService.test.ts @@ -0,0 +1,557 @@ +import { uniqueSuffix } from "../setup/testHelpers"; +import { describe, it, expect, beforeEach } from "vitest"; +import { testPrisma } from "../setup/globalSetup"; +import { + getCategoryForType, + isNotificationEnabled, + filterByPreference, + createNotification, + createBroadcastNotifications, + resolveTemplateVars, + getModeratorIdsForTagNotification, +} from "../../services/notificationService"; + +// Helper pour creer un user en DB +async function createUser(suffix: string) { + return testPrisma.user.create({ + data: { + username: `user_${suffix}`, + email: `user_${suffix}@example.com`, + password: "hashed", + }, + }); +} + +// Helper pour creer une communaute en DB +async function createCommunity(suffix: string) { + return testPrisma.community.create({ + data: { name: `Community ${suffix}` }, + }); +} + +// ============================================================================= +// getCategoryForType +// ============================================================================= +describe("getCategoryForType", () => { + it("should map invitation types to INVITATION", () => { + expect(getCategoryForType("INVITE_SENT")).toBe("INVITATION"); + expect(getCategoryForType("INVITE_ACCEPTED")).toBe("INVITATION"); + expect(getCategoryForType("INVITE_REJECTED")).toBe("INVITATION"); + expect(getCategoryForType("INVITE_CANCELLED")).toBe("INVITATION"); + }); + + it("should map recipe types to RECIPE_PROPOSAL", () => { + expect(getCategoryForType("VARIANT_PROPOSED")).toBe("RECIPE_PROPOSAL"); + expect(getCategoryForType("PROPOSAL_ACCEPTED")).toBe("RECIPE_PROPOSAL"); + expect(getCategoryForType("RECIPE_CREATED")).toBe("RECIPE_PROPOSAL"); + }); + + it("should map tag types to TAG", () => { + expect(getCategoryForType("TAG_SUGGESTION_CREATED")).toBe("TAG"); + expect(getCategoryForType("tag:approved")).toBe("TAG"); + expect(getCategoryForType("tag-suggestion:pending-mod")).toBe("TAG"); + }); + + it("should map ingredient types to INGREDIENT", () => { + expect(getCategoryForType("INGREDIENT_APPROVED")).toBe("INGREDIENT"); + expect(getCategoryForType("INGREDIENT_REJECTED")).toBe("INGREDIENT"); + }); + + it("should map moderation types to MODERATION", () => { + expect(getCategoryForType("USER_PROMOTED")).toBe("MODERATION"); + expect(getCategoryForType("USER_KICKED")).toBe("MODERATION"); + }); + + it("should return null for unknown types", () => { + expect(getCategoryForType("UNKNOWN_TYPE")).toBeNull(); + }); +}); + +// ============================================================================= +// isNotificationEnabled +// ============================================================================= +describe("isNotificationEnabled", () => { + let user: { id: string }; + + beforeEach(async () => { + user = await createUser(uniqueSuffix()); + }); + + it("should return true by default (no preference)", async () => { + const enabled = await isNotificationEnabled(user.id, "TAG", null); + expect(enabled).toBe(true); + }); + + it("should return false when global preference is disabled", async () => { + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "TAG", enabled: false }, + }); + + const enabled = await isNotificationEnabled(user.id, "TAG", null); + expect(enabled).toBe(false); + }); + + it("should respect community preference over global", async () => { + const community = await createCommunity(uniqueSuffix()); + + // Global disabled + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "TAG", enabled: false }, + }); + // Community enabled + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: community.id, category: "TAG", enabled: true }, + }); + + const enabled = await isNotificationEnabled(user.id, "TAG", community.id); + expect(enabled).toBe(true); + }); + + it("should fall back to global when no community preference", async () => { + const community = await createCommunity(uniqueSuffix()); + + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "INVITATION", enabled: false }, + }); + + const enabled = await isNotificationEnabled(user.id, "INVITATION", community.id); + expect(enabled).toBe(false); + }); +}); + +// ============================================================================= +// filterByPreference +// ============================================================================= +describe("filterByPreference", () => { + it("should return all users when no preferences exist", async () => { + const u1 = await createUser(uniqueSuffix()); + const u2 = await createUser(uniqueSuffix()); + + const result = await filterByPreference([u1.id, u2.id], "TAG", null); + expect(result).toContain(u1.id); + expect(result).toContain(u2.id); + }); + + it("should filter out users with disabled preference", async () => { + const u1 = await createUser(uniqueSuffix()); + const u2 = await createUser(uniqueSuffix()); + + await testPrisma.notificationPreference.create({ + data: { userId: u1.id, communityId: null, category: "TAG", enabled: false }, + }); + + const result = await filterByPreference([u1.id, u2.id], "TAG", null); + expect(result).not.toContain(u1.id); + expect(result).toContain(u2.id); + }); + + it("should return empty array for empty input", async () => { + const result = await filterByPreference([], "TAG", null); + expect(result).toEqual([]); + }); +}); + +// ============================================================================= +// createNotification +// ============================================================================= +describe("createNotification", () => { + let user: { id: string }; + let actor: { id: string }; + let community: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + user = await createUser(`target_${suffix}`); + actor = await createUser(`actor_${suffix}`); + community = await createCommunity(suffix); + }); + + it("should create a notification in DB", async () => { + const notif = await createNotification({ + userId: user.id, + type: "INVITE_SENT", + actorId: actor.id, + communityId: community.id, + templateVars: { + actorName: "alice", + communityName: "Test Community", + communityId: community.id, + }, + }); + + expect(notif).not.toBeNull(); + expect(notif!.userId).toBe(user.id); + expect(notif!.type).toBe("INVITE_SENT"); + expect(notif!.category).toBe("INVITATION"); + expect(notif!.title).toBe("Nouvelle invitation"); + expect(notif!.message).toContain("alice"); + expect(notif!.message).toContain("Test Community"); + expect(notif!.actionUrl).toBe("/invitations"); + expect(notif!.readAt).toBeNull(); + }); + + it("should return null for unknown type", async () => { + const notif = await createNotification({ + userId: user.id, + type: "UNKNOWN_TYPE", + actorId: actor.id, + communityId: null, + templateVars: {}, + }); + + expect(notif).toBeNull(); + }); + + it("should return null when preference is disabled", async () => { + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "INVITATION", enabled: false }, + }); + + const notif = await createNotification({ + userId: user.id, + type: "INVITE_ACCEPTED", + actorId: actor.id, + communityId: community.id, + templateVars: { + actorName: "alice", + communityName: "Test", + communityId: community.id, + }, + }); + + expect(notif).toBeNull(); + }); + + it("should always create notification for non-disableable types (INVITE_SENT)", async () => { + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "INVITATION", enabled: false }, + }); + + const notif = await createNotification({ + userId: user.id, + type: "INVITE_SENT", + actorId: actor.id, + communityId: community.id, + templateVars: { + actorName: "alice", + communityName: "Test", + communityId: community.id, + }, + }); + + expect(notif).not.toBeNull(); + expect(notif!.type).toBe("INVITE_SENT"); + }); + + it("should always create notification for USER_KICKED even if MODERATION disabled", async () => { + await testPrisma.notificationPreference.create({ + data: { userId: user.id, communityId: null, category: "MODERATION", enabled: false }, + }); + + const notif = await createNotification({ + userId: user.id, + type: "USER_KICKED", + actorId: actor.id, + communityId: community.id, + templateVars: { + communityName: "Test", + communityId: community.id, + }, + }); + + expect(notif).not.toBeNull(); + }); + + it("should set groupKey for broadcast types", async () => { + const recipe = await testPrisma.recipe.create({ + data: { + title: "Test Recipe", + servings: 4, + creatorId: actor.id, + steps: { create: [{ order: 0, instruction: "content" }] }, + }, + }); + + const notif = await createNotification({ + userId: user.id, + type: "RECIPE_CREATED", + actorId: actor.id, + communityId: community.id, + recipeId: recipe.id, + templateVars: { + actorName: "alice", + communityName: "Test", + communityId: community.id, + recipeName: "Test Recipe", + recipeId: recipe.id, + }, + }); + + expect(notif).not.toBeNull(); + expect(notif!.groupKey).toBe(`community:${community.id}:RECIPE_CREATED`); + }); + + it("should store metadata", async () => { + const notif = await createNotification({ + userId: user.id, + type: "INGREDIENT_APPROVED", + actorId: null, + communityId: null, + metadata: { ingredientName: "Tomate" }, + templateVars: { ingredientName: "Tomate" }, + }); + + expect(notif).not.toBeNull(); + expect(notif!.metadata).toEqual({ ingredientName: "Tomate" }); + }); +}); + +// ============================================================================= +// createBroadcastNotifications +// ============================================================================= +describe("createBroadcastNotifications", () => { + let actor: { id: string }; + let member1: { id: string }; + let member2: { id: string }; + let community: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + actor = await createUser(`bcast_actor_${suffix}`); + member1 = await createUser(`bcast_m1_${suffix}`); + member2 = await createUser(`bcast_m2_${suffix}`); + community = await createCommunity(`bcast_${suffix}`); + + // Add all as members + await testPrisma.userCommunity.createMany({ + data: [ + { userId: actor.id, communityId: community.id, role: "MODERATOR" }, + { userId: member1.id, communityId: community.id, role: "MEMBER" }, + { userId: member2.id, communityId: community.id, role: "MEMBER" }, + ], + }); + }); + + it("should create notifications for all members except actor", async () => { + const recipe = await testPrisma.recipe.create({ + data: { + title: "New Recipe", + servings: 4, + creatorId: actor.id, + communityId: community.id, + steps: { create: [{ order: 0, instruction: "content" }] }, + }, + }); + + const notifs = await createBroadcastNotifications({ + type: "RECIPE_CREATED", + actorId: actor.id, + communityId: community.id, + recipeId: recipe.id, + templateVars: { + actorName: "actor", + communityName: "Test", + communityId: community.id, + recipeName: "New Recipe", + recipeId: recipe.id, + }, + }); + + expect(notifs.length).toBe(2); + const recipientIds = notifs.map((n) => n.userId); + expect(recipientIds).toContain(member1.id); + expect(recipientIds).toContain(member2.id); + expect(recipientIds).not.toContain(actor.id); + }); + + it("should respect preferences when broadcasting", async () => { + // member1 disables RECIPE_PROPOSAL + await testPrisma.notificationPreference.create({ + data: { userId: member1.id, communityId: null, category: "RECIPE_PROPOSAL", enabled: false }, + }); + + const recipe = await testPrisma.recipe.create({ + data: { + title: "New Recipe", + servings: 4, + creatorId: actor.id, + communityId: community.id, + steps: { create: [{ order: 0, instruction: "content" }] }, + }, + }); + + const notifs = await createBroadcastNotifications({ + type: "RECIPE_CREATED", + actorId: actor.id, + communityId: community.id, + recipeId: recipe.id, + templateVars: { + actorName: "actor", + communityName: "Test", + communityId: community.id, + recipeName: "New Recipe", + recipeId: recipe.id, + }, + }); + + expect(notifs.length).toBe(1); + expect(notifs[0].userId).toBe(member2.id); + }); + + it("should return empty array for unknown type", async () => { + const notifs = await createBroadcastNotifications({ + type: "UNKNOWN", + actorId: actor.id, + communityId: community.id, + templateVars: {}, + }); + + expect(notifs).toEqual([]); + }); + + it("should set groupKey on all broadcast notifications", async () => { + const recipe = await testPrisma.recipe.create({ + data: { + title: "R", + servings: 4, + creatorId: actor.id, + communityId: community.id, + steps: { create: [{ order: 0, instruction: "c" }] }, + }, + }); + + const notifs = await createBroadcastNotifications({ + type: "RECIPE_CREATED", + actorId: actor.id, + communityId: community.id, + recipeId: recipe.id, + templateVars: { + actorName: "a", + communityName: "C", + communityId: community.id, + recipeName: "R", + recipeId: recipe.id, + }, + }); + + for (const notif of notifs) { + expect(notif.groupKey).toBe(`community:${community.id}:RECIPE_CREATED`); + } + }); +}); + +// ============================================================================= +// resolveTemplateVars +// ============================================================================= +describe("resolveTemplateVars", () => { + it("should resolve actor username", async () => { + const suffix = uniqueSuffix(); + const user = await createUser(`resolve_${suffix}`); + + const vars = await resolveTemplateVars({ + type: "INVITE_SENT", + userId: user.id, + communityId: null, + }); + + expect(vars.actorName).toBe(user.username); + }); + + it("should resolve community name", async () => { + const suffix = uniqueSuffix(); + const user = await createUser(`resolve2_${suffix}`); + const community = await createCommunity(`resolve2_${suffix}`); + + const vars = await resolveTemplateVars({ + type: "INVITE_SENT", + userId: user.id, + communityId: community.id, + }); + + expect(vars.communityName).toBe(community.name); + expect(vars.communityId).toBe(community.id); + }); + + it("should resolve recipe title", async () => { + const suffix = uniqueSuffix(); + const user = await createUser(`resolve3_${suffix}`); + const recipe = await testPrisma.recipe.create({ + data: { + title: "Ma Recette", + servings: 4, + creatorId: user.id, + steps: { create: [{ order: 0, instruction: "c" }] }, + }, + }); + + const vars = await resolveTemplateVars({ + type: "VARIANT_PROPOSED", + userId: user.id, + communityId: null, + recipeId: recipe.id, + }); + + expect(vars.recipeName).toBe("Ma Recette"); + expect(vars.recipeId).toBe(recipe.id); + }); + + it("should pass through metadata fields", async () => { + const suffix = uniqueSuffix(); + const user = await createUser(`resolve4_${suffix}`); + + const vars = await resolveTemplateVars({ + type: "INGREDIENT_APPROVED", + userId: user.id, + communityId: null, + metadata: { ingredientName: "Tomate", reason: "doublon" }, + }); + + expect(vars.ingredientName).toBe("Tomate"); + expect(vars.reason).toBe("doublon"); + }); +}); + +// ============================================================================= +// getModeratorIdsForTagNotification (legacy, now uses filterByPreference) +// ============================================================================= +describe("getModeratorIdsForTagNotification", () => { + it("should return all moderators by default", async () => { + const suffix = uniqueSuffix(); + const mod1 = await createUser(`mod1_${suffix}`); + const mod2 = await createUser(`mod2_${suffix}`); + const community = await createCommunity(`legacy_${suffix}`); + + await testPrisma.userCommunity.createMany({ + data: [ + { userId: mod1.id, communityId: community.id, role: "MODERATOR" }, + { userId: mod2.id, communityId: community.id, role: "MODERATOR" }, + ], + }); + + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).toContain(mod1.id); + expect(ids).toContain(mod2.id); + }); + + it("should exclude moderator with TAG disabled", async () => { + const suffix = uniqueSuffix(); + const mod1 = await createUser(`mod1b_${suffix}`); + const mod2 = await createUser(`mod2b_${suffix}`); + const community = await createCommunity(`legacy2_${suffix}`); + + await testPrisma.userCommunity.createMany({ + data: [ + { userId: mod1.id, communityId: community.id, role: "MODERATOR" }, + { userId: mod2.id, communityId: community.id, role: "MODERATOR" }, + ], + }); + + await testPrisma.notificationPreference.create({ + data: { userId: mod1.id, communityId: null, category: "TAG", enabled: false }, + }); + + const ids = await getModeratorIdsForTagNotification(community.id); + expect(ids).not.toContain(mod1.id); + expect(ids).toContain(mod2.id); + }); +}); diff --git a/backend/src/__tests__/integration/notifications.test.ts b/backend/src/__tests__/integration/notifications.test.ts new file mode 100644 index 00000000..946cb3a9 --- /dev/null +++ b/backend/src/__tests__/integration/notifications.test.ts @@ -0,0 +1,488 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +describe("Notifications API", () => { + let user: { id: string }; + let userCookie: string; + let actor: { id: string }; + + beforeEach(async () => { + const suffix = uniqueSuffix(); + + // Create user + const userSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `nuser_${suffix}`, + email: `nuser_${suffix}@example.com`, + password: "Test123!Password", + }); + userCookie = extractSessionCookie(userSignup)!; + user = (await testPrisma.user.findFirst({ + where: { email: `nuser_${suffix}@example.com` }, + }))!; + + // Create actor (for notification source) + const actorSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `nactor_${suffix}`, + email: `nactor_${suffix}@example.com`, + password: "Test123!Password", + }); + extractSessionCookie(actorSignup); + actor = (await testPrisma.user.findFirst({ + where: { email: `nactor_${suffix}@example.com` }, + }))!; + }); + + // Helper to create notifications directly in DB + async function createNotif(overrides: Record = {}) { + return testPrisma.notification.create({ + data: { + userId: user.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Test notification", + message: "Test message", + actorId: actor.id, + ...overrides, + }, + }); + } + + // ===================================== + // GET /api/notifications + // ===================================== + describe("GET /api/notifications", () => { + it("should return empty list when no notifications", async () => { + const res = await request(app).get("/api/notifications").set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.pagination.total).toBe(0); + expect(res.body.unreadCount).toBe(0); + }); + + it("should return notifications for the authenticated user", async () => { + await createNotif(); + await createNotif({ + type: "PROPOSAL_ACCEPTED", + category: "RECIPE_PROPOSAL", + title: "Proposal", + }); + + const res = await request(app).get("/api/notifications").set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(2); + expect(res.body.pagination.total).toBe(2); + expect(res.body.unreadCount).toBe(2); + }); + + it("should not return notifications belonging to other users", async () => { + // Create notification for another user + const otherUser = await testPrisma.user.create({ + data: { + username: `other_${uniqueSuffix()}`, + email: `other_${uniqueSuffix()}@example.com`, + password: "h", + }, + }); + await testPrisma.notification.create({ + data: { + userId: otherUser.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "Other", + message: "Other", + }, + }); + + const res = await request(app).get("/api/notifications").set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(0); + }); + + it("should paginate correctly", async () => { + for (let i = 0; i < 5; i++) { + await createNotif({ title: `Notif ${i}` }); + } + + const res = await request(app) + .get("/api/notifications?page=1&limit=2") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(2); + expect(res.body.pagination.total).toBe(5); + expect(res.body.pagination.totalPages).toBe(3); + expect(res.body.pagination.page).toBe(1); + }); + + it("should filter by category", async () => { + await createNotif({ category: "INVITATION", type: "INVITE_SENT" }); + await createNotif({ category: "RECIPE_PROPOSAL", type: "PROPOSAL_ACCEPTED" }); + + const res = await request(app) + .get("/api/notifications?category=INVITATION") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].category).toBe("INVITATION"); + }); + + it("should filter unread only", async () => { + await createNotif(); + await createNotif({ readAt: new Date() }); + + const res = await request(app) + .get("/api/notifications?unreadOnly=true") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].readAt).toBeNull(); + }); + + it("should return 400 for invalid category", async () => { + const res = await request(app) + .get("/api/notifications?category=INVALID") + .set("Cookie", userCookie); + + expect(res.status).toBe(400); + }); + + it("should include actor info in response", async () => { + await createNotif(); + + const res = await request(app).get("/api/notifications").set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data[0].actor).not.toBeNull(); + expect(res.body.data[0].actor.id).toBe(actor.id); + }); + + it("should group notifications with same groupKey within 60min", async () => { + const community = await testPrisma.community.create({ + data: { name: `GrpComm ${uniqueSuffix()}` }, + }); + + const now = new Date(); + const tenMinAgo = new Date(now.getTime() - 10 * 60 * 1000); + const twentyMinAgo = new Date(now.getTime() - 20 * 60 * 1000); + + await createNotif({ + type: "RECIPE_CREATED", + category: "RECIPE_PROPOSAL", + communityId: community.id, + groupKey: `community:${community.id}:RECIPE_CREATED`, + createdAt: now, + message: "R1", + }); + await createNotif({ + type: "RECIPE_CREATED", + category: "RECIPE_PROPOSAL", + communityId: community.id, + groupKey: `community:${community.id}:RECIPE_CREATED`, + createdAt: tenMinAgo, + message: "R2", + }); + await createNotif({ + type: "RECIPE_CREATED", + category: "RECIPE_PROPOSAL", + communityId: community.id, + groupKey: `community:${community.id}:RECIPE_CREATED`, + createdAt: twentyMinAgo, + message: "R3", + }); + + const res = await request(app).get("/api/notifications").set("Cookie", userCookie); + + expect(res.status).toBe(200); + // Should be grouped into 1 entry + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].group).not.toBeNull(); + expect(res.body.data[0].group.count).toBe(3); + expect(res.body.data[0].group.items.length).toBe(3); + }); + + it("should not group when grouped=false", async () => { + const community = await testPrisma.community.create({ + data: { name: `NoGrp ${uniqueSuffix()}` }, + }); + + await createNotif({ + type: "RECIPE_CREATED", + category: "RECIPE_PROPOSAL", + communityId: community.id, + groupKey: `community:${community.id}:RECIPE_CREATED`, + }); + await createNotif({ + type: "RECIPE_CREATED", + category: "RECIPE_PROPOSAL", + communityId: community.id, + groupKey: `community:${community.id}:RECIPE_CREATED`, + }); + + const res = await request(app) + .get("/api/notifications?grouped=false") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(2); + expect(res.body.data[0].group).toBeNull(); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).get("/api/notifications"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // GET /api/notifications/unread-count + // ===================================== + describe("GET /api/notifications/unread-count", () => { + it("should return 0 when no notifications", async () => { + const res = await request(app) + .get("/api/notifications/unread-count") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.byCategory.INVITATION).toBe(0); + }); + + it("should count unread by category", async () => { + await createNotif({ category: "INVITATION" }); + await createNotif({ category: "INVITATION" }); + await createNotif({ category: "TAG", type: "tag:approved" }); + await createNotif({ category: "INVITATION", readAt: new Date() }); // read + + const res = await request(app) + .get("/api/notifications/unread-count") + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(3); + expect(res.body.byCategory.INVITATION).toBe(2); + expect(res.body.byCategory.TAG).toBe(1); + expect(res.body.byCategory.RECIPE_PROPOSAL).toBe(0); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).get("/api/notifications/unread-count"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // PATCH /api/notifications/:id/read + // ===================================== + describe("PATCH /api/notifications/:id/read", () => { + it("should mark a notification as read", async () => { + const notif = await createNotif(); + + const res = await request(app) + .patch(`/api/notifications/${notif.id}/read`) + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(notif.id); + expect(res.body.readAt).not.toBeNull(); + }); + + it("should be idempotent (already read)", async () => { + const notif = await createNotif({ readAt: new Date() }); + + const res = await request(app) + .patch(`/api/notifications/${notif.id}/read`) + .set("Cookie", userCookie); + + expect(res.status).toBe(200); + }); + + it("should return 404 for non-existent notification", async () => { + const res = await request(app) + .patch("/api/notifications/00000000-0000-4000-8000-000000000000/read") + .set("Cookie", userCookie); + + expect(res.status).toBe(404); + }); + + it("should return 403 for notification of another user", async () => { + const otherUser = await testPrisma.user.create({ + data: { + username: `oth_${uniqueSuffix()}`, + email: `oth_${uniqueSuffix()}@example.com`, + password: "h", + }, + }); + const notif = await testPrisma.notification.create({ + data: { + userId: otherUser.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "T", + message: "M", + }, + }); + + const res = await request(app) + .patch(`/api/notifications/${notif.id}/read`) + .set("Cookie", userCookie); + + expect(res.status).toBe(403); + }); + }); + + // ===================================== + // PATCH /api/notifications/read (batch) + // ===================================== + describe("PATCH /api/notifications/read (batch)", () => { + it("should mark multiple notifications as read", async () => { + const n1 = await createNotif({ title: "N1" }); + const n2 = await createNotif({ title: "N2" }); + + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({ ids: [n1.id, n2.id] }); + + expect(res.status).toBe(200); + expect(res.body.updated).toBe(2); + + // Verify in DB + const updated = await testPrisma.notification.findMany({ + where: { id: { in: [n1.id, n2.id] } }, + }); + expect(updated.every((n) => n.readAt !== null)).toBe(true); + }); + + it("should return 400 for empty ids", async () => { + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({ ids: [] }); + + expect(res.status).toBe(400); + }); + + it("should return 400 for missing ids", async () => { + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({}); + + expect(res.status).toBe(400); + }); + + it("should return 403 if any notification belongs to another user", async () => { + const n1 = await createNotif(); + const otherUser = await testPrisma.user.create({ + data: { + username: `oth2_${uniqueSuffix()}`, + email: `oth2_${uniqueSuffix()}@example.com`, + password: "h", + }, + }); + const n2 = await testPrisma.notification.create({ + data: { + userId: otherUser.id, + type: "INVITE_SENT", + category: "INVITATION", + title: "T", + message: "M", + }, + }); + + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({ ids: [n1.id, n2.id] }); + + expect(res.status).toBe(403); + }); + + it("should skip already read notifications (count only new reads)", async () => { + const n1 = await createNotif(); + const n2 = await createNotif({ readAt: new Date() }); + + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({ ids: [n1.id, n2.id] }); + + expect(res.status).toBe(200); + expect(res.body.updated).toBe(1); + }); + + it("should return 400 if ids contain non-string values", async () => { + const res = await request(app) + .patch("/api/notifications/read") + .set("Cookie", userCookie) + .send({ ids: [123, true, null] }); + + expect(res.status).toBe(400); + }); + }); + + // ===================================== + // PATCH /api/notifications/read-all + // ===================================== + describe("PATCH /api/notifications/read-all", () => { + it("should mark all unread notifications as read", async () => { + await createNotif(); + await createNotif(); + await createNotif({ readAt: new Date() }); + + const res = await request(app) + .patch("/api/notifications/read-all") + .set("Cookie", userCookie) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.updated).toBe(2); + }); + + it("should filter by category when provided", async () => { + await createNotif({ category: "INVITATION" }); + await createNotif({ category: "TAG", type: "tag:approved" }); + + const res = await request(app) + .patch("/api/notifications/read-all") + .set("Cookie", userCookie) + .send({ category: "INVITATION" }); + + expect(res.status).toBe(200); + expect(res.body.updated).toBe(1); + + // TAG notification should still be unread + const remaining = await testPrisma.notification.count({ + where: { userId: user.id, readAt: null }, + }); + expect(remaining).toBe(1); + }); + + it("should return 400 for invalid category", async () => { + const res = await request(app) + .patch("/api/notifications/read-all") + .set("Cookie", userCookie) + .send({ category: "INVALID" }); + + expect(res.status).toBe(400); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).patch("/api/notifications/read-all").send({}); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/integration/proposals.test.ts b/backend/src/__tests__/integration/proposals.test.ts index 78567951..f877a55a 100644 --- a/backend/src/__tests__/integration/proposals.test.ts +++ b/backend/src/__tests__/integration/proposals.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { extractSessionCookie } from "../setup/testHelpers"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Proposals API", () => { let recipeCreator: { id: string; username: string; email: string }; let recipeCreatorCookie: string; @@ -22,11 +19,13 @@ describe("Proposals API", () => { const suffix = uniqueSuffix(); // Create recipe creator (moderator) via signup - const creatorSignup = await request(app).post("/api/auth/signup").send({ - username: `propcreator_${suffix}`, - email: `propcreator_${suffix}@example.com`, - password: "Test123!Password", - }); + const creatorSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `propcreator_${suffix}`, + email: `propcreator_${suffix}@example.com`, + password: "Test123!Password", + }); recipeCreatorCookie = extractSessionCookie(creatorSignup)!; recipeCreator = (await testPrisma.user.findFirst({ where: { email: `propcreator_${suffix}@example.com` }, @@ -40,11 +39,13 @@ describe("Proposals API", () => { community = createRes.body; // Create proposer (member) via signup - const proposerSignup = await request(app).post("/api/auth/signup").send({ - username: `proposer_${suffix}`, - email: `proposer_${suffix}@example.com`, - password: "Test123!Password", - }); + const proposerSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `proposer_${suffix}`, + email: `proposer_${suffix}@example.com`, + password: "Test123!Password", + }); proposerCookie = extractSessionCookie(proposerSignup)!; proposer = (await testPrisma.user.findFirst({ where: { email: `proposer_${suffix}@example.com` }, @@ -60,11 +61,13 @@ describe("Proposals API", () => { }); // Create non-member via signup - const nonMemberSignup = await request(app).post("/api/auth/signup").send({ - username: `propnonm_${suffix}`, - email: `propnonm_${suffix}@example.com`, - password: "Test123!Password", - }); + const nonMemberSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `propnonm_${suffix}`, + email: `propnonm_${suffix}@example.com`, + password: "Test123!Password", + }); nonMemberCookie = extractSessionCookie(nonMemberSignup)!; _nonMember = (await testPrisma.user.findFirst({ where: { email: `propnonm_${suffix}@example.com` }, @@ -76,7 +79,8 @@ describe("Proposals API", () => { .set("Cookie", recipeCreatorCookie) .send({ title: "Original Recipe", - content: "Original content for the recipe", + servings: 4, + steps: [{ instruction: "Original step for the recipe" }], }); communityRecipeId = recipeRes.body.community.id; personalRecipeId = recipeRes.body.personal.id; @@ -92,24 +96,45 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Improved Recipe", - proposedContent: "Better content for the recipe", + proposedSteps: [{ instruction: "Better step for the recipe" }], }); expect(res.status).toBe(201); expect(res.body.proposedTitle).toBe("Improved Recipe"); - expect(res.body.proposedContent).toBe("Better content for the recipe"); + expect(res.body.proposedSteps).toHaveLength(1); + expect(res.body.proposedSteps[0].instruction).toBe("Better step for the recipe"); expect(res.body.status).toBe("PENDING"); expect(res.body.proposerId).toBe(proposer.id); expect(res.body.recipeId).toBe(communityRecipeId); }); + it("should create proposal with servings and times", async () => { + const res = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", proposerCookie) + .send({ + proposedTitle: "With servings", + proposedServings: 8, + proposedPrepTime: 15, + proposedCookTime: 30, + proposedRestTime: 10, + proposedSteps: [{ instruction: "Step" }], + }); + + expect(res.status).toBe(201); + expect(res.body.proposedServings).toBe(8); + expect(res.body.proposedPrepTime).toBe(15); + expect(res.body.proposedCookTime).toBe(30); + expect(res.body.proposedRestTime).toBe(10); + }); + it("should return 403 when user is not a member", async () => { const res = await request(app) .post(`/api/recipes/${communityRecipeId}/proposals`) .set("Cookie", nonMemberCookie) .send({ proposedTitle: "Hacked Recipe", - proposedContent: "Hacked content", + proposedSteps: [{ instruction: "Hacked step" }], }); expect(res.status).toBe(403); @@ -122,7 +147,7 @@ describe("Proposals API", () => { .set("Cookie", recipeCreatorCookie) .send({ proposedTitle: "Self improvement", - proposedContent: "Self content", + proposedSteps: [{ instruction: "Self step" }], }); expect(res.status).toBe(400); @@ -135,7 +160,7 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Proposal on personal", - proposedContent: "Content for personal", + proposedSteps: [{ instruction: "Step for personal" }], }); expect(res.status).toBe(400); @@ -144,11 +169,11 @@ describe("Proposals API", () => { it("should return 404 when recipe not found", async () => { const res = await request(app) - .post(`/api/recipes/00000000-0000-0000-0000-000000000000/proposals`) + .post(`/api/recipes/00000000-0000-4000-8000-000000000000/proposals`) .set("Cookie", proposerCookie) .send({ proposedTitle: "Ghost Recipe", - proposedContent: "Ghost content", + proposedSteps: [{ instruction: "Ghost step" }], }); expect(res.status).toBe(404); @@ -160,23 +185,23 @@ describe("Proposals API", () => { .post(`/api/recipes/${communityRecipeId}/proposals`) .set("Cookie", proposerCookie) .send({ - proposedContent: "Content without title", + proposedSteps: [{ instruction: "Step without title" }], }); expect(res.status).toBe(400); expect(res.body.error).toContain("RECIPE_003"); }); - it("should return 400 when content is missing", async () => { + it("should return 400 when steps is missing", async () => { const res = await request(app) .post(`/api/recipes/${communityRecipeId}/proposals`) .set("Cookie", proposerCookie) .send({ - proposedTitle: "Title without content", + proposedTitle: "Title without steps", }); expect(res.status).toBe(400); - expect(res.body.error).toContain("RECIPE_004"); + expect(res.body.error).toContain("RECIPE_007"); }); it("should return 401 when not authenticated", async () => { @@ -184,7 +209,7 @@ describe("Proposals API", () => { .post(`/api/recipes/${communityRecipeId}/proposals`) .send({ proposedTitle: "Unauthorized", - proposedContent: "Content", + proposedSteps: [{ instruction: "Step" }], }); expect(res.status).toBe(401); @@ -196,7 +221,7 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Logged Proposal", - proposedContent: "Logged content", + proposedSteps: [{ instruction: "Logged step" }], }); expect(res.status).toBe(201); @@ -226,7 +251,7 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Proposal 1", - proposedContent: "Content 1", + proposedSteps: [{ instruction: "Step 1" }], }); await request(app) @@ -234,7 +259,7 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Proposal 2", - proposedContent: "Content 2", + proposedSteps: [{ instruction: "Step 2" }], }); }); @@ -268,8 +293,7 @@ describe("Proposals API", () => { }); it("should return 401 when not authenticated", async () => { - const res = await request(app) - .get(`/api/recipes/${communityRecipeId}/proposals`); + const res = await request(app).get(`/api/recipes/${communityRecipeId}/proposals`); expect(res.status).toBe(401); }); @@ -287,7 +311,7 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Detail Proposal", - proposedContent: "Detail content", + proposedSteps: [{ instruction: "Detail step" }], }); proposalId = createRes.body.id; }); @@ -300,6 +324,7 @@ describe("Proposals API", () => { expect(res.status).toBe(200); expect(res.body.id).toBe(proposalId); expect(res.body.proposedTitle).toBe("Detail Proposal"); + expect(res.body.proposedSteps).toHaveLength(1); expect(res.body.proposer).toBeDefined(); expect(res.body.recipe).toBeDefined(); }); @@ -314,7 +339,7 @@ describe("Proposals API", () => { it("should return 404 when proposal not found", async () => { const res = await request(app) - .get(`/api/proposals/00000000-0000-0000-0000-000000000000`) + .get(`/api/proposals/00000000-0000-4000-8000-000000000000`) .set("Cookie", proposerCookie); expect(res.status).toBe(404); @@ -333,7 +358,9 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Accepted Proposal", - proposedContent: "Accepted content", + proposedServings: 6, + proposedPrepTime: 10, + proposedSteps: [{ instruction: "Accepted step" }], }); proposalId = createRes.body.id; }); @@ -353,7 +380,10 @@ describe("Proposals API", () => { .set("Cookie", recipeCreatorCookie); expect(recipeRes.body.title).toBe("Accepted Proposal"); - expect(recipeRes.body.content).toBe("Accepted content"); + expect(recipeRes.body.servings).toBe(6); + expect(recipeRes.body.prepTime).toBe(10); + expect(recipeRes.body.steps).toHaveLength(1); + expect(recipeRes.body.steps[0].instruction).toBe("Accepted step"); }); it("should cascade to personal recipe", async () => { @@ -367,7 +397,9 @@ describe("Proposals API", () => { .set("Cookie", recipeCreatorCookie); expect(personalRes.body.title).toBe("Accepted Proposal"); - expect(personalRes.body.content).toBe("Accepted content"); + expect(personalRes.body.servings).toBe(6); + expect(personalRes.body.steps).toHaveLength(1); + expect(personalRes.body.steps[0].instruction).toBe("Accepted step"); }); it("should cascade to other community copies", async () => { @@ -384,10 +416,13 @@ describe("Proposals API", () => { const recipe2 = await testPrisma.recipe.create({ data: { title: "Original Recipe", - content: "Original content for the recipe", + servings: 4, creatorId: recipeCreator.id, communityId: community2.id, originRecipeId: personalRecipeId, + steps: { + create: [{ order: 0, instruction: "Original step for the recipe" }], + }, }, }); @@ -399,10 +434,13 @@ describe("Proposals API", () => { // Verify the second community recipe was also updated const recipe2Updated = await testPrisma.recipe.findUnique({ where: { id: recipe2.id }, + include: { steps: { orderBy: { order: "asc" } } }, }); expect(recipe2Updated?.title).toBe("Accepted Proposal"); - expect(recipe2Updated?.content).toBe("Accepted content"); + expect(recipe2Updated?.servings).toBe(6); + expect(recipe2Updated?.steps).toHaveLength(1); + expect(recipe2Updated?.steps[0].instruction).toBe("Accepted step"); }); it("should return 403 when user is not the recipe creator", async () => { @@ -464,7 +502,7 @@ describe("Proposals API", () => { it("should return 404 when proposal not found", async () => { const res = await request(app) - .post(`/api/proposals/00000000-0000-0000-0000-000000000000/accept`) + .post(`/api/proposals/00000000-0000-4000-8000-000000000000/accept`) .set("Cookie", recipeCreatorCookie); expect(res.status).toBe(404); @@ -483,7 +521,8 @@ describe("Proposals API", () => { .set("Cookie", proposerCookie) .send({ proposedTitle: "Rejected Proposal", - proposedContent: "Rejected content", + proposedServings: 8, + proposedSteps: [{ instruction: "Rejected step" }], }); proposalId = createRes.body.id; }); @@ -499,7 +538,15 @@ describe("Proposals API", () => { expect(res.body.variant).toBeDefined(); expect(res.body.variant.isVariant).toBe(true); expect(res.body.variant.title).toBe("Rejected Proposal"); - expect(res.body.variant.content).toBe("Rejected content"); + expect(res.body.variant.servings).toBe(8); + + // Verify the variant has the proposed steps + const variantSteps = await testPrisma.recipeStep.findMany({ + where: { recipeId: res.body.variant.id }, + orderBy: { order: "asc" }, + }); + expect(variantSteps).toHaveLength(1); + expect(variantSteps[0].instruction).toBe("Rejected step"); }); it("should set the variant creatorId to the proposer", async () => { @@ -565,10 +612,342 @@ describe("Proposals API", () => { it("should return 404 when proposal not found", async () => { const res = await request(app) - .post(`/api/proposals/00000000-0000-0000-0000-000000000000/reject`) + .post(`/api/proposals/00000000-0000-4000-8000-000000000000/reject`) .set("Cookie", recipeCreatorCookie); 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", + proposedSteps: [{ instruction: "Step 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", + proposedSteps: [{ instruction: "Step" }], + 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", + proposedSteps: [{ instruction: "Step" }], + 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", + proposedSteps: [{ instruction: "Step" }], + 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", + proposedSteps: [{ instruction: "Step" }], + }); + + 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", + proposedSteps: [{ instruction: "Step" }], + 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", + proposedSteps: [{ instruction: "New step 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", + proposedSteps: [{ instruction: "Only step change" }], + }); + + // Contourner la contrainte updatedAt > createdAt + 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", + proposedSteps: [{ instruction: "Step" }], + 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", + proposedSteps: [{ instruction: "Step" }], + }); + + 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", + proposedSteps: [{ instruction: "Step" }], + 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/__tests__/integration/recipeImage.test.ts b/backend/src/__tests__/integration/recipeImage.test.ts new file mode 100644 index 00000000..b454852e --- /dev/null +++ b/backend/src/__tests__/integration/recipeImage.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, createTestRecipe, extractSessionCookie } from "../setup/testHelpers"; + +// Mock storageService (pas de MinIO en CI) +vi.mock("../../services/storageService", () => ({ + generatePresignedUploadUrl: vi.fn().mockResolvedValue("https://minio.test/presigned"), + validateUploadedFile: vi.fn().mockResolvedValue(null), // valide par defaut + deleteObject: vi.fn().mockResolvedValue(undefined), + headObject: vi.fn().mockResolvedValue({ contentType: "image/webp", contentLength: 50000 }), +})); + +import { validateUploadedFile } from "../../services/storageService"; + +const NOT_FOUND_UUID = "00000000-0000-4000-8000-000000000000"; + +describe("Recipe Image API", () => { + let owner: Awaited>; + let ownerCookie: string | null; + let otherUser: Awaited>; + let otherCookie: string | null; + let recipe: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + + owner = await createTestUser(); + const ownerLogin = await request(app) + .post("/api/auth/login") + .send({ username: owner.username, password: owner.password }); + ownerCookie = extractSessionCookie(ownerLogin); + + otherUser = await createTestUser(); + const otherLogin = await request(app) + .post("/api/auth/login") + .send({ username: otherUser.username, password: otherUser.password }); + otherCookie = extractSessionCookie(otherLogin); + + recipe = await createTestRecipe(owner.id); + }); + + // ===================================== + // POST /api/recipes/:recipeId/upload-url + // ===================================== + describe("POST /api/recipes/:recipeId/upload-url", () => { + it("should return presigned URL for recipe owner", async () => { + const res = await request(app) + .post(`/api/recipes/${recipe.id}/upload-url`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(200); + expect(res.body.uploadUrl).toBe("https://minio.test/presigned"); + expect(res.body.imageKey).toBe(`recipes/${recipe.id}/cover.webp`); + }); + + it("should return 403 for non-owner", async () => { + const res = await request(app) + .post(`/api/recipes/${recipe.id}/upload-url`) + .set("Cookie", otherCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .post(`/api/recipes/${NOT_FOUND_UUID}/upload-url`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(404); + }); + + it("should return 401 without auth", async () => { + const res = await request(app).post(`/api/recipes/${recipe.id}/upload-url`); + + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // POST /api/recipes/:recipeId/confirm-upload + // ===================================== + describe("POST /api/recipes/:recipeId/confirm-upload", () => { + it("should confirm upload and save imageKey", async () => { + const res = await request(app) + .post(`/api/recipes/${recipe.id}/confirm-upload`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(200); + expect(res.body.imageKey).toBe(`recipes/${recipe.id}/cover.webp`); + expect(res.body.imageUrl).toBeDefined(); + }); + + it("should return 400 when file validation fails", async () => { + vi.mocked(validateUploadedFile).mockResolvedValueOnce("File too large"); + + const res = await request(app) + .post(`/api/recipes/${recipe.id}/confirm-upload`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_005"); + }); + + it("should return 403 for non-owner", async () => { + const res = await request(app) + .post(`/api/recipes/${recipe.id}/confirm-upload`) + .set("Cookie", otherCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .post(`/api/recipes/${NOT_FOUND_UUID}/confirm-upload`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(404); + }); + }); + + // ===================================== + // DELETE /api/recipes/:recipeId/image + // ===================================== + describe("DELETE /api/recipes/:recipeId/image", () => { + it("should delete image and return 204", async () => { + // D'abord confirmer un upload pour avoir un imageKey en DB + await request(app) + .post(`/api/recipes/${recipe.id}/confirm-upload`) + .set("Cookie", ownerCookie!); + + const res = await request(app) + .delete(`/api/recipes/${recipe.id}/image`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(204); + }); + + it("should return 204 even if no image exists (idempotent)", async () => { + const res = await request(app) + .delete(`/api/recipes/${recipe.id}/image`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(204); + }); + + it("should return 403 for non-owner", async () => { + const res = await request(app) + .delete(`/api/recipes/${recipe.id}/image`) + .set("Cookie", otherCookie!); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .delete(`/api/recipes/${NOT_FOUND_UUID}/image`) + .set("Cookie", ownerCookie!); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/__tests__/integration/recipeImport.test.ts b/backend/src/__tests__/integration/recipeImport.test.ts new file mode 100644 index 00000000..f7a711b2 --- /dev/null +++ b/backend/src/__tests__/integration/recipeImport.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, extractSessionCookie } from "../setup/testHelpers"; + +describe("Recipe Import API", () => { + let sessionCookie: string | null; + + beforeEach(async () => { + const testUser = await createTestUser(); + const loginRes = await request(app).post("/api/auth/login").send({ + username: testUser.username, + password: testUser.password, + }); + sessionCookie = extractSessionCookie(loginRes); + }); + + // ===================================== + // POST /api/recipes/import-url + // ===================================== + describe("POST /api/recipes/import-url", () => { + it("should return 401 when not authenticated", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .send({ url: "https://example.com/recipe" }); + + expect(res.status).toBe(401); + }); + + it("should return 400 when url is missing", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .set("Cookie", sessionCookie!) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("IMPORT_001"); + }); + + it("should return 400 when url is not a string", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .set("Cookie", sessionCookie!) + .send({ url: 12345 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("IMPORT_001"); + }); + + it("should return 400 for invalid URL format", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .set("Cookie", sessionCookie!) + .send({ url: "not-a-url" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("IMPORT_001"); + }); + + it("should return 400 for SSRF attempt (localhost)", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .set("Cookie", sessionCookie!) + .send({ url: "http://localhost:3000/secret" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("IMPORT_001"); + }); + + it("should return 400 for SSRF attempt (private IP)", async () => { + const res = await request(app) + .post("/api/recipes/import-url") + .set("Cookie", sessionCookie!) + .send({ url: "http://192.168.1.1/admin" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("IMPORT_001"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/recipes.test.ts b/backend/src/__tests__/integration/recipes.test.ts index 168e51da..14ffa960 100644 --- a/backend/src/__tests__/integration/recipes.test.ts +++ b/backend/src/__tests__/integration/recipes.test.ts @@ -1,109 +1,249 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; -import { createTestUser, createTestRecipe, extractSessionCookie } from '../setup/testHelpers'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestUser, createTestRecipe, extractSessionCookie } from "../setup/testHelpers"; -describe('Recipes API', () => { +describe("Recipes API", () => { let testUser: Awaited>; let sessionCookie: string | null; beforeEach(async () => { // Creer un user et obtenir sa session testUser = await createTestUser(); - const loginRes = await request(app) - .post('/api/auth/login') - .send({ - username: testUser.username, - password: testUser.password, - }); + const loginRes = await request(app).post("/api/auth/login").send({ + username: testUser.username, + password: testUser.password, + }); sessionCookie = extractSessionCookie(loginRes); }); // ===================================== // POST /api/recipes // ===================================== - describe('POST /api/recipes', () => { - it('should create recipe with minimal data (title + content)', async () => { + describe("POST /api/recipes", () => { + it("should create recipe with minimal data (title + servings + steps)", async () => { const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Ma recette', - content: 'Contenu de la recette', + title: "Ma recette", + servings: 4, + steps: [{ instruction: "Etape 1" }], }); expect(res.status).toBe(201); - expect(res.body.title).toBe('Ma recette'); - expect(res.body.content).toBe('Contenu de la recette'); + expect(res.body.title).toBe("Ma recette"); + expect(res.body.servings).toBe(4); + expect(res.body.steps).toHaveLength(1); + expect(res.body.steps[0].instruction).toBe("Etape 1"); expect(res.body.id).toBeDefined(); }); - it('should create recipe with tags and ingredients', async () => { + it("should create recipe with all fields", async () => { const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Recette complete', - content: 'Contenu', - tags: ['dessert', 'rapide'], + title: "Recette complete", + servings: 6, + prepTime: 15, + cookTime: 30, + restTime: 10, + steps: [{ instruction: "Preparer les ingredients" }, { instruction: "Melanger" }], + tags: ["dessert", "rapide"], ingredients: [ - { name: 'sucre', quantity: '100g' }, - { name: 'farine', quantity: '200g' }, + { name: "sucre", quantity: 100 }, + { name: "farine", quantity: 200 }, ], }); expect(res.status).toBe(201); + expect(res.body.servings).toBe(6); + expect(res.body.prepTime).toBe(15); + expect(res.body.cookTime).toBe(30); + expect(res.body.restTime).toBe(10); + expect(res.body.steps).toHaveLength(2); expect(res.body.tags).toHaveLength(2); expect(res.body.ingredients).toHaveLength(2); }); - it('should return 400 when title is missing', async () => { + it("should return 400 when title is missing", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + servings: 4, + steps: [{ instruction: "Step" }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_003"); + }); + + it("should return 400 when servings is missing", async () => { const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - content: 'Contenu', + title: "Titre", + steps: [{ instruction: "Step" }], }); expect(res.status).toBe(400); - expect(res.body.error).toContain('RECIPE_003'); + expect(res.body.error).toContain("RECIPE_006"); }); - it('should return 400 when content is missing', async () => { + it("should return 400 when servings is 0", async () => { const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Titre', + title: "Titre", + servings: 0, + steps: [{ instruction: "Step" }], }); expect(res.status).toBe(400); - expect(res.body.error).toContain('RECIPE_004'); + expect(res.body.error).toContain("RECIPE_006"); }); - it('should deduplicate tags (case insensitive)', async () => { + it("should return 400 when servings is negative", async () => { const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Recette', - content: 'Contenu', - tags: ['Dessert', 'dessert', 'DESSERT'], + title: "Titre", + servings: -1, + steps: [{ instruction: "Step" }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_006"); + }); + + it("should return 400 when servings exceeds 100", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Titre", + servings: 101, + steps: [{ instruction: "Step" }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_006"); + }); + + it("should return 400 when steps is missing", async () => { + const res = await request(app).post("/api/recipes").set("Cookie", sessionCookie!).send({ + title: "Titre", + servings: 4, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_007"); + }); + + it("should return 400 when steps is empty array", async () => { + const res = await request(app).post("/api/recipes").set("Cookie", sessionCookie!).send({ + title: "Titre", + servings: 4, + steps: [], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_007"); + }); + + it("should return 400 when step instruction is empty", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Titre", + servings: 4, + steps: [{ instruction: " " }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_007"); + }); + + it("should return 400 when time is negative", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Titre", + servings: 4, + steps: [{ instruction: "Step" }], + prepTime: -5, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_008"); + }); + + it("should return 400 when time exceeds 10000", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Titre", + servings: 4, + steps: [{ instruction: "Step" }], + cookTime: 10001, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_008"); + }); + + it("should allow null times", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Titre", + servings: 4, + steps: [{ instruction: "Step" }], + prepTime: null, + cookTime: null, + restTime: null, + }); + + expect(res.status).toBe(201); + expect(res.body.prepTime).toBeNull(); + expect(res.body.cookTime).toBeNull(); + expect(res.body.restTime).toBeNull(); + }); + + it("should deduplicate tags (case insensitive)", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Recette", + servings: 4, + steps: [{ instruction: "Step" }], + tags: ["Dessert", "dessert", "DESSERT"], }); expect(res.status).toBe(201); expect(res.body.tags).toHaveLength(1); - expect(res.body.tags[0].name).toBe('dessert'); + expect(res.body.tags[0].name).toBe("dessert"); }); - it('should create tags on-the-fly', async () => { + it("should create tags on-the-fly", async () => { const uniqueTag = `tag_${Date.now()}`; const res = await request(app) - .post('/api/recipes') - .set('Cookie', sessionCookie!) + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Recette', - content: 'Contenu', + title: "Recette", + servings: 4, + steps: [{ instruction: "Step" }], tags: [uniqueTag], }); @@ -112,12 +252,63 @@ describe('Recipes API', () => { expect(res.body.tags[0].name).toBe(uniqueTag); }); - it('should return 401 when not authenticated', async () => { + it("should return 400 when title is not a string", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ title: 123, servings: 4, steps: [{ instruction: "Step" }] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when title is too long", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ title: "a".repeat(201), servings: 4, steps: [{ instruction: "Step" }] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when tags is not an array", async () => { + const res = await request(app) + .post("/api/recipes") + .set("Cookie", sessionCookie!) + .send({ + title: "Recette", + servings: 4, + steps: [{ instruction: "Step" }], + tags: "notarray", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when ingredient quantity is negative", async () => { const res = await request(app) - .post('/api/recipes') + .post("/api/recipes") + .set("Cookie", sessionCookie!) .send({ - title: 'Recette', - content: 'Contenu', + title: "Recette", + servings: 4, + steps: [{ instruction: "Step" }], + ingredients: [{ name: "farine", quantity: -1 }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 401 when not authenticated", async () => { + const res = await request(app) + .post("/api/recipes") + .send({ + title: "Recette", + servings: 4, + steps: [{ instruction: "Step" }], }); expect(res.status).toBe(401); @@ -127,94 +318,117 @@ describe('Recipes API', () => { // ===================================== // GET /api/recipes // ===================================== - describe('GET /api/recipes', () => { + describe("GET /api/recipes", () => { beforeEach(async () => { // Creer quelques recettes - await createTestRecipe(testUser.id, { title: 'Recette 1' }); - await createTestRecipe(testUser.id, { title: 'Recette 2', tags: ['dessert'] }); - await createTestRecipe(testUser.id, { title: 'Gateau chocolat', tags: ['dessert', 'chocolat'] }); + await createTestRecipe(testUser.id, { title: "Recette 1" }); + await createTestRecipe(testUser.id, { title: "Recette 2", tags: ["dessert"] }); + await createTestRecipe(testUser.id, { + title: "Gateau chocolat", + tags: ["dessert", "chocolat"], + }); }); - it('should list recipes with default pagination', async () => { - const res = await request(app) - .get('/api/recipes') - .set('Cookie', sessionCookie!); + it("should list recipes with default pagination", async () => { + const res = await request(app).get("/api/recipes").set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(3); expect(res.body.pagination.limit).toBe(20); }); - it('should respect limit parameter', async () => { - const res = await request(app) - .get('/api/recipes?limit=2') - .set('Cookie', sessionCookie!); + it("should include servings and times in list response", async () => { + const res = await request(app).get("/api/recipes").set("Cookie", sessionCookie!); + + expect(res.status).toBe(200); + const recipe = res.body.data[0]; + expect(recipe).toHaveProperty("servings"); + expect(recipe).toHaveProperty("prepTime"); + expect(recipe).toHaveProperty("cookTime"); + expect(recipe).toHaveProperty("restTime"); + }); + + it("should respect limit parameter", async () => { + const res = await request(app).get("/api/recipes?limit=2").set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(2); expect(res.body.pagination.hasMore).toBe(true); }); - it('should respect offset parameter', async () => { + it("should respect offset parameter", async () => { const res = await request(app) - .get('/api/recipes?limit=2&offset=2') - .set('Cookie', sessionCookie!); + .get("/api/recipes?limit=2&offset=2") + .set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); expect(res.body.pagination.offset).toBe(2); }); - it('should cap limit at 100', async () => { - const res = await request(app) - .get('/api/recipes?limit=200') - .set('Cookie', sessionCookie!); + it("should cap limit at 100", async () => { + const res = await request(app).get("/api/recipes?limit=200").set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.pagination.limit).toBe(100); }); - it('should filter by tags (AND logic)', async () => { + it("should filter by tags (AND logic)", async () => { const res = await request(app) - .get('/api/recipes?tags=dessert,chocolat') - .set('Cookie', sessionCookie!); + .get("/api/recipes?tags=dessert,chocolat") + .set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); - expect(res.body.data[0].title).toBe('Gateau chocolat'); + expect(res.body.data[0].title).toBe("Gateau chocolat"); }); - it('should filter by single tag', async () => { - const res = await request(app) - .get('/api/recipes?tags=dessert') - .set('Cookie', sessionCookie!); + it("should filter by single tag", async () => { + const res = await request(app).get("/api/recipes?tags=dessert").set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(2); }); - it('should search by title (case insensitive)', async () => { + it("should search by title (case insensitive)", async () => { const res = await request(app) - .get('/api/recipes?search=gateau') - .set('Cookie', sessionCookie!); + .get("/api/recipes?search=gateau") + .set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); - expect(res.body.data[0].title).toContain('Gateau'); + expect(res.body.data[0].title).toContain("Gateau"); }); - it('should return empty array when no match', async () => { + it("should return empty array when no match", async () => { const res = await request(app) - .get('/api/recipes?search=inexistant') - .set('Cookie', sessionCookie!); + .get("/api/recipes?search=inexistant") + .set("Cookie", sessionCookie!); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(0); }); - it('should return 401 when not authenticated', async () => { + it("should return 400 when too many tag filters", async () => { + const tags = Array.from({ length: 21 }, (_, i) => `tag${i}`).join(","); + const res = await request(app).get(`/api/recipes?tags=${tags}`).set("Cookie", sessionCookie!); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when search query is too long", async () => { + const search = "a".repeat(201); const res = await request(app) - .get('/api/recipes'); + .get(`/api/recipes?search=${search}`) + .set("Cookie", sessionCookie!); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 401 when not authenticated", async () => { + const res = await request(app).get("/api/recipes"); expect(res.status).toBe(401); }); @@ -223,149 +437,208 @@ describe('Recipes API', () => { // ===================================== // GET /api/recipes/:id // ===================================== - describe('GET /api/recipes/:id', () => { - it('should return recipe details', async () => { + describe("GET /api/recipes/:id", () => { + it("should return recipe details with steps and servings", async () => { const recipe = await createTestRecipe(testUser.id, { - title: 'Ma recette', - content: 'Mon contenu', - tags: ['tag1'], - ingredients: [{ name: 'ingredient1', quantity: '100g' }], + title: "Ma recette", + servings: 6, + prepTime: 10, + cookTime: 20, + steps: [{ instruction: "Etape 1" }, { instruction: "Etape 2" }], + tags: ["tag1"], + ingredients: [{ name: "ingredient1", quantity: 100 }], }); - const res = await request(app) - .get(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!); + const res = await request(app).get(`/api/recipes/${recipe.id}`).set("Cookie", sessionCookie!); expect(res.status).toBe(200); - expect(res.body.title).toBe('Ma recette'); - expect(res.body.content).toBe('Mon contenu'); + expect(res.body.title).toBe("Ma recette"); + expect(res.body.servings).toBe(6); + expect(res.body.prepTime).toBe(10); + expect(res.body.cookTime).toBe(20); + expect(res.body.restTime).toBeNull(); + expect(res.body.steps).toHaveLength(2); + expect(res.body.steps[0].instruction).toBe("Etape 1"); + expect(res.body.steps[1].instruction).toBe("Etape 2"); expect(res.body.tags).toBeDefined(); expect(res.body.ingredients).toBeDefined(); }); - it('should return 404 for non-existent recipe', async () => { + it("should return 404 for non-existent recipe", async () => { const res = await request(app) - .get('/api/recipes/00000000-0000-0000-0000-000000000000') - .set('Cookie', sessionCookie!); + .get("/api/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", sessionCookie!); expect(res.status).toBe(404); - expect(res.body.error).toContain('RECIPE_001'); + expect(res.body.error).toContain("RECIPE_001"); }); - it('should return 403 for another users recipe', async () => { + it("should return 403 for another users recipe", async () => { // Creer un autre user et sa recette - const otherUser = await createTestUser({ username: 'otheruser' }); - const otherRecipe = await createTestRecipe(otherUser.id, { title: 'Other recipe' }); + const otherUser = await createTestUser({ username: "otheruser" }); + const otherRecipe = await createTestRecipe(otherUser.id, { title: "Other recipe" }); const res = await request(app) .get(`/api/recipes/${otherRecipe.id}`) - .set('Cookie', sessionCookie!); + .set("Cookie", sessionCookie!); expect(res.status).toBe(403); - expect(res.body.error).toContain('RECIPE_002'); + expect(res.body.error).toContain("RECIPE_002"); }); }); // ===================================== // PATCH /api/recipes/:id // ===================================== - describe('PATCH /api/recipes/:id', () => { - it('should update recipe title', async () => { - const recipe = await createTestRecipe(testUser.id, { title: 'Ancien titre' }); + describe("PATCH /api/recipes/:id", () => { + it("should update recipe title", async () => { + const recipe = await createTestRecipe(testUser.id, { title: "Ancien titre" }); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) - .send({ title: 'Nouveau titre' }); + .set("Cookie", sessionCookie!) + .send({ title: "Nouveau titre" }); expect(res.status).toBe(200); - expect(res.body.title).toBe('Nouveau titre'); + expect(res.body.title).toBe("Nouveau titre"); }); - it('should update recipe content', async () => { - const recipe = await createTestRecipe(testUser.id, { content: 'Ancien contenu' }); + it("should update recipe servings", async () => { + const recipe = await createTestRecipe(testUser.id, { servings: 4 }); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) - .send({ content: 'Nouveau contenu' }); + .set("Cookie", sessionCookie!) + .send({ servings: 8 }); expect(res.status).toBe(200); - expect(res.body.content).toBe('Nouveau contenu'); + expect(res.body.servings).toBe(8); }); - it('should replace tags completely', async () => { + it("should update recipe steps", async () => { + const recipe = await createTestRecipe(testUser.id); + + const res = await request(app) + .patch(`/api/recipes/${recipe.id}`) + .set("Cookie", sessionCookie!) + .send({ steps: [{ instruction: "New step 1" }, { instruction: "New step 2" }] }); + + expect(res.status).toBe(200); + expect(res.body.steps).toHaveLength(2); + expect(res.body.steps[0].instruction).toBe("New step 1"); + expect(res.body.steps[1].instruction).toBe("New step 2"); + }); + + it("should update recipe times", async () => { + const recipe = await createTestRecipe(testUser.id); + + const res = await request(app) + .patch(`/api/recipes/${recipe.id}`) + .set("Cookie", sessionCookie!) + .send({ prepTime: 15, cookTime: 45, restTime: 10 }); + + expect(res.status).toBe(200); + expect(res.body.prepTime).toBe(15); + expect(res.body.cookTime).toBe(45); + expect(res.body.restTime).toBe(10); + }); + + it("should replace tags completely", async () => { const recipe = await createTestRecipe(testUser.id, { - title: 'Recette', - tags: ['ancien'], + title: "Recette", + tags: ["ancien"], }); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) - .send({ tags: ['nouveau', 'tag'] }); + .set("Cookie", sessionCookie!) + .send({ tags: ["nouveau", "tag"] }); expect(res.status).toBe(200); expect(res.body.tags).toHaveLength(2); - expect(res.body.tags.map((t: { name: string }) => t.name)).not.toContain('ancien'); + expect(res.body.tags.map((t: { name: string }) => t.name)).not.toContain("ancien"); }); - it('should allow empty tags array', async () => { + it("should allow empty tags array", async () => { const recipe = await createTestRecipe(testUser.id, { - title: 'Recette', - tags: ['tag'], + title: "Recette", + tags: ["tag"], }); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) + .set("Cookie", sessionCookie!) .send({ tags: [] }); expect(res.status).toBe(200); expect(res.body.tags).toHaveLength(0); }); - it('should return 400 for empty title', async () => { + it("should return 400 for empty title", async () => { + const recipe = await createTestRecipe(testUser.id); + + const res = await request(app) + .patch(`/api/recipes/${recipe.id}`) + .set("Cookie", sessionCookie!) + .send({ title: "" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_003"); + }); + + it("should return 400 for invalid servings on update", async () => { const recipe = await createTestRecipe(testUser.id); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) - .send({ title: '' }); + .set("Cookie", sessionCookie!) + .send({ servings: 0 }); expect(res.status).toBe(400); - expect(res.body.error).toContain('RECIPE_003'); + expect(res.body.error).toContain("RECIPE_006"); }); - it('should return 400 for empty content', async () => { + it("should return 400 for empty steps on update", async () => { const recipe = await createTestRecipe(testUser.id); const res = await request(app) .patch(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!) - .send({ content: ' ' }); + .set("Cookie", sessionCookie!) + .send({ steps: [] }); expect(res.status).toBe(400); - expect(res.body.error).toContain('RECIPE_004'); + expect(res.body.error).toContain("RECIPE_007"); }); - it('should return 404 for non-existent recipe', async () => { + it("should return 400 for invalid time on update", async () => { + const recipe = await createTestRecipe(testUser.id); + const res = await request(app) - .patch('/api/recipes/00000000-0000-0000-0000-000000000000') - .set('Cookie', sessionCookie!) - .send({ title: 'New title' }); + .patch(`/api/recipes/${recipe.id}`) + .set("Cookie", sessionCookie!) + .send({ prepTime: -1 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("RECIPE_008"); + }); + + it("should return 404 for non-existent recipe", async () => { + const res = await request(app) + .patch("/api/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", sessionCookie!) + .send({ title: "New title" }); expect(res.status).toBe(404); }); - it('should return 403 for another users recipe', async () => { - const otherUser = await createTestUser({ username: 'otheruser2' }); + it("should return 403 for another users recipe", async () => { + const otherUser = await createTestUser({ username: "otheruser2" }); const otherRecipe = await createTestRecipe(otherUser.id); const res = await request(app) .patch(`/api/recipes/${otherRecipe.id}`) - .set('Cookie', sessionCookie!) - .send({ title: 'Hacked' }); + .set("Cookie", sessionCookie!) + .send({ title: "Hacked" }); expect(res.status).toBe(403); }); @@ -374,60 +647,52 @@ describe('Recipes API', () => { // ===================================== // DELETE /api/recipes/:id // ===================================== - describe('DELETE /api/recipes/:id', () => { - it('should soft delete recipe', async () => { - const recipe = await createTestRecipe(testUser.id, { title: 'A supprimer' }); + describe("DELETE /api/recipes/:id", () => { + it("should soft delete recipe", async () => { + const recipe = await createTestRecipe(testUser.id, { title: "A supprimer" }); const res = await request(app) .delete(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!); + .set("Cookie", sessionCookie!); expect(res.status).toBe(204); }); - it('should not appear in list after delete', async () => { - const recipe = await createTestRecipe(testUser.id, { title: 'A supprimer' }); + it("should not appear in list after delete", async () => { + const recipe = await createTestRecipe(testUser.id, { title: "A supprimer" }); - await request(app) - .delete(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!); + await request(app).delete(`/api/recipes/${recipe.id}`).set("Cookie", sessionCookie!); - const listRes = await request(app) - .get('/api/recipes') - .set('Cookie', sessionCookie!); + const listRes = await request(app).get("/api/recipes").set("Cookie", sessionCookie!); expect(listRes.body.data.find((r: { id: string }) => r.id === recipe.id)).toBeUndefined(); }); - it('should return 404 when getting deleted recipe', async () => { + it("should return 404 when getting deleted recipe", async () => { const recipe = await createTestRecipe(testUser.id); - await request(app) - .delete(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!); + await request(app).delete(`/api/recipes/${recipe.id}`).set("Cookie", sessionCookie!); - const res = await request(app) - .get(`/api/recipes/${recipe.id}`) - .set('Cookie', sessionCookie!); + const res = await request(app).get(`/api/recipes/${recipe.id}`).set("Cookie", sessionCookie!); expect(res.status).toBe(404); }); - it('should return 403 for another users recipe', async () => { - const otherUser = await createTestUser({ username: 'otheruser3' }); + it("should return 403 for another users recipe", async () => { + const otherUser = await createTestUser({ username: "otheruser3" }); const otherRecipe = await createTestRecipe(otherUser.id); const res = await request(app) .delete(`/api/recipes/${otherRecipe.id}`) - .set('Cookie', sessionCookie!); + .set("Cookie", sessionCookie!); expect(res.status).toBe(403); }); - it('should return 404 for non-existent recipe', async () => { + it("should return 404 for non-existent recipe", async () => { const res = await request(app) - .delete('/api/recipes/00000000-0000-0000-0000-000000000000') - .set('Cookie', sessionCookie!); + .delete("/api/recipes/00000000-0000-4000-8000-000000000000") + .set("Cookie", sessionCookie!); expect(res.status).toBe(404); }); diff --git a/backend/src/__tests__/integration/share.test.ts b/backend/src/__tests__/integration/share.test.ts index a4860ffd..f968d0cf 100644 --- a/backend/src/__tests__/integration/share.test.ts +++ b/backend/src/__tests__/integration/share.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { extractSessionCookie } from "../setup/testHelpers"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Share Recipe API", () => { let _user1: { id: string }; let user1Cookie: string; @@ -20,11 +17,13 @@ describe("Share Recipe API", () => { const suffix = uniqueSuffix(); // Create user1 (moderator in both communities) - const user1Signup = await request(app).post("/api/auth/signup").send({ - username: `shareuser1_${suffix}`, - email: `shareuser1_${suffix}@example.com`, - password: "Test123!Password", - }); + const user1Signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `shareuser1_${suffix}`, + email: `shareuser1_${suffix}@example.com`, + password: "Test123!Password", + }); user1Cookie = extractSessionCookie(user1Signup)!; _user1 = (await testPrisma.user.findFirst({ where: { email: `shareuser1_${suffix}@example.com` }, @@ -45,11 +44,13 @@ describe("Share Recipe API", () => { targetCommunity = targetRes.body; // Create user2 (member) - const user2Signup = await request(app).post("/api/auth/signup").send({ - username: `shareuser2_${suffix}`, - email: `shareuser2_${suffix}@example.com`, - password: "Test123!Password", - }); + const user2Signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `shareuser2_${suffix}`, + email: `shareuser2_${suffix}@example.com`, + password: "Test123!Password", + }); user2Cookie = extractSessionCookie(user2Signup)!; user2 = (await testPrisma.user.findFirst({ where: { email: `shareuser2_${suffix}@example.com` }, @@ -70,9 +71,12 @@ describe("Share Recipe API", () => { .set("Cookie", user1Cookie) .send({ title: "Recipe to Share", - content: "This recipe will be shared", + servings: 4, + prepTime: 10, + cookTime: 20, + steps: [{ instruction: "This recipe will be shared" }], tags: ["sharing", "test"], - ingredients: [{ name: "ingredient1", quantity: "100g" }], + ingredients: [{ name: "ingredient1", quantity: 100 }], }); communityRecipeId = recipeRes.body.community.id; }); @@ -95,7 +99,7 @@ describe("Share Recipe API", () => { expect(res.body.isVariant).toBe(false); }); - it("should copy tags and ingredients to the fork", async () => { + it("should copy tags, ingredients, steps, servings and times to the fork", async () => { const res = await request(app) .post(`/api/recipes/${communityRecipeId}/share`) .set("Cookie", user1Cookie) @@ -107,6 +111,11 @@ describe("Share Recipe API", () => { expect(res.body.tags.map((t: { name: string }) => t.name)).toContain("test"); expect(res.body.ingredients).toHaveLength(1); expect(res.body.ingredients[0].name).toBe("ingredient1"); + expect(res.body.servings).toBe(4); + expect(res.body.prepTime).toBe(10); + expect(res.body.cookTime).toBe(20); + expect(res.body.steps).toHaveLength(1); + expect(res.body.steps[0].instruction).toBe("This recipe will be shared"); }); it("should create activity logs in both communities", async () => { @@ -155,7 +164,8 @@ describe("Share Recipe API", () => { .set("Cookie", user2Cookie) .send({ title: "User2 Recipe", - content: "Created by user2", + servings: 4, + steps: [{ instruction: "Created by user2" }], }); const user2RecipeId = recipeRes.body.community.id; @@ -184,7 +194,8 @@ describe("Share Recipe API", () => { .set("Cookie", user1Cookie) .send({ title: "Personal Recipe", - content: "This is personal", + servings: 4, + steps: [{ instruction: "This is personal" }], }); const personalRecipeId = personalRes.body.id; @@ -210,11 +221,13 @@ describe("Share Recipe API", () => { it("should reject if not member of source community", async () => { // Create user3 who is only member of target const suffix = uniqueSuffix(); - const user3Signup = await request(app).post("/api/auth/signup").send({ - username: `shareuser3_${suffix}`, - email: `shareuser3_${suffix}@example.com`, - password: "Test123!Password", - }); + const user3Signup = await request(app) + .post("/api/auth/signup") + .send({ + username: `shareuser3_${suffix}`, + email: `shareuser3_${suffix}@example.com`, + password: "Test123!Password", + }); const user3Cookie = extractSessionCookie(user3Signup)!; const user3 = (await testPrisma.user.findFirst({ where: { email: `shareuser3_${suffix}@example.com` }, @@ -273,7 +286,7 @@ describe("Share Recipe API", () => { const res = await request(app) .post(`/api/recipes/${communityRecipeId}/share`) .set("Cookie", user1Cookie) - .send({ targetCommunityId: "00000000-0000-0000-0000-000000000000" }); + .send({ targetCommunityId: "00000000-0000-4000-8000-000000000000" }); expect(res.status).toBe(404); expect(res.body.error).toContain("COMMUNITY_002"); @@ -281,7 +294,7 @@ describe("Share Recipe API", () => { it("should reject if recipe does not exist", async () => { const res = await request(app) - .post("/api/recipes/00000000-0000-0000-0000-000000000000/share") + .post("/api/recipes/00000000-0000-4000-8000-000000000000/share") .set("Cookie", user1Cookie) .send({ targetCommunityId: targetCommunity.id }); @@ -296,7 +309,7 @@ describe("Share Recipe API", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.error).toContain("SHARE_001"); + expect(res.body.error).toContain("VALIDATION_001"); }); it("should reject unauthenticated requests", async () => { @@ -325,6 +338,119 @@ 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) // ===================================== @@ -375,46 +501,59 @@ describe("Share Recipe API", () => { let personalRecipeId: string; beforeEach(async () => { - // The beforeEach already creates a community recipe via createCommunityRecipe, - // which also creates a personal copy. Find the personal recipe. + // Find the personal recipe from the community recipe const communityRecipe = await testPrisma.recipe.findUnique({ where: { id: communityRecipeId }, }); personalRecipeId = communityRecipe!.originRecipeId!; }); - it("should sync title/content from personal recipe to community copies", async () => { + it("should sync title/servings/steps from personal recipe to community copies", async () => { // Update the personal recipe const res = await request(app) .patch(`/api/recipes/${personalRecipeId}`) .set("Cookie", user1Cookie) - .send({ title: "Updated Title", content: "Updated Content" }); + .send({ + title: "Updated Title", + servings: 8, + steps: [{ instruction: "Updated step" }], + }); expect(res.status).toBe(200); // Check community copy is synced const communityRecipe = await testPrisma.recipe.findUnique({ where: { id: communityRecipeId }, + include: { steps: { orderBy: { order: "asc" } } }, }); expect(communityRecipe?.title).toBe("Updated Title"); - expect(communityRecipe?.content).toBe("Updated Content"); + expect(communityRecipe?.servings).toBe(8); + expect(communityRecipe?.steps).toHaveLength(1); + expect(communityRecipe?.steps[0].instruction).toBe("Updated step"); }); - it("should sync title/content from community recipe to personal + other copies", async () => { + it("should sync title/servings/steps from community recipe to personal + other copies", async () => { // Update the community recipe const res = await request(app) .patch(`/api/recipes/${communityRecipeId}`) .set("Cookie", user1Cookie) - .send({ title: "Community Updated", content: "Community Content Updated" }); + .send({ + title: "Community Updated", + servings: 12, + steps: [{ instruction: "Community updated step" }], + }); expect(res.status).toBe(200); // Check personal recipe is synced const personalRecipe = await testPrisma.recipe.findUnique({ where: { id: personalRecipeId }, + include: { steps: { orderBy: { order: "asc" } } }, }); expect(personalRecipe?.title).toBe("Community Updated"); - expect(personalRecipe?.content).toBe("Community Content Updated"); + expect(personalRecipe?.servings).toBe(12); + expect(personalRecipe?.steps).toHaveLength(1); + expect(personalRecipe?.steps[0].instruction).toBe("Community updated step"); }); it("should sync ingredients from personal recipe to community copies", async () => { @@ -424,8 +563,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 }, ], }); @@ -439,7 +578,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"); }); @@ -495,9 +634,11 @@ describe("Share Recipe API", () => { .set("Cookie", user1Cookie) .send({ title: "Personal to Publish", - content: "Content to publish", + servings: 4, + prepTime: 5, + steps: [{ instruction: "Step to publish" }], tags: ["publish"], - ingredients: [{ name: "flour", quantity: "200g" }], + ingredients: [{ name: "flour", quantity: 200 }], }); personalRecipeId = res.body.id; }); @@ -565,7 +706,7 @@ describe("Share Recipe API", () => { expect(res.body.error).toContain("PUBLISH_001"); }); - it("should copy tags and ingredients to published copies", async () => { + it("should copy tags, ingredients, steps and servings to published copies", async () => { await request(app) .post(`/api/recipes/${personalRecipeId}/publish`) .set("Cookie", user1Cookie) @@ -576,6 +717,7 @@ describe("Share Recipe API", () => { include: { tags: { include: { tag: true } }, ingredients: { include: { ingredient: true } }, + steps: { orderBy: { order: "asc" } }, }, }); @@ -583,6 +725,10 @@ describe("Share Recipe API", () => { expect(copy?.tags[0].tag.name).toBe("publish"); expect(copy?.ingredients).toHaveLength(1); expect(copy?.ingredients[0].ingredient.name).toBe("flour"); + expect(copy?.servings).toBe(4); + expect(copy?.prepTime).toBe(5); + expect(copy?.steps).toHaveLength(1); + expect(copy?.steps[0].instruction).toBe("Step to publish"); }); }); @@ -595,7 +741,7 @@ describe("Share Recipe API", () => { const recipeRes = await request(app) .post("/api/recipes") .set("Cookie", user1Cookie) - .send({ title: "Test Communities", content: "Content" }); + .send({ title: "Test Communities", servings: 4, steps: [{ instruction: "Step" }] }); const personalRecipeId = recipeRes.body.id; // Publish to source community diff --git a/backend/src/__tests__/integration/tagPreferences.test.ts b/backend/src/__tests__/integration/tagPreferences.test.ts new file mode 100644 index 00000000..cff47eb7 --- /dev/null +++ b/backend/src/__tests__/integration/tagPreferences.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +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/notifications/preferences + // ===================================== + describe("GET /api/notifications/preferences", () => { + it("should return preferences for all categories with defaults true", async () => { + const res = await request(app) + .get("/api/notifications/preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.global).toBeDefined(); + // Toutes les categories doivent avoir un defaut true + expect(res.body.global.INVITATION).toBe(true); + expect(res.body.global.RECIPE_PROPOSAL).toBe(true); + expect(res.body.global.TAG).toBe(true); + expect(res.body.global.INGREDIENT).toBe(true); + expect(res.body.global.MODERATION).toBe(true); + }); + + it("should return communities with inherited preferences", async () => { + const res = await request(app) + .get("/api/notifications/preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.communities).toBeInstanceOf(Array); + expect(res.body.communities.length).toBe(2); + // Chaque communaute herite des prefs globales + for (const comm of res.body.communities) { + expect(comm.communityId).toBeDefined(); + expect(comm.communityName).toBeDefined(); + expect(comm.preferences.INVITATION).toBe(true); + expect(comm.preferences.TAG).toBe(true); + } + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).get("/api/notifications/preferences"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // PUT /api/notifications/preferences + // ===================================== + describe("PUT /api/notifications/preferences", () => { + it("should update global preference", async () => { + const res = await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: false }); + + expect(res.status).toBe(200); + expect(res.body.category).toBe("TAG"); + expect(res.body.enabled).toBe(false); + expect(res.body.communityId).toBeNull(); + }); + + it("should update community-level preference", async () => { + const res = await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "RECIPE_PROPOSAL", enabled: false, communityId: community.id }); + + expect(res.status).toBe(200); + expect(res.body.category).toBe("RECIPE_PROPOSAL"); + expect(res.body.enabled).toBe(false); + expect(res.body.communityId).toBe(community.id); + }); + + it("should toggle back to true", async () => { + await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: false }); + + const res = await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: true }); + + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + }); + + it("should return 400 for invalid category", async () => { + const res = await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "INVALID", enabled: false }); + + expect(res.status).toBe(400); + }); + + it("should return 400 if enabled is not a boolean", async () => { + const res = await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: "yes" }); + + expect(res.status).toBe(400); + }); + + it("should return 403 for non-member community", 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/notifications/preferences") + .set("Cookie", outsiderCookie) + .send({ category: "TAG", enabled: false, communityId: community.id }); + + expect(res.status).toBe(403); + }); + + it("community preference should override global in GET response", async () => { + // Set global TAG to true + await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: true }); + + // Set community TAG to false + await request(app) + .put("/api/notifications/preferences") + .set("Cookie", memberCookie) + .send({ category: "TAG", enabled: false, communityId: community.id }); + + const res = await request(app) + .get("/api/notifications/preferences") + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.global.TAG).toBe(true); + + const comm = res.body.communities.find( + (c: { communityId: string }) => c.communityId === community.id + ); + expect(comm.preferences.TAG).toBe(false); + + // community2 should inherit global (true) + const comm2 = res.body.communities.find( + (c: { communityId: string }) => c.communityId === community2.id + ); + expect(comm2.preferences.TAG).toBe(true); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app) + .put("/api/notifications/preferences") + .send({ category: "TAG", enabled: false }); + + expect(res.status).toBe(401); + }); + }); +}); + +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.notificationPreference.create({ + data: { userId: moderator1.id, communityId: null, category: "TAG", enabled: 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.notificationPreference.create({ + data: { userId: moderator1.id, communityId: null, category: "TAG", enabled: false }, + }); + await testPrisma.notificationPreference.create({ + data: { userId: moderator1.id, communityId: community.id, category: "TAG", enabled: 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.notificationPreference.create({ + data: { userId: moderator1.id, communityId: null, category: "TAG", enabled: true }, + }); + await testPrisma.notificationPreference.create({ + data: { userId: moderator1.id, communityId: community.id, category: "TAG", enabled: 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/__tests__/integration/tagSuggestions.test.ts b/backend/src/__tests__/integration/tagSuggestions.test.ts new file mode 100644 index 00000000..9c09dddc --- /dev/null +++ b/backend/src/__tests__/integration/tagSuggestions.test.ts @@ -0,0 +1,688 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; + +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", servings: 4, steps: [{ instruction: "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", servings: 4, steps: [{ instruction: "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-4000-8000-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-4000-8000-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-4000-8000-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-4000-8000-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__/integration/tags.test.ts b/backend/src/__tests__/integration/tags.test.ts index 7e702bd8..c4ac1bd1 100644 --- a/backend/src/__tests__/integration/tags.test.ts +++ b/backend/src/__tests__/integration/tags.test.ts @@ -1,13 +1,15 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import request from 'supertest'; -import app from '../../app'; +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; import { createTestUser, createTestRecipe, + createTestTag, extractSessionCookie, -} from '../setup/testHelpers'; +} from "../setup/testHelpers"; +import { testPrisma } from "../setup/globalSetup"; -describe('Tags API', () => { +describe("Tags API", () => { let userCookie: string; let userId: string; @@ -16,12 +18,10 @@ describe('Tags API', () => { userId = user.id; // Login - const loginRes = await request(app) - .post('/api/auth/login') - .send({ - username: user.username, - password: user.password, - }); + const loginRes = await request(app).post("/api/auth/login").send({ + username: user.username, + password: user.password, + }); userCookie = extractSessionCookie(loginRes)!; }); @@ -29,77 +29,172 @@ describe('Tags API', () => { // ===================================== // GET /api/tags // ===================================== - describe('GET /api/tags', () => { - it('should return tags with recipe counts for authenticated user', async () => { + describe("GET /api/tags", () => { + it("should return tags with recipe counts for authenticated user", async () => { // Creer des tags et des recettes - await createTestRecipe(userId, { tags: ['breakfast', 'healthy'] }); - await createTestRecipe(userId, { tags: ['breakfast', 'quick'] }); + await createTestRecipe(userId, { tags: ["breakfast", "healthy"] }); + await createTestRecipe(userId, { tags: ["breakfast", "quick"] }); - const res = await request(app) - .get('/api/tags') - .set('Cookie', userCookie); + const res = await request(app).get("/api/tags").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data).toBeDefined(); expect(Array.isArray(res.body.data)).toBe(true); // Verifier la structure - const breakfastTag = res.body.data.find((t: { name: string }) => t.name === 'breakfast'); + const breakfastTag = res.body.data.find((t: { name: string }) => t.name === "breakfast"); expect(breakfastTag).toBeDefined(); expect(breakfastTag.id).toBeDefined(); expect(breakfastTag.recipeCount).toBe(2); // 2 recettes avec ce tag }); - it('should filter tags by search query', async () => { - await createTestRecipe(userId, { tags: ['chocolate', 'dessert'] }); - await createTestRecipe(userId, { tags: ['cheese', 'savory'] }); + it("should filter tags by search query", async () => { + await createTestRecipe(userId, { tags: ["chocolate", "dessert"] }); + await createTestRecipe(userId, { tags: ["cheese", "savory"] }); - const res = await request(app) - .get('/api/tags?search=ch') - .set('Cookie', userCookie); + const res = await request(app).get("/api/tags?search=ch").set("Cookie", userCookie); expect(res.status).toBe(200); - expect(res.body.data.every((t: { name: string }) => - t.name.toLowerCase().includes('ch') - )).toBe(true); + expect( + res.body.data.every((t: { name: string }) => t.name.toLowerCase().includes("ch")) + ).toBe(true); }); - it('should respect limit parameter', async () => { + it("should respect limit parameter", async () => { // Creer plusieurs tags for (let i = 0; i < 10; i++) { await createTestRecipe(userId, { tags: [`tag_${i}`] }); } - const res = await request(app) - .get('/api/tags?limit=5') - .set('Cookie', userCookie); + const res = await request(app).get("/api/tags?limit=5").set("Cookie", userCookie); expect(res.status).toBe(200); expect(res.body.data.length).toBeLessThanOrEqual(5); }); - it('should only count recipes owned by the authenticated user', async () => { + it("should only count recipes owned by the authenticated user", async () => { const otherUser = await createTestUser(); // Creer des recettes avec le meme tag par differents users - await createTestRecipe(userId, { tags: ['shared_tag'] }); - await createTestRecipe(otherUser.id, { tags: ['shared_tag'] }); + await createTestRecipe(userId, { tags: ["shared_tag"] }); + await createTestRecipe(otherUser.id, { tags: ["shared_tag"] }); - const res = await request(app) - .get('/api/tags') - .set('Cookie', userCookie); + const res = await request(app).get("/api/tags").set("Cookie", userCookie); expect(res.status).toBe(200); - const sharedTag = res.body.data.find((t: { name: string }) => t.name === 'shared_tag'); + const sharedTag = res.body.data.find((t: { name: string }) => t.name === "shared_tag"); expect(sharedTag).toBeDefined(); expect(sharedTag.recipeCount).toBe(1); // Seulement la recette de l'user connecte }); - it('should return 401 without authentication', async () => { - const res = await request(app) - .get('/api/tags'); + it("should return 401 without authentication", async () => { + const res = await request(app).get("/api/tags"); 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__/integration/users.test.ts b/backend/src/__tests__/integration/users.test.ts new file mode 100644 index 00000000..ea583c40 --- /dev/null +++ b/backend/src/__tests__/integration/users.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { extractSessionCookie } from "../setup/testHelpers"; + +describe("Users API", () => { + // ===================================== + // PATCH /api/users/me + // ===================================== + describe("PATCH /api/users/me", () => { + let cookie: string; + + beforeEach(async () => { + const res = await request(app).post("/api/auth/signup").send({ + username: "profileuser", + email: "profileuser@example.com", + password: "Test123!Password", + }); + cookie = extractSessionCookie(res)!; + }); + + it("should return 400 when username is not a string", async () => { + const res = await request(app) + .patch("/api/users/me") + .set("Cookie", cookie) + .send({ username: 123 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when email is not a string", async () => { + const res = await request(app) + .patch("/api/users/me") + .set("Cookie", cookie) + .send({ email: ["array"] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 400 when newPassword is not a string", async () => { + const res = await request(app) + .patch("/api/users/me") + .set("Cookie", cookie) + .send({ currentPassword: "Test123!Password", newPassword: 42 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("VALIDATION_001"); + }); + + it("should return 401 when not authenticated", async () => { + const res = await request(app).patch("/api/users/me").send({ username: "newname" }); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/integration/variants.test.ts b/backend/src/__tests__/integration/variants.test.ts index 0262b31f..f7189ece 100644 --- a/backend/src/__tests__/integration/variants.test.ts +++ b/backend/src/__tests__/integration/variants.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; import app from "../../app"; -import { extractSessionCookie } from "../setup/testHelpers"; +import { uniqueSuffix, extractSessionCookie } from "../setup/testHelpers"; import { testPrisma } from "../setup/globalSetup"; -const uniqueSuffix = () => - `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - describe("Variants API", () => { let recipeCreator: { id: string; username: string; email: string }; let recipeCreatorCookie: string; @@ -21,11 +18,13 @@ describe("Variants API", () => { const suffix = uniqueSuffix(); // Create recipe creator (moderator) via signup - const creatorSignup = await request(app).post("/api/auth/signup").send({ - username: `varcreator_${suffix}`, - email: `varcreator_${suffix}@example.com`, - password: "Test123!Password", - }); + const creatorSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `varcreator_${suffix}`, + email: `varcreator_${suffix}@example.com`, + password: "Test123!Password", + }); recipeCreatorCookie = extractSessionCookie(creatorSignup)!; recipeCreator = (await testPrisma.user.findFirst({ where: { email: `varcreator_${suffix}@example.com` }, @@ -39,11 +38,13 @@ describe("Variants API", () => { community = createRes.body; // Create member via signup - const memberSignup = await request(app).post("/api/auth/signup").send({ - username: `varmem_${suffix}`, - email: `varmem_${suffix}@example.com`, - password: "Test123!Password", - }); + const memberSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `varmem_${suffix}`, + email: `varmem_${suffix}@example.com`, + password: "Test123!Password", + }); memberCookie = extractSessionCookie(memberSignup)!; member = (await testPrisma.user.findFirst({ where: { email: `varmem_${suffix}@example.com` }, @@ -59,11 +60,13 @@ describe("Variants API", () => { }); // Create non-member via signup - const nonMemberSignup = await request(app).post("/api/auth/signup").send({ - username: `varnonm_${suffix}`, - email: `varnonm_${suffix}@example.com`, - password: "Test123!Password", - }); + const nonMemberSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `varnonm_${suffix}`, + email: `varnonm_${suffix}@example.com`, + password: "Test123!Password", + }); nonMemberCookie = extractSessionCookie(nonMemberSignup)!; _nonMember = (await testPrisma.user.findFirst({ where: { email: `varnonm_${suffix}@example.com` }, @@ -75,7 +78,8 @@ describe("Variants API", () => { .set("Cookie", recipeCreatorCookie) .send({ title: "Original Recipe", - content: "Original content for the recipe", + servings: 4, + steps: [{ instruction: "Original step for the recipe" }], }); communityRecipeId = recipeRes.body.community.id; }); @@ -101,7 +105,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "Variant Title", - proposedContent: "Variant content", + proposedSteps: [{ instruction: "Variant step" }], }); // Reject the proposal (creates a variant) @@ -129,7 +133,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "Variant with Creator", - proposedContent: "Content", + proposedSteps: [{ instruction: "Step" }], }); await request(app) @@ -146,6 +150,33 @@ describe("Variants API", () => { expect(res.body.data[0].creator.username).toBeDefined(); }); + it("should include servings and times in variant response", async () => { + // Create and reject a proposal with servings/times + const proposalRes = await request(app) + .post(`/api/recipes/${communityRecipeId}/proposals`) + .set("Cookie", memberCookie) + .send({ + proposedTitle: "Variant with times", + proposedServings: 8, + proposedPrepTime: 15, + proposedSteps: [{ instruction: "Step" }], + }); + + await request(app) + .post(`/api/proposals/${proposalRes.body.id}/reject`) + .set("Cookie", recipeCreatorCookie); + + const res = await request(app) + .get(`/api/recipes/${communityRecipeId}/variants`) + .set("Cookie", recipeCreatorCookie); + + expect(res.status).toBe(200); + expect(res.body.data[0]).toHaveProperty("servings"); + expect(res.body.data[0]).toHaveProperty("prepTime"); + expect(res.body.data[0]).toHaveProperty("cookTime"); + expect(res.body.data[0]).toHaveProperty("restTime"); + }); + it("should allow any community member to view variants", async () => { // Create and reject a proposal const proposalRes = await request(app) @@ -153,7 +184,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "Member Visible Variant", - proposedContent: "Content", + proposedSteps: [{ instruction: "Step" }], }); await request(app) @@ -180,7 +211,7 @@ describe("Variants API", () => { it("should return 404 for non-existent recipe", async () => { const res = await request(app) - .get(`/api/recipes/00000000-0000-0000-0000-000000000000/variants`) + .get(`/api/recipes/00000000-0000-4000-8000-000000000000/variants`) .set("Cookie", recipeCreatorCookie); expect(res.status).toBe(404); @@ -188,8 +219,7 @@ describe("Variants API", () => { }); it("should return 401 when not authenticated", async () => { - const res = await request(app) - .get(`/api/recipes/${communityRecipeId}/variants`); + const res = await request(app).get(`/api/recipes/${communityRecipeId}/variants`); expect(res.status).toBe(401); }); @@ -201,7 +231,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "First Variant", - proposedContent: "Content 1", + proposedSteps: [{ instruction: "Step 1" }], }); await request(app) @@ -214,7 +244,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "Second Variant", - proposedContent: "Content 2", + proposedSteps: [{ instruction: "Step 2" }], }); await request(app) @@ -240,7 +270,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: `Variant ${i}`, - proposedContent: `Content ${i}`, + proposedSteps: [{ instruction: `Step ${i}` }], }); await request(app) @@ -284,7 +314,7 @@ describe("Variants API", () => { .set("Cookie", memberCookie) .send({ proposedTitle: "Community 1 Variant", - proposedContent: "Content", + proposedSteps: [{ instruction: "Step" }], }); await request(app) @@ -295,11 +325,14 @@ describe("Variants API", () => { await testPrisma.recipe.create({ data: { title: "Community 2 Variant", - content: "Different community content", + servings: 4, creatorId: recipeCreator.id, communityId: community2.id, originRecipeId: communityRecipeId, isVariant: true, + steps: { + create: [{ order: 0, instruction: "Different community step" }], + }, }, }); diff --git a/backend/src/__tests__/integration/websocket.test.ts b/backend/src/__tests__/integration/websocket.test.ts index e00b104d..8251e159 100644 --- a/backend/src/__tests__/integration/websocket.test.ts +++ b/backend/src/__tests__/integration/websocket.test.ts @@ -12,9 +12,7 @@ let httpServer: http.Server; let port: number; async function getSessionCookie(username: string, password: string): Promise { - const res = await request(app) - .post("/api/auth/login") - .send({ username, password }); + const res = await request(app).post("/api/auth/login").send({ username, password }); const cookie = extractSessionCookie(res); if (!cookie) throw new Error("Failed to get session cookie"); return cookie; @@ -84,6 +82,25 @@ describe("WebSocket", () => { expect(client.connected).toBe(true); }); + it("should emit notification:count on connection", async () => { + const user = await createTestUser({ username: "ws-user-count", email: "wscount@test.com" }); + const cookie = await getSessionCookie(user.username, user.password); + + const client = createClient(cookie); + clients.push(client); + + const countPromise = new Promise>((resolve) => { + client.on("notification:count", (data: Record) => resolve(data)); + }); + + client.connect(); + + const received = await countPromise; + expect(received).toHaveProperty("count"); + expect(received).toHaveProperty("byCategory"); + expect(received.count).toBe(0); + }); + it("should receive activity events in community room", async () => { const user = await createTestUser({ username: "ws-user-2", email: "ws2@test.com" }); const community = await testPrisma.community.create({ @@ -122,32 +139,231 @@ describe("WebSocket", () => { expect(received.communityId).toBe(community.id); }); - it("should receive personal notification events", async () => { - const user = await createTestUser({ username: "ws-user-3", email: "ws3@test.com" }); - const cookie = await getSessionCookie(user.username, user.password); - const client = createClient(cookie); - clients.push(client); + it("should persist broadcast notification and emit notification:new", async () => { + // Creer 2 users dans une communaute + const actor = await createTestUser({ username: "ws-actor-bc", email: "wsactorbc@test.com" }); + const member = await createTestUser({ username: "ws-member-bc", email: "wsmemberbc@test.com" }); + const community = await testPrisma.community.create({ + data: { name: "WS Broadcast Community" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: actor.id, communityId: community.id, role: "MODERATOR" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId: community.id, role: "MEMBER" }, + }); + + // Creer une recette pour le template + const recipe = await testPrisma.recipe.create({ + data: { + title: "WS Test Recipe", + servings: 4, + creator: { connect: { id: actor.id } }, + steps: { create: [{ order: 0, instruction: "content" }] }, + }, + }); + + const memberCookie = await getSessionCookie(member.username, member.password); + const memberClient = createClient(memberCookie); + clients.push(memberClient); await new Promise((resolve, reject) => { - client.on("connect", () => resolve()); - client.on("connect_error", (err) => reject(err)); - client.connect(); + memberClient.on("connect", () => resolve()); + memberClient.on("connect_error", (err) => reject(err)); + memberClient.connect(); + }); + + await new Promise((r) => setTimeout(r, 200)); + + const notifPromise = new Promise>((resolve) => { + memberClient.on("notification:new", (data: Record) => resolve(data)); + }); + + const countPromises: Record[] = []; + memberClient.on("notification:count", (data: Record) => { + countPromises.push(data); + }); + + // Emettre un evenement broadcast + appEvents.emitActivity({ + type: "RECIPE_CREATED", + userId: actor.id, + communityId: community.id, + recipeId: recipe.id, + }); + + const received = await notifPromise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const notification = (received as any).notification; + expect(notification).toBeDefined(); + expect(notification.type).toBe("RECIPE_CREATED"); + expect(notification.userId).toBe(member.id); + expect(notification.communityId).toBe(community.id); + expect(notification.title).toBe("Nouvelle recette"); + + // Verifier que la notification est persistee en DB + const dbNotif = await testPrisma.notification.findFirst({ + where: { userId: member.id, type: "RECIPE_CREATED", communityId: community.id }, + }); + expect(dbNotif).toBeTruthy(); + expect(dbNotif!.readAt).toBeNull(); + + // Attendre un peu pour notification:count + await new Promise((r) => setTimeout(r, 500)); + expect(countPromises.length).toBeGreaterThanOrEqual(1); + }); + + it("should persist personal notification and emit notification:new", async () => { + const actor = await createTestUser({ username: "ws-actor-pn", email: "wsactorpn@test.com" }); + const target = await createTestUser({ username: "ws-target-pn", email: "wstargetpn@test.com" }); + const community = await testPrisma.community.create({ + data: { name: "WS Personal Notif Community" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: actor.id, communityId: community.id, role: "MODERATOR" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: target.id, communityId: community.id, role: "MEMBER" }, + }); + + const targetCookie = await getSessionCookie(target.username, target.password); + const targetClient = createClient(targetCookie); + clients.push(targetClient); + + await new Promise((resolve, reject) => { + targetClient.on("connect", () => resolve()); + targetClient.on("connect_error", (err) => reject(err)); + targetClient.connect(); }); await new Promise((r) => setTimeout(r, 200)); const notifPromise = new Promise>((resolve) => { - client.on("notification", (data: Record) => resolve(data)); + targetClient.on("notification:new", (data: Record) => resolve(data)); }); + // Emettre une invitation (notification personnelle) appEvents.emitActivity({ type: "INVITE_SENT", - userId: "someone-else", - communityId: "some-community", - targetUserIds: [user.id], + userId: actor.id, + communityId: community.id, + targetUserIds: [target.id], + }); + + const received = await notifPromise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const notification = (received as any).notification; + expect(notification).toBeDefined(); + expect(notification.type).toBe("INVITE_SENT"); + expect(notification.userId).toBe(target.id); + expect(notification.title).toBe("Nouvelle invitation"); + + // Verifier la persistance en DB + const dbNotif = await testPrisma.notification.findFirst({ + where: { userId: target.id, type: "INVITE_SENT" }, + }); + expect(dbNotif).toBeTruthy(); + }); + + it("should not create notification when preference is disabled", async () => { + const actor = await createTestUser({ username: "ws-actor-np", email: "wsactornp@test.com" }); + const target = await createTestUser({ username: "ws-target-np", email: "wstargetnp@test.com" }); + const community = await testPrisma.community.create({ + data: { name: "WS NoPref Community" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: actor.id, communityId: community.id, role: "MODERATOR" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: target.id, communityId: community.id, role: "MEMBER" }, + }); + + // Desactiver les notifications TAG pour le target + await testPrisma.notificationPreference.create({ + data: { userId: target.id, communityId: null, category: "TAG", enabled: false }, + }); + + const targetCookie = await getSessionCookie(target.username, target.password); + const targetClient = createClient(targetCookie); + clients.push(targetClient); + + await new Promise((resolve, reject) => { + targetClient.on("connect", () => resolve()); + targetClient.on("connect_error", (err) => reject(err)); + targetClient.connect(); + }); + + await new Promise((r) => setTimeout(r, 200)); + + let notifReceived = false; + targetClient.on("notification:new", () => { + notifReceived = true; + }); + + // Emettre un evenement TAG + appEvents.emitActivity({ + type: "TAG_SUGGESTION_CREATED", + userId: actor.id, + communityId: community.id, + targetUserIds: [target.id], + metadata: { tagName: "test-tag" }, + }); + + // Attendre un peu pour laisser le temps au handler + await new Promise((r) => setTimeout(r, 1000)); + + expect(notifReceived).toBe(false); + + // Verifier qu'aucune notification n'est en DB + const dbNotif = await testPrisma.notification.findFirst({ + where: { userId: target.id, type: "TAG_SUGGESTION_CREATED" }, + }); + expect(dbNotif).toBeNull(); + }); + + it("should always create notification for non-disableable types (INVITE_SENT)", async () => { + const actor = await createTestUser({ username: "ws-actor-nd", email: "wsactornd@test.com" }); + const target = await createTestUser({ username: "ws-target-nd", email: "wstargetnd@test.com" }); + const community = await testPrisma.community.create({ + data: { name: "WS NonDisable Community" }, + }); + await testPrisma.userCommunity.create({ + data: { userId: actor.id, communityId: community.id, role: "MODERATOR" }, + }); + + // Desactiver les notifications INVITATION pour le target + await testPrisma.notificationPreference.create({ + data: { userId: target.id, communityId: null, category: "INVITATION", enabled: false }, + }); + + const targetCookie = await getSessionCookie(target.username, target.password); + const targetClient = createClient(targetCookie); + clients.push(targetClient); + + await new Promise((resolve, reject) => { + targetClient.on("connect", () => resolve()); + targetClient.on("connect_error", (err) => reject(err)); + targetClient.connect(); + }); + + await new Promise((r) => setTimeout(r, 200)); + + const notifPromise = new Promise>((resolve) => { + targetClient.on("notification:new", (data: Record) => resolve(data)); + }); + + // INVITE_SENT est non-desactivable + appEvents.emitActivity({ + type: "INVITE_SENT", + userId: actor.id, + communityId: community.id, + targetUserIds: [target.id], }); const received = await notifPromise; - expect(received.type).toBe("INVITE_SENT"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const notification = (received as any).notification; + expect(notification).toBeDefined(); + expect(notification.type).toBe("INVITE_SENT"); }); }); diff --git a/backend/src/__tests__/setup/globalSetup.ts b/backend/src/__tests__/setup/globalSetup.ts index 5d46bd15..fa519cc5 100644 --- a/backend/src/__tests__/setup/globalSetup.ts +++ b/backend/src/__tests__/setup/globalSetup.ts @@ -1,5 +1,5 @@ -import { beforeAll, afterAll, afterEach } from 'vitest'; -import { PrismaClient } from '@prisma/client'; +import { beforeAll, afterAll, afterEach } from "vitest"; +import { PrismaClient } from "@prisma/client"; // Instance Prisma pour les tests export const testPrisma = new PrismaClient({ @@ -15,16 +15,22 @@ afterEach(async () => { // Nettoyer les donnees de test apres chaque test // Ordre important pour respecter les contraintes FK await testPrisma.$transaction([ + testPrisma.notification.deleteMany(), + testPrisma.notificationPreference.deleteMany(), + testPrisma.proposalIngredient.deleteMany(), testPrisma.recipeIngredient.deleteMany(), testPrisma.recipeTag.deleteMany(), testPrisma.recipeView.deleteMany(), testPrisma.recipeAnalytics.deleteMany(), + testPrisma.tagSuggestion.deleteMany(), testPrisma.recipeUpdateProposal.deleteMany(), testPrisma.recipe.deleteMany(), testPrisma.tag.deleteMany(), testPrisma.ingredient.deleteMany(), + testPrisma.unit.deleteMany(), testPrisma.activityLog.deleteMany(), testPrisma.communityInvite.deleteMany(), + testPrisma.userCommunityTagPreference.deleteMany(), testPrisma.userCommunity.deleteMany(), testPrisma.communityFeature.deleteMany(), testPrisma.community.deleteMany(), diff --git a/backend/src/__tests__/setup/testHelpers.ts b/backend/src/__tests__/setup/testHelpers.ts index 6550f986..b5cc43ec 100644 --- a/backend/src/__tests__/setup/testHelpers.ts +++ b/backend/src/__tests__/setup/testHelpers.ts @@ -1,7 +1,14 @@ -import { Request, Response } from 'supertest'; -import bcrypt from 'bcrypt'; -import { testPrisma } from './globalSetup'; -import { generateSecret, generateSync } from 'otplib'; +import { Request, Response } from "supertest"; +import bcrypt from "bcrypt"; +import { testPrisma } from "./globalSetup"; +import { generateSecret, generateSync } from "otplib"; + +/** + * Genere un suffix unique et court (max 12 chars) pour les noms de test. + * Permet de garder les usernames sous 30 caracteres. + */ +export const uniqueSuffix = () => + `${Date.now().toString(36)}${Math.random().toString(36).substring(2, 5)}`; // Types pour les donnees de test interface TestUser { @@ -22,8 +29,8 @@ interface TestAdmin { interface TestRecipe { id: string; title: string; - content: string; - imageUrl: string | null; + servings: number; + imageKey: string | null; creatorId: string; } @@ -34,12 +41,14 @@ interface TestRecipe { /** * Creer un utilisateur de test dans la DB */ -export async function createTestUser(data?: Partial<{ - username: string; - email: string; - password: string; -}>): Promise { - const password = data?.password ?? 'Test123!'; +export async function createTestUser( + data?: Partial<{ + username: string; + email: string; + password: string; + }> +): Promise { + const password = data?.password ?? "Test123!"; const hashedPassword = await bcrypt.hash(password, 10); const user = await testPrisma.user.create({ @@ -61,12 +70,14 @@ export async function createTestUser(data?: Partial<{ /** * Creer un admin de test dans la DB avec TOTP configure */ -export async function createTestAdmin(data?: Partial<{ - username: string; - email: string; - password: string; -}>): Promise { - const password = data?.password ?? 'AdminTest123!'; +export async function createTestAdmin( + data?: Partial<{ + username: string; + email: string; + password: string; + }> +): Promise { + const password = data?.password ?? "AdminTest123!"; const hashedPassword = await bcrypt.hash(password, 10); const totpSecret = generateSecret(); @@ -94,12 +105,14 @@ export async function createTestAdmin(data?: Partial<{ * Note: totpSecret est requis par le schema, on met une valeur placeholder * mais totpEnabled=false indique qu'il n'est pas encore configure */ -export async function createTestAdminWithoutTotp(data?: Partial<{ - username: string; - email: string; - password: string; -}>): Promise & { totpSecret: string }> { - const password = data?.password ?? 'AdminTest123!'; +export async function createTestAdminWithoutTotp( + data?: Partial<{ + username: string; + email: string; + password: string; + }> +): Promise & { totpSecret: string }> { + const password = data?.password ?? "AdminTest123!"; const hashedPassword = await bcrypt.hash(password, 10); // Le secret sera regenere lors de la premiere connexion const placeholderSecret = generateSecret(); @@ -130,48 +143,72 @@ export async function createTestRecipe( creatorId: string, data?: Partial<{ title: string; - content: string; - imageUrl: string | null; + servings: number; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + steps: Array<{ instruction: string }>; + imageKey: 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) + 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, + servings: data?.servings ?? 4, + prepTime: data?.prepTime ?? undefined, + cookTime: data?.cookTime ?? undefined, + restTime: data?.restTime ?? undefined, + imageKey: data?.imageKey ?? null, creatorId, - tags: data?.tags ? { - create: data.tags.map(tagName => ({ - tag: { - connectOrCreate: { - where: { name: tagName.toLowerCase().trim() }, - create: { name: tagName.toLowerCase().trim() }, - }, - }, - })), - } : undefined, - ingredients: data?.ingredients ? { - create: data.ingredients.map((ing, index) => ({ + steps: { + create: (data?.steps ?? [{ instruction: "Test step" }]).map((step, index) => ({ order: index, - quantity: ing.quantity ?? null, - ingredient: { - connectOrCreate: { - where: { name: ing.name.toLowerCase().trim() }, - create: { name: ing.name.toLowerCase().trim() }, - }, - }, + instruction: step.instruction, })), - } : undefined, + }, + tags: + tagIds.length > 0 + ? { + create: tagIds.map((tagId) => ({ tagId })), + } + : undefined, + ingredients: data?.ingredients + ? { + create: data.ingredients.map((ing, index) => ({ + order: index, + quantity: ing.quantity ?? null, + ingredient: { + connectOrCreate: { + where: { name: ing.name.toLowerCase().trim() }, + create: { name: ing.name.toLowerCase().trim() }, + }, + }, + })), + } + : undefined, }, }); return { id: recipe.id, title: recipe.title, - content: recipe.content, - imageUrl: recipe.imageUrl, + servings: recipe.servings, + imageKey: recipe.imageKey, creatorId: recipe.creatorId, }; } @@ -179,10 +216,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, }, }); } @@ -190,10 +239,42 @@ export async function createTestTag(name?: string) { /** * 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, }, }); } @@ -205,17 +286,20 @@ export async function createTestIngredient(name?: string) { /** * Extraire le cookie de session de la reponse */ -export function extractSessionCookie(res: Response, cookieName = 'connect.sid'): string | null { - const cookies = res.headers['set-cookie']; +export function extractSessionCookie( + res: Response, + cookieName = "forestmanager_user_session" +): string | null { + const cookies = res.headers["set-cookie"]; if (!cookies) return null; const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; - const sessionCookie = cookieArray.find(c => c.startsWith(`${cookieName}=`)); + const sessionCookie = cookieArray.find((c) => c.startsWith(`${cookieName}=`)); if (!sessionCookie) return null; // Extraire seulement la valeur du cookie - return sessionCookie.split(';')[0]; + return sessionCookie.split(";")[0]; } /** @@ -234,7 +318,7 @@ export function generateTotpCode(secret: string): string { */ export function withCookie(request: Request, cookie: string | null): Request { if (cookie) { - return request.set('Cookie', cookie); + return request.set("Cookie", cookie); } return request; } @@ -271,12 +355,12 @@ export async function createTestCommunity( const community = await testPrisma.community.create({ data: { name: data?.name ?? `Test Community ${Date.now()}`, - description: data?.description ?? 'Test community description', + description: data?.description ?? "Test community description", // visibility uses default INVITE_ONLY from schema members: { create: { userId: creatorId, - role: 'MODERATOR', + role: "MODERATOR", }, }, }, @@ -306,15 +390,15 @@ export async function createTestInvite( communityId: string, inviterId: string, inviteeId: string, - status?: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' + status?: "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" ): Promise { const invite = await testPrisma.communityInvite.create({ data: { communityId, inviterId, inviteeId, - status: status ?? 'PENDING', - respondedAt: status && status !== 'PENDING' ? new Date() : null, + status: status ?? "PENDING", + respondedAt: status && status !== "PENDING" ? new Date() : null, }, }); @@ -331,12 +415,14 @@ export async function createTestInvite( /** * Creer une feature de test */ -export async function createTestFeature(data?: Partial<{ - code: string; - name: string; - description: string; - isDefault: boolean; -}>): Promise { +export async function createTestFeature( + data?: Partial<{ + code: string; + name: string; + description: string; + isDefault: boolean; + }> +): Promise { const randomSuffix = Math.random().toString(36).substring(2, 8); const code = data?.code ?? `FEATURE_${Date.now()}_${randomSuffix}`; @@ -358,36 +444,77 @@ 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 // ===================================== -import supertest from 'supertest'; -import app from '../../app'; +import supertest from "supertest"; +import app from "../../app"; /** * Effectuer un login complet admin (password + TOTP) et retourner le cookie de session */ export async function loginAsAdmin(admin: TestAdmin): Promise { // Step 1: Login avec password - const loginRes = await supertest(app) - .post('/api/admin/auth/login') - .send({ - email: admin.email, - password: admin.password, - }); - - const sessionCookie = extractSessionCookie(loginRes, 'admin.sid'); - if (!sessionCookie) { - throw new Error('Failed to get admin session cookie from login'); + const loginRes = await supertest(app).post("/api/admin/auth/login").send({ + email: admin.email, + password: admin.password, + }); + + // Capturer le cookie apres regenerate() du step 1 + const step1Cookie = extractSessionCookie(loginRes, "forestmanager_admin_session"); + if (!step1Cookie) { + throw new Error("Failed to get admin session cookie from login"); } - // Step 2: Verifier TOTP - const totpCode = generateTotpCode(admin.totpSecret); - await supertest(app) - .post('/api/admin/auth/totp/verify') - .set('Cookie', sessionCookie) - .send({ code: totpCode }); + // Step 2: Verifier TOTP (regenerate() cree un nouveau session ID) + // Retry once si le code TOTP tombe sur une frontiere de fenetre 30s + let totpRes = await supertest(app) + .post("/api/admin/auth/totp/verify") + .set("Cookie", step1Cookie) + .send({ code: generateTotpCode(admin.totpSecret) }); + + if (totpRes.status !== 200) { + // Attendre que la fenetre TOTP change puis retenter + await new Promise((resolve) => setTimeout(resolve, 1000)); + totpRes = await supertest(app) + .post("/api/admin/auth/totp/verify") + .set("Cookie", step1Cookie) + .send({ code: generateTotpCode(admin.totpSecret) }); + } - return sessionCookie; + if (totpRes.status !== 200) { + throw new Error( + `TOTP verification failed with status ${totpRes.status}: ${JSON.stringify(totpRes.body)}` + ); + } + + // Capturer le nouveau cookie apres regenerate() du step 2 + const finalCookie = extractSessionCookie(totpRes, "forestmanager_admin_session"); + if (!finalCookie) { + throw new Error("Failed to get admin session cookie after TOTP verification"); + } + return finalCookie; } diff --git a/backend/src/__tests__/unit/middleware/auth.test.ts b/backend/src/__tests__/unit/middleware/auth.test.ts index a00ec60b..1f1a2c90 100644 --- a/backend/src/__tests__/unit/middleware/auth.test.ts +++ b/backend/src/__tests__/unit/middleware/auth.test.ts @@ -30,9 +30,7 @@ describe("requireAuth", () => { it("should call next with 401 when session has no userId (undefined)", () => { const { req, res, next } = mockReqResNext({ userId: undefined }); requireAuth(req, res, next); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ status: 401 }) - ); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); }); it("should pass when userId is a valid string", () => { diff --git a/backend/src/__tests__/unit/middleware/csrf.test.ts b/backend/src/__tests__/unit/middleware/csrf.test.ts new file mode 100644 index 00000000..0fcbe716 --- /dev/null +++ b/backend/src/__tests__/unit/middleware/csrf.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response, NextFunction } from "express"; + +// En test env, le middleware est un passthrough. +// On teste la logique interne en mockant NODE_ENV. + +function createMockReq(overrides: Partial = {}): Request { + return { + method: "GET", + headers: {}, + ...overrides, + } as unknown as Request; +} + +function createMockRes(): Response & { + _cookies: Record; + _status: number; + _json: unknown; +} { + const res = { + _cookies: {} as Record, + _status: 0, + _json: null as unknown, + cookie: vi.fn(function (this: typeof res, name: string, value: string, options: unknown) { + this._cookies[name] = { value, options }; + }), + status: vi.fn(function (this: typeof res, code: number) { + this._status = code; + return this; + }), + json: vi.fn(function (this: typeof res, data: unknown) { + this._json = data; + }), + } as unknown as Response & { _cookies: Record; _status: number; _json: unknown }; + return res; +} + +describe("CSRF middleware", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("should be a passthrough in test environment", async () => { + const { csrfProtection } = await import("../../../middleware/csrf"); + const req = createMockReq({ method: "POST" }); + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + csrfProtection(req, res as unknown as Response, next); + expect(next).toHaveBeenCalled(); + }); + + it("should exist and be a function", async () => { + const { csrfProtection } = await import("../../../middleware/csrf"); + expect(typeof csrfProtection).toBe("function"); + }); +}); diff --git a/backend/src/__tests__/unit/recipeImportService.test.ts b/backend/src/__tests__/unit/recipeImportService.test.ts new file mode 100644 index 00000000..790f19a9 --- /dev/null +++ b/backend/src/__tests__/unit/recipeImportService.test.ts @@ -0,0 +1,616 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + parseIsoDuration, + parseIngredientLine, + importFromUrl, +} from "../../services/recipeImportService"; + +// --- Unit tests for parseIsoDuration --- + +describe("parseIsoDuration", () => { + it("should parse PT30M to 30", () => { + expect(parseIsoDuration("PT30M")).toBe(30); + }); + + it("should parse PT1H to 60", () => { + expect(parseIsoDuration("PT1H")).toBe(60); + }); + + it("should parse PT1H30M to 90", () => { + expect(parseIsoDuration("PT1H30M")).toBe(90); + }); + + it("should parse PT2H to 120", () => { + expect(parseIsoDuration("PT2H")).toBe(120); + }); + + it("should return null for null input", () => { + expect(parseIsoDuration(null)).toBeNull(); + }); + + it("should return null for undefined input", () => { + expect(parseIsoDuration(undefined)).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(parseIsoDuration("")).toBeNull(); + }); + + it("should return null for invalid format", () => { + expect(parseIsoDuration("30 minutes")).toBeNull(); + }); + + it("should return null for PT0M (zero duration)", () => { + expect(parseIsoDuration("PT0M")).toBeNull(); + }); +}); + +// --- Unit tests for parseIngredientLine --- + +describe("parseIngredientLine", () => { + it("should parse quantity + unit + name: 200g de farine", () => { + const result = parseIngredientLine("200g de farine"); + expect(result).toEqual({ + raw: "200g de farine", + quantity: 200, + unitAbbreviation: "g", + name: "farine", + }); + }); + + it("should parse quantity without unit: 3 oeufs", () => { + const result = parseIngredientLine("3 oeufs"); + expect(result).toEqual({ + raw: "3 oeufs", + quantity: 3, + unitAbbreviation: null, + name: "oeufs", + }); + }); + + it("should parse with bullet prefix: - 100ml de lait", () => { + const result = parseIngredientLine("- 100ml de lait"); + expect(result).toEqual({ + raw: "100ml de lait", + quantity: 100, + unitAbbreviation: "ml", + name: "lait", + }); + }); + + it("should parse 'a gout' pattern: sel a gout", () => { + const result = parseIngredientLine("sel a gout"); + expect(result).toEqual({ + raw: "sel a gout", + quantity: null, + unitAbbreviation: null, + name: "sel", + }); + }); + + it("should parse 'selon besoin' pattern: poivre selon besoin", () => { + const result = parseIngredientLine("poivre selon besoin"); + expect(result).toEqual({ + raw: "poivre selon besoin", + quantity: null, + unitAbbreviation: null, + name: "poivre", + }); + }); + + it("should parse cs unit as cas: 2 cs de sucre", () => { + const result = parseIngredientLine("2 cs de sucre"); + expect(result).toEqual({ + raw: "2 cs de sucre", + quantity: 2, + unitAbbreviation: "cas", + name: "sucre", + }); + }); + + it("should parse cc unit as cac: 1 cc de vanille", () => { + const result = parseIngredientLine("1 cc de vanille"); + expect(result).toEqual({ + raw: "1 cc de vanille", + quantity: 1, + unitAbbreviation: "cac", + name: "vanille", + }); + }); + + it("should parse plural units: 3 gousses d'ail", () => { + const result = parseIngredientLine("3 gousses d'ail"); + expect(result).toEqual({ + raw: "3 gousses d'ail", + quantity: 3, + unitAbbreviation: "gousse", + name: "ail", + }); + }); + + it("should parse kg unit: 1.5kg de pommes de terre", () => { + const result = parseIngredientLine("1.5kg de pommes de terre"); + expect(result).toEqual({ + raw: "1.5kg de pommes de terre", + quantity: 1.5, + unitAbbreviation: "kg", + name: "pommes de terre", + }); + }); + + it("should parse fraction: 1/2 l de bouillon", () => { + const result = parseIngredientLine("1/2 l de bouillon"); + expect(result).toEqual({ + raw: "1/2 l de bouillon", + quantity: 0.5, + unitAbbreviation: "l", + name: "bouillon", + }); + }); + + it("should handle line with no pattern match", () => { + const result = parseIngredientLine("un peu de coriandre fraiche"); + expect(result.raw).toBe("un peu de coriandre fraiche"); + expect(result.name).toBe("un peu de coriandre fraiche"); + expect(result.quantity).toBeNull(); + expect(result.unitAbbreviation).toBeNull(); + }); + + it("should handle decimal with comma: 1,5 cl de creme", () => { + const result = parseIngredientLine("1,5 cl de creme"); + expect(result.quantity).toBe(1.5); + expect(result.unitAbbreviation).toBe("cl"); + expect(result.name).toBe("creme"); + }); + + it("should parse unicode fraction ½: ½ litre de lait", () => { + const result = parseIngredientLine("½ litre de lait"); + expect(result.quantity).toBe(0.5); + expect(result.unitAbbreviation).toBe("l"); + expect(result.name).toBe("lait"); + }); + + it("should parse unicode fraction ¼: ¼ kg de sucre", () => { + const result = parseIngredientLine("¼ kg de sucre"); + expect(result.quantity).toBe(0.25); + expect(result.unitAbbreviation).toBe("kg"); + expect(result.name).toBe("sucre"); + }); + + // --- HelloFresh formats --- + + it("should parse unit with parentheses: 2 pièce(s) Aubergine", () => { + const result = parseIngredientLine("2 pièce(s) Aubergine"); + expect(result.quantity).toBe(2); + expect(result.unitAbbreviation).toBe("piece"); + expect(result.name).toBe("Aubergine"); + }); + + it("should parse sachet(s) unit: 1 sachet(s) Noix de cajou", () => { + const result = parseIngredientLine("1 sachet(s) Noix de cajou"); + expect(result.quantity).toBe(1); + expect(result.unitAbbreviation).toBe("sachet"); + expect(result.name).toBe("Noix de cajou"); + }); + + it("should parse botte(s) with fraction: ½ botte(s) Oignon nouveau", () => { + const result = parseIngredientLine("½ botte(s) Oignon nouveau"); + expect(result.quantity).toBe(0.5); + expect(result.unitAbbreviation).toBe("botte"); + expect(result.name).toBe("Oignon nouveau"); + }); + + it("should parse 'selon le goût' prefix: selon le goût Poivre et sel", () => { + const result = parseIngredientLine("selon le goût Poivre et sel"); + expect(result.quantity).toBeNull(); + expect(result.unitAbbreviation).toBeNull(); + expect(result.name).toBe("Poivre et sel"); + }); + + it("should preserve accents in ingredient name: 2 pièce(s) Échalote", () => { + const result = parseIngredientLine("2 pièce(s) Échalote"); + expect(result.name).toBe("Échalote"); + }); +}); + +// --- Unit tests for importFromUrl --- + +describe("importFromUrl", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + // Reset fetch mock before each test + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + // Helper pour creer une reponse HTML avec JSON-LD + function makeHtml(jsonLd: object): string { + return ` + + + + +

Recipe Page

+ + `; + } + + function mockFetch(html: string, status = 200) { + global.fetch = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: new Map([["content-length", String(html.length)]]), + text: () => Promise.resolve(html), + } as unknown as Response); + } + + // --- URL validation --- + + it("should reject empty URL", async () => { + await expect(importFromUrl("")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject non-http URL", async () => { + await expect(importFromUrl("ftp://example.com/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject URL longer than 2000 chars", async () => { + const longUrl = "https://example.com/" + "a".repeat(2000); + await expect(importFromUrl(longUrl)).rejects.toThrow("IMPORT_001"); + }); + + it("should reject invalid URL", async () => { + await expect(importFromUrl("not a url")).rejects.toThrow("IMPORT_001"); + }); + + // --- SSRF protection --- + + it("should reject localhost", async () => { + await expect(importFromUrl("http://localhost/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject 127.0.0.1", async () => { + await expect(importFromUrl("http://127.0.0.1/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject 10.x.x.x", async () => { + await expect(importFromUrl("http://10.0.0.1/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject 172.16.x.x", async () => { + await expect(importFromUrl("http://172.16.0.1/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject 192.168.x.x", async () => { + await expect(importFromUrl("http://192.168.1.1/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject 0.0.0.0", async () => { + await expect(importFromUrl("http://0.0.0.0/recipe")).rejects.toThrow("IMPORT_001"); + }); + + it("should reject ::1", async () => { + await expect(importFromUrl("http://[::1]/recipe")).rejects.toThrow("IMPORT_001"); + }); + + // --- JSON-LD parsing --- + + it("should parse a direct Recipe JSON-LD object", async () => { + const jsonLd = { + "@context": "https://schema.org", + "@type": "Recipe", + name: "Gateau au chocolat", + recipeYield: "6 servings", + prepTime: "PT20M", + cookTime: "PT35M", + recipeIngredient: ["200g de farine", "3 oeufs", "150g de sucre"], + recipeInstructions: [ + { "@type": "HowToStep", text: "Prechauffer le four a 180C." }, + { "@type": "HowToStep", text: "Melanger les ingredients." }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.title).toBe("Gateau au chocolat"); + expect(result.servings).toBe(6); + expect(result.prepTime).toBe(20); + expect(result.cookTime).toBe(35); + expect(result.restTime).toBeNull(); + expect(result.ingredients).toHaveLength(3); + expect(result.ingredients[0].quantity).toBe(200); + expect(result.ingredients[0].unitAbbreviation).toBe("g"); + expect(result.ingredients[0].name).toBe("farine"); + expect(result.steps).toHaveLength(2); + expect(result.steps[0]).toBe("Prechauffer le four a 180C."); + }); + + it("should parse Recipe in @graph array", async () => { + const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { "@type": "WebPage", name: "Some page" }, + { + "@type": "Recipe", + name: "Tarte aux pommes", + recipeYield: "8", + prepTime: "PT30M", + cookTime: "PT45M", + recipeIngredient: ["500g de pommes"], + recipeInstructions: "Eplucher les pommes.\nCuire au four.", + }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.title).toBe("Tarte aux pommes"); + expect(result.servings).toBe(8); + expect(result.prepTime).toBe(30); + expect(result.cookTime).toBe(45); + expect(result.ingredients).toHaveLength(1); + expect(result.steps).toHaveLength(2); + expect(result.steps[0]).toBe("Eplucher les pommes."); + expect(result.steps[1]).toBe("Cuire au four."); + }); + + it("should parse Recipe with schema: namespace type", async () => { + const jsonLd = { + "@context": "https://schema.org", + "@type": "schema:Recipe", + name: "Soupe", + recipeIngredient: ["1 l d'eau"], + recipeInstructions: [{ "@type": "HowToStep", text: "Faire bouillir." }], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.title).toBe("Soupe"); + }); + + it("should parse Recipe with array @type", async () => { + const jsonLd = { + "@context": "https://schema.org", + "@type": ["Recipe"], + name: "Salade", + recipeIngredient: ["100g de salade"], + recipeInstructions: ["Laver la salade."], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.title).toBe("Salade"); + expect(result.steps).toEqual(["Laver la salade."]); + }); + + // --- recipeInstructions formats --- + + it("should handle recipeInstructions as array of strings", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test", + recipeInstructions: ["Etape 1: faire ceci", "Etape 2: faire cela"], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps).toEqual(["Etape 1: faire ceci", "Etape 2: faire cela"]); + }); + + it("should handle recipeInstructions as HowToSection array", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test", + recipeInstructions: [ + { + "@type": "HowToSection", + name: "Preparation", + itemListElement: [ + { "@type": "HowToStep", text: "Couper les legumes." }, + { "@type": "HowToStep", text: "Faire revenir." }, + ], + }, + { + "@type": "HowToSection", + name: "Cuisson", + itemListElement: [{ "@type": "HowToStep", text: "Enfourner 30 min." }], + }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps).toHaveLength(3); + expect(result.steps[0]).toBe("Couper les legumes."); + expect(result.steps[1]).toBe("Faire revenir."); + expect(result.steps[2]).toBe("Enfourner 30 min."); + }); + + it("should handle recipeInstructions as a single string", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test", + recipeInstructions: "1. Faire ceci\n2. Faire cela\n3. Servir", + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps).toHaveLength(3); + expect(result.steps[0]).toBe("Faire ceci"); + expect(result.steps[1]).toBe("Faire cela"); + expect(result.steps[2]).toBe("Servir"); + }); + + // --- totalTime fallback --- + + it("should use totalTime as prepTime fallback when no other times", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Quick recipe", + totalTime: "PT45M", + recipeIngredient: [], + recipeInstructions: [], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.prepTime).toBe(45); + expect(result.cookTime).toBeNull(); + }); + + it("should not use totalTime as prepTime when cookTime is present", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Recipe with cook time", + cookTime: "PT30M", + totalTime: "PT60M", + recipeIngredient: [], + recipeInstructions: [], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.prepTime).toBeNull(); + expect(result.cookTime).toBe(30); + }); + + // --- Error cases --- + + it("should throw IMPORT_003 when no JSON-LD recipe found", async () => { + const html = "

Not a recipe page

"; + mockFetch(html); + + await expect(importFromUrl("https://example.com/not-recipe")).rejects.toThrow("IMPORT_003"); + }); + + it("should throw IMPORT_002 when fetch fails (non-ok status)", async () => { + mockFetch("Not found", 404); + + await expect(importFromUrl("https://example.com/404")).rejects.toThrow("IMPORT_002"); + }); + + it("should throw IMPORT_002 when fetch throws network error", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + await expect(importFromUrl("https://example.com/recipe")).rejects.toThrow("IMPORT_002"); + }); + + // --- recipeYield parsing --- + + it("should extract number from recipeYield string", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test", + recipeYield: "Pour 4 personnes", + recipeInstructions: [], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + expect(result.servings).toBe(4); + }); + + it("should handle recipeYield as array", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test", + recipeYield: ["6 portions", "6"], + recipeInstructions: [], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + expect(result.servings).toBe(6); + }); + + // --- HTML stripping in instructions (HelloFresh) --- + + it("should strip HTML tags from HowToStep text", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test HTML", + recipeInstructions: [ + { + "@type": "HowToStep", + text: "
    \n
  • Prechauffer le four a 200C.
  • \n
  • Couper les legumes.
  • \n
", + }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps).toContain("Prechauffer le four a 200C."); + expect(result.steps).toContain("Couper les legumes."); + // Pas de balises HTML + result.steps.forEach((step) => { + expect(step).not.toMatch(/<[^>]+>/); + }); + }); + + it("should handle HowToStep with HTML entities", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test entities", + recipeInstructions: [ + { + "@type": "HowToStep", + text: "

Ajoutez l'huile d'olive.

", + }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps[0]).toBe("Ajoutez l'huile d'olive."); + }); + + it("should handle mixed HTML with
  • and

    tags", async () => { + const jsonLd = { + "@type": "Recipe", + name: "Test mixed", + recipeInstructions: [ + { + "@type": "HowToStep", + text: "

    • Etape dans une liste.

    CONSEIL : un tip utile.

    ", + }, + ], + }; + + mockFetch(makeHtml(jsonLd)); + + const result = await importFromUrl("https://example.com/recipe"); + + expect(result.steps).toContain("Etape dans une liste."); + expect(result.steps).toContain("CONSEIL : un tip utile."); + }); + + // --- Non-authenticated test is handled by integration tests --- +}); diff --git a/backend/src/__tests__/unit/responseFormatters.test.ts b/backend/src/__tests__/unit/responseFormatters.test.ts index 843e0b16..ac928880 100644 --- a/backend/src/__tests__/unit/responseFormatters.test.ts +++ b/backend/src/__tests__/unit/responseFormatters.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect } from "vitest"; -import { formatTags, formatIngredients } from "../../util/responseFormatters"; +import { formatTags, formatIngredients, formatSteps } 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" }, ]); }); @@ -23,24 +27,56 @@ 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, + unitId: null, + unit: null, + order: 0, + }, + { + id: "ri2", + name: "milk", + ingredientId: "i2", + quantity: 200, + unitId: null, + unit: null, + order: 1, + }, ]); }); + it("should include unit when present", () => { + const raw = [ + { + id: "ri1", + quantity: 100, + order: 0, + ingredient: { id: "i1", name: "flour" }, + unit: { id: "u1", abbreviation: "g" }, + }, + ]; + + const result = formatIngredients(raw); + expect(result[0].unitId).toBe("u1"); + expect(result[0].unit).toEqual({ id: "u1", abbreviation: "g" }); + }); + it("should handle null quantity", () => { const raw = [ { @@ -59,3 +95,21 @@ describe("formatIngredients", () => { expect(formatIngredients([])).toEqual([]); }); }); + +describe("formatSteps", () => { + it("should map step fields correctly", () => { + const raw = [ + { id: "s1", order: 0, instruction: "Preparer les ingredients" }, + { id: "s2", order: 1, instruction: "Melanger et cuire" }, + ]; + + expect(formatSteps(raw)).toEqual([ + { id: "s1", order: 0, instruction: "Preparer les ingredients" }, + { id: "s2", order: 1, instruction: "Melanger et cuire" }, + ]); + }); + + it("should return empty array for empty input", () => { + expect(formatSteps([])).toEqual([]); + }); +}); diff --git a/backend/src/__tests__/unit/storageService.test.ts b/backend/src/__tests__/unit/storageService.test.ts new file mode 100644 index 00000000..1c28825c --- /dev/null +++ b/backend/src/__tests__/unit/storageService.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// vi.hoisted pour que les mocks soient accessibles dans vi.mock (hoiste) +const { mockSend, mockGetSignedUrl } = vi.hoisted(() => ({ + mockSend: vi.fn(), + mockGetSignedUrl: vi.fn(), +})); + +vi.mock("@aws-sdk/client-s3", () => { + return { + S3Client: vi.fn().mockImplementation(() => ({ send: mockSend })), + PutObjectCommand: vi.fn().mockImplementation((input) => ({ input })), + DeleteObjectCommand: vi.fn().mockImplementation((input) => ({ input })), + HeadObjectCommand: vi.fn().mockImplementation((input) => ({ input })), + }; +}); + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args), +})); + +vi.mock("../../config/storage", () => ({ + storageConfig: { + endpoint: "localhost", + port: 9000, + accessKey: "minioadmin", + secretKey: "minioadmin", + bucket: "test-bucket", + publicUrl: "http://localhost:9000", + useSSL: false, + presignedUrlTTL: 60, + maxFileSize: 2 * 1024 * 1024, + allowedMimeTypes: ["image/webp", "image/jpeg", "image/png"], + }, +})); + +vi.mock("../../util/logger", () => ({ + default: { + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { + generatePresignedUploadUrl, + headObject, + deleteObject, + validateUploadedFile, +} from "../../services/storageService"; + +describe("storageService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("generatePresignedUploadUrl", () => { + it("should return a presigned URL", async () => { + mockGetSignedUrl.mockResolvedValue("https://minio.test/presigned-url"); + + const url = await generatePresignedUploadUrl("recipes/abc/cover.webp"); + + expect(url).toBe("https://minio.test/presigned-url"); + expect(mockGetSignedUrl).toHaveBeenCalledOnce(); + }); + }); + + describe("headObject", () => { + it("should return content type and length", async () => { + mockSend.mockResolvedValue({ + ContentType: "image/webp", + ContentLength: 50000, + }); + + const result = await headObject("recipes/abc/cover.webp"); + + expect(result).toEqual({ + contentType: "image/webp", + contentLength: 50000, + }); + }); + + it("should return null for NotFound", async () => { + const notFoundError = new Error("NotFound"); + notFoundError.name = "NotFound"; + mockSend.mockRejectedValue(notFoundError); + + const result = await headObject("nonexistent.webp"); + expect(result).toBeNull(); + }); + + it("should throw for other errors", async () => { + mockSend.mockRejectedValue(new Error("NetworkError")); + + await expect(headObject("key")).rejects.toThrow("NetworkError"); + }); + + it("should default contentType and contentLength when missing", async () => { + mockSend.mockResolvedValue({}); + + const result = await headObject("key"); + expect(result).toEqual({ + contentType: "unknown", + contentLength: 0, + }); + }); + }); + + describe("deleteObject", () => { + it("should call S3 send with delete command", async () => { + mockSend.mockResolvedValue({}); + + await deleteObject("recipes/abc/cover.webp"); + + expect(mockSend).toHaveBeenCalledOnce(); + }); + }); + + describe("validateUploadedFile", () => { + it("should return null for valid file", async () => { + mockSend.mockResolvedValue({ + ContentType: "image/webp", + ContentLength: 100000, + }); + + const error = await validateUploadedFile("key"); + expect(error).toBeNull(); + }); + + it("should return error when file not found", async () => { + const notFoundError = new Error("NotFound"); + notFoundError.name = "NotFound"; + mockSend.mockRejectedValue(notFoundError); + + const error = await validateUploadedFile("key"); + expect(error).toBe("File not found on storage"); + }); + + it("should return error for invalid MIME type", async () => { + mockSend.mockResolvedValue({ + ContentType: "application/pdf", + ContentLength: 100000, + }); + + const error = await validateUploadedFile("key"); + expect(error).toContain("Invalid file type"); + }); + + it("should return error for file too large", async () => { + mockSend.mockResolvedValue({ + ContentType: "image/webp", + ContentLength: 5 * 1024 * 1024, // 5 MB + }); + + const error = await validateUploadedFile("key"); + expect(error).toContain("File too large"); + }); + }); +}); diff --git a/backend/src/__tests__/unit/validation.test.ts b/backend/src/__tests__/unit/validation.test.ts index 792a75e8..4929eaa0 100644 --- a/backend/src/__tests__/unit/validation.test.ts +++ b/backend/src/__tests__/unit/validation.test.ts @@ -5,6 +5,9 @@ import { EMAIL_REGEX, USERNAME_REGEX, COMMUNITY_VALIDATION, + validateServings, + validateTime, + validateSteps, } from "../../util/validation"; describe("normalizeNames", () => { @@ -82,3 +85,102 @@ describe("Validation constants", () => { expect(COMMUNITY_VALIDATION.DESCRIPTION_MAX).toBe(1000); }); }); + +describe("validateServings", () => { + it("should accept valid servings (1-100)", () => { + expect(validateServings(1)).toBe(true); + expect(validateServings(4)).toBe(true); + expect(validateServings(100)).toBe(true); + }); + + it("should reject 0", () => { + expect(validateServings(0)).toBe(false); + }); + + it("should reject negative values", () => { + expect(validateServings(-1)).toBe(false); + }); + + it("should reject values above 100", () => { + expect(validateServings(101)).toBe(false); + }); + + it("should reject non-integer values", () => { + expect(validateServings(3.5)).toBe(false); + }); + + it("should reject null/undefined/string", () => { + expect(validateServings(null)).toBe(false); + expect(validateServings(undefined)).toBe(false); + expect(validateServings("4")).toBe(false); + }); +}); + +describe("validateTime", () => { + it("should accept null and undefined (optional)", () => { + expect(validateTime(null)).toBe(true); + expect(validateTime(undefined)).toBe(true); + }); + + it("should accept 0", () => { + expect(validateTime(0)).toBe(true); + }); + + it("should accept valid times", () => { + expect(validateTime(45)).toBe(true); + expect(validateTime(10000)).toBe(true); + }); + + it("should reject negative values", () => { + expect(validateTime(-1)).toBe(false); + }); + + it("should reject values above 10000", () => { + expect(validateTime(10001)).toBe(false); + }); + + it("should reject non-integer values", () => { + expect(validateTime(3.5)).toBe(false); + }); + + it("should reject strings", () => { + expect(validateTime("10" as unknown)).toBe(false); + }); +}); + +describe("validateSteps", () => { + it("should accept valid steps", () => { + expect(validateSteps([{ instruction: "Step 1" }])).toBe(true); + expect(validateSteps([{ instruction: "Step 1" }, { instruction: "Step 2" }])).toBe(true); + }); + + it("should reject empty array", () => { + expect(validateSteps([])).toBe(false); + }); + + it("should reject non-array", () => { + expect(validateSteps(null)).toBe(false); + expect(validateSteps(undefined)).toBe(false); + expect(validateSteps("step")).toBe(false); + }); + + it("should reject steps with empty instruction", () => { + expect(validateSteps([{ instruction: "" }])).toBe(false); + expect(validateSteps([{ instruction: " " }])).toBe(false); + }); + + it("should reject steps without instruction field", () => { + expect(validateSteps([{ text: "Step" }])).toBe(false); + expect(validateSteps([{}])).toBe(false); + }); + + it("should reject steps with instruction exceeding 5000 chars", () => { + const longInstruction = "a".repeat(5001); + expect(validateSteps([{ instruction: longInstruction }])).toBe(false); + }); + + it("should accept steps with instruction at 5000 chars", () => { + const maxInstruction = "a".repeat(5000); + expect(validateSteps([{ instruction: maxInstruction }])).toBe(true); + }); +}); diff --git a/backend/src/admin/controllers/activityController.ts b/backend/src/admin/controllers/activityController.ts index 80f863c5..942c068c 100644 --- a/backend/src/admin/controllers/activityController.ts +++ b/backend/src/admin/controllers/activityController.ts @@ -14,7 +14,11 @@ interface GetActivityQuery { * GET /api/admin/activity * Liste des activites admin avec pagination */ -export const getAll: RequestHandler = async (req, res, next) => { +export const getAll: RequestHandler = async ( + req, + res, + next +) => { try { const { type, adminId } = req.query; const { limit: take, offset: skip } = parsePagination(req.query, 50); diff --git a/backend/src/admin/controllers/authController.ts b/backend/src/admin/controllers/authController.ts index 3bc1a3e5..88becbdd 100644 --- a/backend/src/admin/controllers/authController.ts +++ b/backend/src/admin/controllers/authController.ts @@ -4,6 +4,15 @@ import bcrypt from "bcrypt"; import { generateURI, verifySync } from "otplib"; import * as QRCode from "qrcode"; import prisma from "../../util/db"; +import { + ADMIN_001, + ADMIN_004, + ADMIN_006, + ADMIN_007, + ADMIN_008, + ADMIN_009, +} from "../../constants/errorCodes"; +import { AdminLoginInput, VerifyTotpInput } from "../schemas/auth.schema"; const MAX_TOTP_ATTEMPTS = 3; const APP_NAME = "ForestManager"; @@ -13,48 +22,53 @@ const APP_NAME = "ForestManager"; * Premiere etape: verification email/password * Si totpEnabled = false, retourne le QR code pour configurer TOTP */ -export const login: RequestHandler = async (req, res, next) => { +export const login: RequestHandler = async (req, res, next) => { try { const { email, password } = req.body; - if (!email || !password) { - throw createHttpError(400, "ADMIN_003: Email and password required"); - } - const admin = await prisma.adminUser.findUnique({ where: { email }, }); if (!admin) { // Message generique pour eviter l'enumeration - throw createHttpError(401, "ADMIN_004: Invalid credentials"); + throw createHttpError(401, ADMIN_004); } const isPasswordValid = await bcrypt.compare(password, admin.password); if (!isPasswordValid) { - throw createHttpError(401, "ADMIN_004: Invalid credentials"); + throw createHttpError(401, ADMIN_004); } - // Stocke l'adminId en session (mais totpVerified reste false) - req.session.adminId = admin.id; - req.session.totpVerified = false; - req.session.totpAttempts = 0; - - // Si TOTP pas encore configure, generer le QR code - if (!admin.totpEnabled) { - const otpauth = generateURI({ secret: admin.totpSecret, issuer: APP_NAME, label: admin.email }); - const qrCodeDataUrl = await QRCode.toDataURL(otpauth); + // Regenerer la session pour prevenir la session fixation + const adminId = admin.id; + const totpEnabled = admin.totpEnabled; + const adminEmail = admin.email; + const totpSecret = admin.totpSecret; + + req.session.regenerate(async (err) => { + if (err) return next(err); + + req.session.adminId = adminId; + req.session.totpVerified = false; + req.session.totpAttempts = 0; + + // Si TOTP pas encore configure, generer le QR code + if (!totpEnabled) { + const otpauth = generateURI({ secret: totpSecret, issuer: APP_NAME, label: adminEmail }); + const qrCodeDataUrl = await QRCode.toDataURL(otpauth); + + return res.status(200).json({ + requiresTotpSetup: true, + qrCode: qrCodeDataUrl, + message: "Scan this QR code with your authenticator app, then verify with a code", + }); + } - return res.status(200).json({ - requiresTotpSetup: true, - qrCode: qrCodeDataUrl, - message: "Scan this QR code with your authenticator app, then verify with a code", + res.status(200).json({ + requiresTotpSetup: false, + message: "Please enter your TOTP code", }); - } - - res.status(200).json({ - requiresTotpSetup: false, - message: "Please enter your TOTP code", }); } catch (error) { next(error); @@ -66,17 +80,17 @@ export const login: RequestHandler = async (req, res, next) => { * Deuxieme etape: verification du code TOTP * Finalise l'authentification si le code est valide */ -export const verifyTotp: RequestHandler = async (req, res, next) => { +export const verifyTotp: RequestHandler = async ( + req, + res, + next +) => { try { const { code } = req.body; const adminId = req.session.adminId; if (!adminId) { - throw createHttpError(401, "ADMIN_001: Not authenticated"); - } - - if (!code) { - throw createHttpError(400, "ADMIN_005: TOTP code required"); + throw createHttpError(401, ADMIN_001); } // Verifier le nombre de tentatives @@ -84,7 +98,7 @@ export const verifyTotp: RequestHandler = async (req, res, next) => { if (attempts >= MAX_TOTP_ATTEMPTS) { // Reset la session et bloquer req.session.destroy(() => {}); - throw createHttpError(429, "ADMIN_006: Too many failed attempts, please login again"); + throw createHttpError(429, ADMIN_006); } const admin = await prisma.adminUser.findUnique({ @@ -92,7 +106,7 @@ export const verifyTotp: RequestHandler = async (req, res, next) => { }); if (!admin) { - throw createHttpError(401, "ADMIN_001: Not authenticated"); + throw createHttpError(401, ADMIN_001); } const result = verifySync({ @@ -103,7 +117,7 @@ export const verifyTotp: RequestHandler = async (req, res, next) => { if (!isValid) { req.session.totpAttempts = attempts + 1; - throw createHttpError(401, "ADMIN_007: Invalid TOTP code"); + throw createHttpError(401, ADMIN_007); } // TOTP valide - marquer comme configure si premiere fois @@ -123,10 +137,6 @@ export const verifyTotp: RequestHandler = async (req, res, next) => { }); } - // Finaliser l'authentification - req.session.totpVerified = true; - req.session.totpAttempts = 0; - // Mettre a jour lastLoginAt et logger await prisma.adminUser.update({ where: { id: adminId }, @@ -141,13 +151,25 @@ export const verifyTotp: RequestHandler = async (req, res, next) => { }, }); - res.status(200).json({ - message: "Authentication successful", - admin: { - id: admin.id, - username: admin.username, - email: admin.email, - }, + // Regenerer la session apres authentification complete + const finalAdminId = admin.id; + const adminUsername = admin.username; + const adminEmail = admin.email; + + req.session.regenerate((err) => { + if (err) return next(err); + req.session.adminId = finalAdminId; + req.session.totpVerified = true; + req.session.totpAttempts = 0; + + res.status(200).json({ + message: "Authentication successful", + admin: { + id: finalAdminId, + username: adminUsername, + email: adminEmail, + }, + }); }); } catch (error) { next(error); @@ -174,7 +196,7 @@ export const logout: RequestHandler = async (req, res, next) => { req.session.destroy((err) => { if (err) { - return next(createHttpError(500, "ADMIN_008: Logout failed")); + return next(createHttpError(500, ADMIN_008)); } res.clearCookie("admin.sid"); res.status(200).json({ message: "Logged out successfully" }); @@ -193,7 +215,7 @@ export const getMe: RequestHandler = async (req, res, next) => { const adminId = req.session.adminId; if (!adminId) { - throw createHttpError(401, "ADMIN_001: Not authenticated"); + throw createHttpError(401, ADMIN_001); } const admin = await prisma.adminUser.findUnique({ @@ -207,7 +229,7 @@ export const getMe: RequestHandler = async (req, res, next) => { }); if (!admin) { - throw createHttpError(404, "ADMIN_009: Admin not found"); + throw createHttpError(404, ADMIN_009); } res.status(200).json({ admin }); diff --git a/backend/src/admin/controllers/communitiesController.ts b/backend/src/admin/controllers/communitiesController.ts index b3827303..7290dc85 100644 --- a/backend/src/admin/controllers/communitiesController.ts +++ b/backend/src/admin/controllers/communitiesController.ts @@ -2,6 +2,9 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../../util/db"; import { assertIsDefine } from "../../util/assertIsDefine"; +import { parsePagination, buildPaginationMeta } from "../../util/pagination"; +import { ADMIN_COM_001, ADMIN_COM_003 } from "../../constants/errorCodes"; +import { AdminUpdateCommunityInput } from "../schemas/community.schema"; /** * GET /api/admin/communities @@ -10,28 +13,34 @@ import { assertIsDefine } from "../../util/assertIsDefine"; export const getAll: RequestHandler = async (req, res, next) => { try { const { search, includeDeleted } = req.query; - - const communities = await prisma.community.findMany({ - where: { - ...(search - ? { name: { contains: String(search), mode: "insensitive" } } - : {}), - ...(includeDeleted !== "true" ? { deletedAt: null } : {}), - }, - include: { - _count: { - select: { - members: true, - recipes: true, + const { limit, offset } = parsePagination(req.query as Record, 100); + + const where = { + ...(search ? { name: { contains: String(search), mode: "insensitive" as const } } : {}), + ...(includeDeleted !== "true" ? { deletedAt: null } : {}), + }; + + const [communities, total] = await Promise.all([ + prisma.community.findMany({ + where, + include: { + _count: { + select: { + members: true, + recipes: true, + }, + }, + features: { + where: { revokedAt: null }, + include: { feature: true }, }, }, - features: { - where: { revokedAt: null }, - include: { feature: true }, - }, - }, - orderBy: { createdAt: "desc" }, - }); + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + }), + prisma.community.count({ where }), + ]); res.status(200).json({ communities: communities.map((c) => ({ @@ -45,6 +54,7 @@ export const getAll: RequestHandler = async (req, res, next) => { createdAt: c.createdAt, deletedAt: c.deletedAt, })), + pagination: buildPaginationMeta(total, limit, offset, communities.length), }); } catch (error) { next(error); @@ -82,7 +92,7 @@ export const getOne: RequestHandler = async (req, res, next) => { }); if (!community) { - throw createHttpError(404, "ADMIN_COM_001: Community not found"); + throw createHttpError(404, ADMIN_COM_001); } res.status(200).json({ @@ -125,23 +135,19 @@ export const getOne: RequestHandler = async (req, res, next) => { export const update: RequestHandler = async (req, res, next) => { try { const { id } = req.params; - const { name } = req.body; + const { name } = req.body as AdminUpdateCommunityInput; const adminId = req.session.adminId; assertIsDefine(adminId); const community = await prisma.community.findUnique({ where: { id } }); if (!community) { - throw createHttpError(404, "ADMIN_COM_001: Community not found"); - } - - if (!name || typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_COM_002: Name is required"); + throw createHttpError(404, ADMIN_COM_001); } const oldName = community.name; const updated = await prisma.community.update({ where: { id }, - data: { name: name.trim() }, + data: { name }, }); await prisma.adminActivityLog.create({ @@ -150,7 +156,7 @@ export const update: RequestHandler = async (req, res, next) => { type: "COMMUNITY_RENAMED", targetType: "Community", targetId: id, - metadata: { oldName, newName: name.trim() }, + metadata: { oldName, newName: name }, }, }); @@ -172,11 +178,11 @@ export const remove: RequestHandler = async (req, res, next) => { const community = await prisma.community.findUnique({ where: { id } }); if (!community) { - throw createHttpError(404, "ADMIN_COM_001: Community not found"); + throw createHttpError(404, ADMIN_COM_001); } if (community.deletedAt) { - throw createHttpError(400, "ADMIN_COM_003: Community already deleted"); + throw createHttpError(400, ADMIN_COM_003); } await prisma.community.update({ diff --git a/backend/src/admin/controllers/dashboardController.ts b/backend/src/admin/controllers/dashboardController.ts index ed64f20a..5bee38e6 100644 --- a/backend/src/admin/controllers/dashboardController.ts +++ b/backend/src/admin/controllers/dashboardController.ts @@ -7,31 +7,21 @@ import prisma from "../../util/db"; */ export const getStats: RequestHandler = async (req, res, next) => { try { - const [ - userCount, - communityCount, - recipeCount, - tagCount, - ingredientCount, - featureCount, - ] = await Promise.all([ - prisma.user.count({ where: { deletedAt: null } }), - prisma.community.count({ where: { deletedAt: null } }), - prisma.recipe.count({ where: { deletedAt: null } }), - prisma.tag.count(), - prisma.ingredient.count(), - prisma.feature.count(), - ]); + const [userCount, communityCount, recipeCount, tagCount, ingredientCount, featureCount] = + await Promise.all([ + prisma.user.count({ where: { deletedAt: null } }), + prisma.community.count({ where: { deletedAt: null } }), + prisma.recipe.count({ where: { deletedAt: null } }), + prisma.tag.count(), + prisma.ingredient.count(), + prisma.feature.count(), + ]); // Stats recentes (7 derniers jours) const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const [ - newUsersWeek, - newCommunitiesWeek, - newRecipesWeek, - ] = await Promise.all([ + const [newUsersWeek, newCommunitiesWeek, newRecipesWeek] = await Promise.all([ prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo }, deletedAt: null }, }), diff --git a/backend/src/admin/controllers/featuresController.ts b/backend/src/admin/controllers/featuresController.ts index 0e71614e..ec3daaa7 100644 --- a/backend/src/admin/controllers/featuresController.ts +++ b/backend/src/admin/controllers/featuresController.ts @@ -2,6 +2,14 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../../util/db"; import { assertIsDefine } from "../../util/assertIsDefine"; +import { + ADMIN_COM_001, + ADMIN_FEAT_003, + ADMIN_FEAT_004, + ADMIN_FEAT_005, + ADMIN_FEAT_006, +} from "../../constants/errorCodes"; +import { AdminCreateFeatureInput, AdminUpdateFeatureInput } from "../schemas/feature.schema"; /** * GET /api/admin/features @@ -40,34 +48,24 @@ export const getAll: RequestHandler = async (req, res, next) => { */ export const create: RequestHandler = async (req, res, next) => { try { - const { code, name, description, isDefault } = req.body; + const { code, name, description, isDefault } = req.body as AdminCreateFeatureInput; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!code || typeof code !== "string" || code.trim().length === 0) { - throw createHttpError(400, "ADMIN_FEAT_001: Code is required"); - } - - if (!name || typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_FEAT_002: Name is required"); - } - - const normalizedCode = code.trim().toUpperCase().replace(/\s+/g, "_"); - const existing = await prisma.feature.findUnique({ - where: { code: normalizedCode }, + where: { code }, }); if (existing) { - throw createHttpError(409, "ADMIN_FEAT_003: Feature code already exists"); + throw createHttpError(409, ADMIN_FEAT_003); } const feature = await prisma.feature.create({ data: { - code: normalizedCode, - name: name.trim(), - description: description?.trim() || null, - isDefault: Boolean(isDefault), + code, + name, + description: description ?? null, + isDefault, }, }); @@ -77,7 +75,7 @@ export const create: RequestHandler = async (req, res, next) => { type: "FEATURE_CREATED", targetType: "Feature", targetId: feature.id, - metadata: { code: normalizedCode, name: name.trim() }, + metadata: { code, name }, }, }); @@ -94,30 +92,27 @@ export const create: RequestHandler = async (req, res, next) => { export const update: RequestHandler = async (req, res, next) => { try { const { id } = req.params; - const { name, description, isDefault } = req.body; + const { name, description, isDefault } = req.body as AdminUpdateFeatureInput; const adminId = req.session.adminId; assertIsDefine(adminId); const feature = await prisma.feature.findUnique({ where: { id } }); if (!feature) { - throw createHttpError(404, "ADMIN_FEAT_004: Feature not found"); + throw createHttpError(404, ADMIN_FEAT_004); } const updateData: { name?: string; description?: string | null; isDefault?: boolean } = {}; if (name !== undefined) { - if (typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_FEAT_002: Name is required"); - } - updateData.name = name.trim(); + updateData.name = name; } if (description !== undefined) { - updateData.description = description?.trim() || null; + updateData.description = description; } if (isDefault !== undefined) { - updateData.isDefault = Boolean(isDefault); + updateData.isDefault = isDefault; } const updated = await prisma.feature.update({ @@ -157,10 +152,10 @@ export const grant: RequestHandler = async (req, res, next) => { ]); if (!community) { - throw createHttpError(404, "ADMIN_COM_001: Community not found"); + throw createHttpError(404, ADMIN_COM_001); } if (!feature) { - throw createHttpError(404, "ADMIN_FEAT_004: Feature not found"); + throw createHttpError(404, ADMIN_FEAT_004); } // Verifier si deja attribue (et non revoke) @@ -169,7 +164,7 @@ export const grant: RequestHandler = async (req, res, next) => { }); if (existing && !existing.revokedAt) { - throw createHttpError(409, "ADMIN_FEAT_005: Feature already granted"); + throw createHttpError(409, ADMIN_FEAT_005); } if (existing && existing.revokedAt) { @@ -218,10 +213,10 @@ export const revoke: RequestHandler = async (req, res, next) => { ]); if (!community) { - throw createHttpError(404, "ADMIN_COM_001: Community not found"); + throw createHttpError(404, ADMIN_COM_001); } if (!feature) { - throw createHttpError(404, "ADMIN_FEAT_004: Feature not found"); + throw createHttpError(404, ADMIN_FEAT_004); } const existing = await prisma.communityFeature.findUnique({ @@ -229,7 +224,7 @@ export const revoke: RequestHandler = async (req, res, next) => { }); if (!existing || existing.revokedAt) { - throw createHttpError(404, "ADMIN_FEAT_006: Feature not granted to this community"); + throw createHttpError(404, ADMIN_FEAT_006); } await prisma.communityFeature.update({ diff --git a/backend/src/admin/controllers/ingredientsController.ts b/backend/src/admin/controllers/ingredientsController.ts index f11c1e48..77d3fc49 100644 --- a/backend/src/admin/controllers/ingredientsController.ts +++ b/backend/src/admin/controllers/ingredientsController.ts @@ -2,63 +2,155 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../../util/db"; import { assertIsDefine } from "../../util/assertIsDefine"; +import { parsePagination, buildPaginationMeta } from "../../util/pagination"; +import appEvents from "../../services/eventEmitter"; +import { + ADMIN_ING_002, + ADMIN_ING_003, + ADMIN_ING_005, + ADMIN_ING_006, + ADMIN_ING_007, + ADMIN_ING_008, +} from "../../constants/errorCodes"; +import { + AdminCreateIngredientInput, + AdminUpdateIngredientInput, + AdminApproveIngredientInput, + AdminRejectIngredientInput, + AdminMergeIngredientInput, +} from "../schemas/ingredient.schema"; /** * 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 ingredients = await prisma.ingredient.findMany({ - where: search - ? { name: { contains: String(search), mode: "insensitive" } } - : undefined, - include: { - _count: { select: { recipes: true } }, - }, - orderBy: { name: "asc" }, - }); + const { search, status } = req.query; + const { limit, offset } = parsePagination(req.query as Record, 100); + + const where: Record = {}; + + if (search) { + where.name = { contains: String(search), mode: "insensitive" }; + } + + if (status === "APPROVED" || status === "PENDING") { + where.status = status; + } + + const [ingredients, total] = await Promise.all([ + prisma.ingredient.findMany({ + where, + include: { + _count: { select: { recipes: true, proposals: true } }, + createdBy: { select: { id: true, username: true } }, + defaultUnit: { select: { id: true, name: true, abbreviation: true } }, + }, + orderBy: { name: "asc" }, + skip: offset, + take: limit, + }), + prisma.ingredient.count({ where }), + ]); + + // Calculer l'unite populaire pour chaque ingredient + const ingredientIds = ingredients.map((i) => i.id); + const popularUnits = await getPopularUnitsForIngredients(ingredientIds); res.status(200).json({ ingredients: ingredients.map((i) => ({ id: i.id, name: i.name, + status: i.status, + createdBy: i.createdBy, + defaultUnit: i.defaultUnit, + popularUnit: popularUnits[i.id] || null, recipeCount: i._count.recipes, + proposalCount: i._count.proposals, + createdAt: i.createdAt, })), + pagination: buildPaginationMeta(total, limit, offset, ingredients.length), }); } catch (error) { next(error); } }; +/** + * Calcule l'unite la plus utilisee pour chaque ingredient + */ +async function getPopularUnitsForIngredients( + ingredientIds: string[] +): Promise> { + if (ingredientIds.length === 0) return {}; + + // Requete raw pour obtenir l'unite la plus utilisee par ingredient + const results = await prisma.$queryRaw< + Array<{ + ingredientId: string; + unitId: string; + abbreviation: string; + useCount: bigint; + }> + >` + SELECT DISTINCT ON (ri."ingredientId") + ri."ingredientId", + ri."unitId", + u."abbreviation", + COUNT(*) as "useCount" + FROM "RecipeIngredient" ri + JOIN "Unit" u ON u.id = ri."unitId" + WHERE ri."ingredientId" = ANY(${ingredientIds}) + AND ri."unitId" IS NOT NULL + GROUP BY ri."ingredientId", ri."unitId", u."abbreviation" + ORDER BY ri."ingredientId", COUNT(*) DESC + `; + + const map: Record = {}; + for (const row of results) { + map[row.ingredientId] = { + id: row.unitId, + abbreviation: row.abbreviation, + useCount: Number(row.useCount), + }; + } + return map; +} + /** * 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 as AdminCreateIngredientInput; 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 normalized = name.trim().toLowerCase(); - const existing = await prisma.ingredient.findUnique({ - where: { name: normalized }, + where: { name }, }); if (existing) { - throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); + throw createHttpError(409, ADMIN_ING_002); + } + + // Valider defaultUnitId si fourni + if (defaultUnitId) { + const unit = await prisma.unit.findUnique({ where: { id: defaultUnitId } }); + if (!unit) { + throw createHttpError(400, ADMIN_ING_007); + } } const ingredient = await prisma.ingredient.create({ - data: { name: normalized }, + data: { + name, + status: "APPROVED", + defaultUnitId: defaultUnitId || null, + }, }); await prisma.adminActivityLog.create({ @@ -67,7 +159,7 @@ export const create: RequestHandler = async (req, res, next) => { type: "INGREDIENT_CREATED", targetType: "Ingredient", targetId: ingredient.id, - metadata: { name: normalized }, + metadata: { name }, }, }); @@ -79,39 +171,54 @@ 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 as AdminUpdateIngredientInput; 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"); + throw createHttpError(404, ADMIN_ING_003); } - const normalized = name.trim().toLowerCase(); + const data: Record = {}; + const metadata: Record = {}; - if (normalized !== ingredient.name) { + if (name !== undefined && name !== ingredient.name) { const existing = await prisma.ingredient.findUnique({ - where: { name: normalized }, + where: { name }, }); if (existing) { - throw createHttpError(409, "ADMIN_ING_002: Ingredient already exists"); + throw createHttpError(409, ADMIN_ING_002); } + metadata.oldName = ingredient.name; + metadata.newName = name; + data.name = 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); + } + data.defaultUnitId = defaultUnitId; + } + } + + if (Object.keys(data).length === 0) { + return res.status(200).json({ ingredient }); } - const oldName = ingredient.name; const updated = await prisma.ingredient.update({ where: { id }, - data: { name: normalized }, + data, }); await prisma.adminActivityLog.create({ @@ -120,7 +227,7 @@ export const update: RequestHandler = async (req, res, next) => { type: "INGREDIENT_UPDATED", targetType: "Ingredient", targetId: id, - metadata: { oldName, newName: normalized }, + metadata, }, }); @@ -142,7 +249,7 @@ export const remove: RequestHandler = async (req, res, next) => { const ingredient = await prisma.ingredient.findUnique({ where: { id } }); if (!ingredient) { - throw createHttpError(404, "ADMIN_ING_003: Ingredient not found"); + throw createHttpError(404, ADMIN_ING_003); } await prisma.ingredient.delete({ where: { id } }); @@ -166,20 +273,17 @@ 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 { const { id: sourceId } = req.params; - const { targetId } = req.body; + const { targetId } = req.body as AdminMergeIngredientInput; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!targetId) { - throw createHttpError(400, "ADMIN_ING_004: Target ingredient ID required"); - } - if (sourceId === targetId) { - throw createHttpError(400, "ADMIN_ING_005: Cannot merge ingredient into itself"); + throw createHttpError(400, ADMIN_ING_005); } const [source, target] = await Promise.all([ @@ -188,33 +292,50 @@ export const merge: RequestHandler = async (req, res, next) => { ]); if (!source) { - throw createHttpError(404, "ADMIN_ING_003: Source ingredient not found"); + throw createHttpError(404, ADMIN_ING_003); } if (!target) { - throw createHttpError(404, "ADMIN_ING_006: Target ingredient not found"); + throw createHttpError(404, ADMIN_ING_006); } 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 }, + }); + } + } + + // 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 les RecipeIngredient) + // Supprimer le source (cascade supprime RecipeIngredient + ProposalIngredient) await tx.ingredient.delete({ where: { id: sourceId } }); }); @@ -232,6 +353,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}"`, }); @@ -239,3 +374,128 @@ 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 as AdminApproveIngredientInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const ingredient = await prisma.ingredient.findUnique({ where: { id } }); + if (!ingredient) { + throw createHttpError(404, ADMIN_ING_003); + } + + if (ingredient.status !== "PENDING") { + throw createHttpError(400, ADMIN_ING_008); + } + + const data: Record = { status: "APPROVED" }; + const metadata: Record = { name: ingredient.name }; + + if (newName && newName !== ingredient.name) { + const existing = await prisma.ingredient.findUnique({ where: { name: newName } }); + if (existing) { + throw createHttpError(409, ADMIN_ING_002); + } + data.name = newName; + metadata.oldName = ingredient.name; + metadata.newName = newName; + } + + const updated = await prisma.ingredient.update({ + where: { id }, + data, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "INGREDIENT_APPROVED", + targetType: "Ingredient", + targetId: id, + metadata, + }, + }); + + // 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); + } +}; + +/** + * 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 as AdminRejectIngredientInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const ingredient = await prisma.ingredient.findUnique({ where: { id } }); + if (!ingredient) { + throw createHttpError(404, ADMIN_ING_003); + } + + if (ingredient.status !== "PENDING") { + throw createHttpError(400, ADMIN_ING_008); + } + + // 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, + createdById: ingredient.createdById, + }, + }, + }); + + // 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, + }, + }); + } + + res.status(200).json({ message: "Ingredient rejected and deleted" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/admin/controllers/recipesController.ts b/backend/src/admin/controllers/recipesController.ts new file mode 100644 index 00000000..05ae3fd3 --- /dev/null +++ b/backend/src/admin/controllers/recipesController.ts @@ -0,0 +1,177 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import prisma from "../../util/db"; +import { assertIsDefine } from "../../util/assertIsDefine"; +import { parsePagination, buildPaginationMeta } from "../../util/pagination"; +import { RECIPE_DETAIL_INCLUDE } from "../../util/prismaSelects"; +import { ADMIN_REC_001, ADMIN_REC_002, ADMIN_REC_003 } from "../../constants/errorCodes"; +import { AdminUpdateRecipeInput } from "../schemas/recipe.schema"; + +/** + * GET /api/admin/tags/:id/recipes + * Liste les recettes associees a un tag + */ +export const getTagRecipes: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const { includeDeleted } = req.query; + const { limit, offset } = parsePagination(req.query as Record, 50); + + const tag = await prisma.tag.findUnique({ where: { id } }); + if (!tag) { + throw createHttpError(404, ADMIN_REC_001); + } + + const deletedFilter = includeDeleted === "true" ? {} : { deletedAt: null }; + + const [recipeTags, total] = await Promise.all([ + prisma.recipeTag.findMany({ + where: { + tagId: id, + recipe: deletedFilter, + }, + include: { + recipe: { + select: { + id: true, + title: true, + createdAt: true, + deletedAt: true, + creator: { select: { id: true, username: true } }, + community: { select: { id: true, name: true } }, + }, + }, + }, + skip: offset, + take: limit, + orderBy: { recipe: { createdAt: "desc" } }, + }), + prisma.recipeTag.count({ + where: { + tagId: id, + recipe: deletedFilter, + }, + }), + ]); + + res.status(200).json({ + recipes: recipeTags.map((rt) => rt.recipe), + pagination: buildPaginationMeta(total, limit, offset, recipeTags.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/admin/recipes/:recipeId + * Detail complet d'une recette + */ +export const getDetail: RequestHandler = async (req, res, next) => { + try { + const { recipeId } = req.params; + + const recipe = await prisma.recipe.findUnique({ + where: { id: recipeId }, + include: { + ...RECIPE_DETAIL_INCLUDE, + community: { select: { id: true, name: true } }, + }, + }); + + if (!recipe) { + throw createHttpError(404, ADMIN_REC_002); + } + + res.status(200).json({ recipe }); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/admin/recipes/:recipeId + * Modification des champs scalaires d'une recette + */ +export const update: RequestHandler = async (req, res, next) => { + try { + const { recipeId } = req.params; + const { title, servings, prepTime, cookTime, restTime } = req.body as AdminUpdateRecipeInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const recipe = await prisma.recipe.findUnique({ where: { id: recipeId } }); + if (!recipe) { + throw createHttpError(404, ADMIN_REC_002); + } + + const data: Record = {}; + if (title !== undefined) data.title = title; + if (servings !== undefined) data.servings = servings; + if (prepTime !== undefined) data.prepTime = prepTime; + if (cookTime !== undefined) data.cookTime = cookTime; + if (restTime !== undefined) data.restTime = restTime; + + const updated = await prisma.recipe.update({ + where: { id: recipeId }, + data, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "RECIPE_UPDATED", + targetType: "Recipe", + targetId: recipeId, + metadata: { + changes: data as Record, + oldTitle: recipe.title, + }, + }, + }); + + res.status(200).json({ recipe: updated }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/admin/recipes/:recipeId + * Soft delete d'une recette + */ +export const remove: RequestHandler = async (req, res, next) => { + try { + const { recipeId } = req.params; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const recipe = await prisma.recipe.findUnique({ where: { id: recipeId } }); + if (!recipe) { + throw createHttpError(404, ADMIN_REC_002); + } + + if (recipe.deletedAt) { + throw createHttpError(400, ADMIN_REC_003); + } + + await prisma.recipe.update({ + where: { id: recipeId }, + data: { deletedAt: new Date() }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "RECIPE_DELETED", + targetType: "Recipe", + targetId: recipeId, + metadata: { title: recipe.title }, + }, + }); + + res.status(200).json({ message: "Recipe deleted" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/admin/controllers/tagsController.ts b/backend/src/admin/controllers/tagsController.ts index 157a05fd..2a62def4 100644 --- a/backend/src/admin/controllers/tagsController.ts +++ b/backend/src/admin/controllers/tagsController.ts @@ -2,6 +2,18 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../../util/db"; import { assertIsDefine } from "../../util/assertIsDefine"; +import { parsePagination, buildPaginationMeta } from "../../util/pagination"; +import { + ADMIN_TAG_002, + ADMIN_TAG_003, + ADMIN_TAG_005, + ADMIN_TAG_006, +} from "../../constants/errorCodes"; +import { + AdminCreateTagInput, + AdminUpdateTagInput, + AdminMergeTagInput, +} from "../schemas/tag.schema"; /** * GET /api/admin/tags @@ -9,24 +21,72 @@ import { assertIsDefine } from "../../util/assertIsDefine"; */ export const getAll: RequestHandler = async (req, res, next) => { try { - const { search } = req.query; - - const tags = await prisma.tag.findMany({ - where: search - ? { name: { contains: String(search), mode: "insensitive" } } - : undefined, - include: { - _count: { select: { recipes: true } }, - }, - orderBy: { name: "asc" }, - }); + const { search, scope } = req.query; + const { limit, offset } = parsePagination(req.query as Record, 100); + + 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, total] = await Promise.all([ + prisma.tag.findMany({ + where, + include: { + _count: { + select: { + recipes: { + where: { + recipe: { deletedAt: null }, + }, + }, + }, + }, + community: { select: { id: true, name: true } }, + }, + orderBy: { name: "asc" }, + skip: offset, + take: limit, + }), + prisma.tag.count({ where }), + ]); + + // Pour les tags COMMUNITY, ne compter que les recettes dans la communaute + const tagIds = tags.filter((t) => t.communityId).map((t) => t.id); + const communityCountsRaw = + tagIds.length > 0 + ? await prisma.recipeTag.groupBy({ + by: ["tagId"], + where: { + tagId: { in: tagIds }, + recipe: { + deletedAt: null, + communityId: { not: null }, + }, + }, + _count: { tagId: true }, + }) + : []; + const communityCountMap = new Map(communityCountsRaw.map((c) => [c.tagId, c._count.tagId])); res.status(200).json({ tags: tags.map((t) => ({ id: t.id, name: t.name, - recipeCount: t._count.recipes, + scope: t.scope, + status: t.status, + communityId: t.communityId, + community: t.community, + recipeCount: t.communityId ? (communityCountMap.get(t.id) ?? 0) : t._count.recipes, })), + pagination: buildPaginationMeta(total, limit, offset, tags.length), }); } catch (error) { next(error); @@ -39,26 +99,20 @@ export const getAll: RequestHandler = async (req, res, next) => { */ export const create: RequestHandler = async (req, res, next) => { try { - const { name } = req.body; + const { name } = req.body as AdminCreateTagInput; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!name || typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_TAG_001: Name is required"); - } - - const normalized = name.trim().toLowerCase(); - - const existing = await prisma.tag.findUnique({ - where: { name: normalized }, + const existing = await prisma.tag.findFirst({ + where: { name, communityId: null }, }); if (existing) { - throw createHttpError(409, "ADMIN_TAG_002: Tag already exists"); + throw createHttpError(409, ADMIN_TAG_002); } const tag = await prisma.tag.create({ - data: { name: normalized }, + data: { name }, }); await prisma.adminActivityLog.create({ @@ -67,7 +121,7 @@ export const create: RequestHandler = async (req, res, next) => { type: "TAG_CREATED", targetType: "Tag", targetId: tag.id, - metadata: { name: normalized }, + metadata: { name }, }, }); @@ -84,34 +138,29 @@ export const create: RequestHandler = async (req, res, next) => { export const update: RequestHandler = async (req, res, next) => { try { const { id } = req.params; - const { name } = req.body; + const { name } = req.body as AdminUpdateTagInput; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!name || typeof name !== "string" || name.trim().length === 0) { - throw createHttpError(400, "ADMIN_TAG_001: Name is required"); - } - const tag = await prisma.tag.findUnique({ where: { id } }); if (!tag) { - throw createHttpError(404, "ADMIN_TAG_003: Tag not found"); + throw createHttpError(404, ADMIN_TAG_003); } - const normalized = name.trim().toLowerCase(); - - if (normalized !== tag.name) { - const existing = await prisma.tag.findUnique({ - where: { name: normalized }, + if (name !== tag.name) { + // Verifier unicite dans le meme scope + const existing = await prisma.tag.findFirst({ + where: { name, communityId: tag.communityId, id: { not: tag.id } }, }); if (existing) { - throw createHttpError(409, "ADMIN_TAG_002: Tag already exists"); + throw createHttpError(409, ADMIN_TAG_002); } } const oldName = tag.name; const updated = await prisma.tag.update({ where: { id }, - data: { name: normalized }, + data: { name }, }); await prisma.adminActivityLog.create({ @@ -120,7 +169,7 @@ export const update: RequestHandler = async (req, res, next) => { type: "TAG_UPDATED", targetType: "Tag", targetId: id, - metadata: { oldName, newName: normalized }, + metadata: { oldName, newName: name }, }, }); @@ -142,7 +191,7 @@ export const remove: RequestHandler = async (req, res, next) => { const tag = await prisma.tag.findUnique({ where: { id } }); if (!tag) { - throw createHttpError(404, "ADMIN_TAG_003: Tag not found"); + throw createHttpError(404, ADMIN_TAG_003); } await prisma.tag.delete({ where: { id } }); @@ -171,16 +220,12 @@ export const remove: RequestHandler = async (req, res, next) => { export const merge: RequestHandler = async (req, res, next) => { try { const { id: sourceId } = req.params; - const { targetId } = req.body; + const { targetId } = req.body as AdminMergeTagInput; const adminId = req.session.adminId; assertIsDefine(adminId); - if (!targetId) { - throw createHttpError(400, "ADMIN_TAG_004: Target tag ID required"); - } - if (sourceId === targetId) { - throw createHttpError(400, "ADMIN_TAG_005: Cannot merge tag into itself"); + throw createHttpError(400, ADMIN_TAG_005); } const [source, target] = await Promise.all([ @@ -189,10 +234,10 @@ export const merge: RequestHandler = async (req, res, next) => { ]); if (!source) { - throw createHttpError(404, "ADMIN_TAG_003: Source tag not found"); + throw createHttpError(404, ADMIN_TAG_003); } if (!target) { - throw createHttpError(404, "ADMIN_TAG_006: Target tag not found"); + throw createHttpError(404, ADMIN_TAG_006); } // Transferer les recettes du source vers le target diff --git a/backend/src/admin/controllers/unitsController.ts b/backend/src/admin/controllers/unitsController.ts new file mode 100644 index 00000000..e38da46a --- /dev/null +++ b/backend/src/admin/controllers/unitsController.ts @@ -0,0 +1,236 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import prisma from "../../util/db"; +import { assertIsDefine } from "../../util/assertIsDefine"; +import { + ADMIN_UNIT_004, + ADMIN_UNIT_005, + ADMIN_UNIT_006, + ADMIN_UNIT_007, +} from "../../constants/errorCodes"; +import { AdminCreateUnitInput, AdminUpdateUnitInput } from "../schemas/unit.schema"; + +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 as AdminCreateUnitInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + // Verifier unicite nom + const existingName = await prisma.unit.findUnique({ where: { name } }); + if (existingName) { + throw createHttpError(409, ADMIN_UNIT_004); + } + + // Verifier unicite abbreviation + const existingAbbr = await prisma.unit.findUnique({ where: { abbreviation } }); + if (existingAbbr) { + throw createHttpError(409, ADMIN_UNIT_005); + } + + const unit = await prisma.unit.create({ + data: { + name, + abbreviation, + category, + sortOrder, + }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "UNIT_CREATED", + targetType: "Unit", + targetId: unit.id, + metadata: { name, abbreviation, 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 as AdminUpdateUnitInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const unit = await prisma.unit.findUnique({ where: { id } }); + if (!unit) { + throw createHttpError(404, ADMIN_UNIT_006); + } + + const data: Record = {}; + const metadata: Record = {}; + + if (name !== undefined && name !== unit.name) { + const existing = await prisma.unit.findUnique({ where: { name } }); + if (existing) { + throw createHttpError(409, ADMIN_UNIT_004); + } + metadata.oldName = unit.name; + metadata.newName = name; + data.name = name; + } + + if (abbreviation !== undefined && abbreviation !== unit.abbreviation) { + const existing = await prisma.unit.findUnique({ where: { abbreviation } }); + if (existing) { + throw createHttpError(409, ADMIN_UNIT_005); + } + metadata.oldAbbreviation = unit.abbreviation; + metadata.newAbbreviation = abbreviation; + data.abbreviation = abbreviation; + } + + if (category !== undefined) { + data.category = category; + } + + if (sortOrder !== undefined) { + 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); + } + + const totalUsage = + unit._count.recipeIngredients + + unit._count.proposalIngredients + + unit._count.defaultIngredients; + if (totalUsage > 0) { + throw createHttpError(409, ADMIN_UNIT_007); + } + + 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/middleware/requireSuperAdmin.ts b/backend/src/admin/middleware/requireSuperAdmin.ts index fd13c735..40129389 100644 --- a/backend/src/admin/middleware/requireSuperAdmin.ts +++ b/backend/src/admin/middleware/requireSuperAdmin.ts @@ -1,5 +1,6 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; +import { ADMIN_001, ADMIN_002 } from "../../constants/errorCodes"; /** * Middleware pour proteger les routes admin. @@ -9,13 +10,13 @@ import createHttpError from "http-errors"; */ export const requireSuperAdmin: RequestHandler = (req, res, next) => { if (!req.session.adminId) { - return next(createHttpError(401, "ADMIN_001: Not authenticated")); + return next(createHttpError(401, ADMIN_001)); } - + if (!req.session.totpVerified) { - return next(createHttpError(401, "ADMIN_002: TOTP not verified")); + return next(createHttpError(401, ADMIN_002)); } - + next(); }; @@ -25,8 +26,8 @@ export const requireSuperAdmin: RequestHandler = (req, res, next) => { */ export const requireAdminSession: RequestHandler = (req, res, next) => { if (!req.session.adminId) { - return next(createHttpError(401, "ADMIN_001: Not authenticated")); + return next(createHttpError(401, ADMIN_001)); } - + next(); }; diff --git a/backend/src/admin/routes/authRoutes.ts b/backend/src/admin/routes/authRoutes.ts index db776b39..2b7e9341 100644 --- a/backend/src/admin/routes/authRoutes.ts +++ b/backend/src/admin/routes/authRoutes.ts @@ -1,29 +1,32 @@ -import express, { RequestHandler } from "express"; -import rateLimit from "express-rate-limit"; +import express from "express"; import * as authController from "../controllers/authController"; import { requireAdminSession, requireSuperAdmin } from "../middleware/requireSuperAdmin"; -import env from "../../util/validateEnv"; +import { ADMIN_010 } from "../../constants/errorCodes"; +import { createRateLimiter } from "../../config/rateLimiter"; +import { validateBody } from "../../middleware/validateBody"; +import { adminLoginSchema, verifyTotpSchema } from "../schemas/auth.schema"; const router = express.Router(); -// Rate limiter pour les routes d'auth admin (5 tentatives / 15min) -// Desactive en mode test pour permettre l'execution des tests -const adminAuthLimiter: RequestHandler = env.NODE_ENV === "test" - ? ((_req, _res, next) => next()) - : rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // 5 tentatives max - message: { error: "ADMIN_010: Too many login attempts, please try again later" }, - standardHeaders: true, - legacyHeaders: false, - }); +/** Rate limiter admin auth : 5 req / 15 min */ +const adminAuthLimiter = createRateLimiter({ + windowMs: 15 * 60 * 1000, + max: 5, + message: ADMIN_010, +}); // POST /api/admin/auth/login - Premiere etape (email/password) -router.post("/login", adminAuthLimiter, authController.login); +router.post("/login", adminAuthLimiter, validateBody(adminLoginSchema), authController.login); // POST /api/admin/auth/totp/verify - Deuxieme etape (TOTP) // Necessite une session admin initiee (apres login) -router.post("/totp/verify", adminAuthLimiter, requireAdminSession, authController.verifyTotp); +router.post( + "/totp/verify", + adminAuthLimiter, + requireAdminSession, + validateBody(verifyTotpSchema), + authController.verifyTotp +); // POST /api/admin/auth/logout - Deconnexion router.post("/logout", authController.logout); diff --git a/backend/src/admin/routes/communitiesRoutes.ts b/backend/src/admin/routes/communitiesRoutes.ts index e74babda..a285c100 100644 --- a/backend/src/admin/routes/communitiesRoutes.ts +++ b/backend/src/admin/routes/communitiesRoutes.ts @@ -1,6 +1,9 @@ import express from "express"; import * as communitiesController from "../controllers/communitiesController"; import * as featuresController from "../controllers/featuresController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { adminUpdateCommunitySchema } from "../schemas/community.schema"; const router = express.Router(); @@ -8,18 +11,23 @@ const router = express.Router(); router.get("/", communitiesController.getAll); // GET /api/admin/communities/:id - Detail d'une communaute -router.get("/:id", communitiesController.getOne); +router.get("/:id", validateUUID, communitiesController.getOne); // PATCH /api/admin/communities/:id - Modifie une communaute -router.patch("/:id", communitiesController.update); +router.patch( + "/:id", + validateUUID, + validateBody(adminUpdateCommunitySchema), + communitiesController.update +); // DELETE /api/admin/communities/:id - Soft delete une communaute -router.delete("/:id", communitiesController.remove); +router.delete("/:id", validateUUID, communitiesController.remove); // POST /api/admin/communities/:communityId/features/:featureId - Attribue une feature -router.post("/:communityId/features/:featureId", featuresController.grant); +router.post("/:communityId/features/:featureId", validateUUID, featuresController.grant); // DELETE /api/admin/communities/:communityId/features/:featureId - Revoque une feature -router.delete("/:communityId/features/:featureId", featuresController.revoke); +router.delete("/:communityId/features/:featureId", validateUUID, featuresController.revoke); export default router; diff --git a/backend/src/admin/routes/featuresRoutes.ts b/backend/src/admin/routes/featuresRoutes.ts index cd0be6a4..68412cd1 100644 --- a/backend/src/admin/routes/featuresRoutes.ts +++ b/backend/src/admin/routes/featuresRoutes.ts @@ -1,5 +1,7 @@ import express from "express"; import * as featuresController from "../controllers/featuresController"; +import { validateBody } from "../../middleware/validateBody"; +import { adminCreateFeatureSchema, adminUpdateFeatureSchema } from "../schemas/feature.schema"; const router = express.Router(); @@ -7,9 +9,9 @@ const router = express.Router(); router.get("/", featuresController.getAll); // POST /api/admin/features - Cree une feature -router.post("/", featuresController.create); +router.post("/", validateBody(adminCreateFeatureSchema), featuresController.create); // PATCH /api/admin/features/:id - Modifie une feature -router.patch("/:id", featuresController.update); +router.patch("/:id", validateBody(adminUpdateFeatureSchema), featuresController.update); export default router; diff --git a/backend/src/admin/routes/ingredientsRoutes.ts b/backend/src/admin/routes/ingredientsRoutes.ts index 383211c2..858bd7b9 100644 --- a/backend/src/admin/routes/ingredientsRoutes.ts +++ b/backend/src/admin/routes/ingredientsRoutes.ts @@ -1,5 +1,14 @@ import express from "express"; import * as ingredientsController from "../controllers/ingredientsController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { + adminCreateIngredientSchema, + adminUpdateIngredientSchema, + adminApproveIngredientSchema, + adminRejectIngredientSchema, + adminMergeIngredientSchema, +} from "../schemas/ingredient.schema"; const router = express.Router(); @@ -7,15 +16,41 @@ const router = express.Router(); router.get("/", ingredientsController.getAll); // POST /api/admin/ingredients - Cree un ingredient -router.post("/", ingredientsController.create); +router.post("/", validateBody(adminCreateIngredientSchema), ingredientsController.create); -// PATCH /api/admin/ingredients/:id - Renomme un ingredient -router.patch("/:id", ingredientsController.update); +// PATCH /api/admin/ingredients/:id - Modifie un ingredient +router.patch( + "/:id", + validateUUID, + validateBody(adminUpdateIngredientSchema), + ingredientsController.update +); // DELETE /api/admin/ingredients/:id - Supprime un ingredient -router.delete("/:id", ingredientsController.remove); +router.delete("/:id", validateUUID, ingredientsController.remove); // POST /api/admin/ingredients/:id/merge - Fusionne un ingredient dans un autre -router.post("/:id/merge", ingredientsController.merge); +router.post( + "/:id/merge", + validateUUID, + validateBody(adminMergeIngredientSchema), + ingredientsController.merge +); + +// POST /api/admin/ingredients/:id/approve - Approuve un ingredient PENDING +router.post( + "/:id/approve", + validateUUID, + validateBody(adminApproveIngredientSchema), + ingredientsController.approve +); + +// POST /api/admin/ingredients/:id/reject - Rejette un ingredient PENDING +router.post( + "/:id/reject", + validateUUID, + validateBody(adminRejectIngredientSchema), + ingredientsController.reject +); export default router; diff --git a/backend/src/admin/routes/recipesRoutes.ts b/backend/src/admin/routes/recipesRoutes.ts new file mode 100644 index 00000000..92dd349e --- /dev/null +++ b/backend/src/admin/routes/recipesRoutes.ts @@ -0,0 +1,23 @@ +import express from "express"; +import * as recipesController from "../controllers/recipesController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { adminUpdateRecipeSchema } from "../schemas/recipe.schema"; + +const router = express.Router(); + +// GET /api/admin/recipes/:recipeId - Detail complet d'une recette +router.get("/:recipeId", validateUUID, recipesController.getDetail); + +// PATCH /api/admin/recipes/:recipeId - Modifier une recette +router.patch( + "/:recipeId", + validateUUID, + validateBody(adminUpdateRecipeSchema), + recipesController.update +); + +// DELETE /api/admin/recipes/:recipeId - Soft delete une recette +router.delete("/:recipeId", validateUUID, recipesController.remove); + +export default router; diff --git a/backend/src/admin/routes/tagsRoutes.ts b/backend/src/admin/routes/tagsRoutes.ts index f4719eac..345cd034 100644 --- a/backend/src/admin/routes/tagsRoutes.ts +++ b/backend/src/admin/routes/tagsRoutes.ts @@ -1,5 +1,13 @@ import express from "express"; import * as tagsController from "../controllers/tagsController"; +import * as recipesController from "../controllers/recipesController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { + adminCreateTagSchema, + adminUpdateTagSchema, + adminMergeTagSchema, +} from "../schemas/tag.schema"; const router = express.Router(); @@ -7,15 +15,18 @@ const router = express.Router(); router.get("/", tagsController.getAll); // POST /api/admin/tags - Cree un tag -router.post("/", tagsController.create); +router.post("/", validateBody(adminCreateTagSchema), tagsController.create); // PATCH /api/admin/tags/:id - Renomme un tag -router.patch("/:id", tagsController.update); +router.patch("/:id", validateUUID, validateBody(adminUpdateTagSchema), tagsController.update); // DELETE /api/admin/tags/:id - Supprime un tag -router.delete("/:id", tagsController.remove); +router.delete("/:id", validateUUID, tagsController.remove); + +// GET /api/admin/tags/:id/recipes - Liste les recettes d'un tag +router.get("/:id/recipes", validateUUID, recipesController.getTagRecipes); // POST /api/admin/tags/:id/merge - Fusionne un tag dans un autre -router.post("/:id/merge", tagsController.merge); +router.post("/:id/merge", validateUUID, validateBody(adminMergeTagSchema), tagsController.merge); export default router; diff --git a/backend/src/admin/routes/unitsRoutes.ts b/backend/src/admin/routes/unitsRoutes.ts new file mode 100644 index 00000000..8875e9d5 --- /dev/null +++ b/backend/src/admin/routes/unitsRoutes.ts @@ -0,0 +1,21 @@ +import express from "express"; +import * as unitsController from "../controllers/unitsController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { adminCreateUnitSchema, adminUpdateUnitSchema } from "../schemas/unit.schema"; + +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("/", validateBody(adminCreateUnitSchema), unitsController.create); + +// PATCH /api/admin/units/:id - Modifie une unite +router.patch("/:id", validateUUID, validateBody(adminUpdateUnitSchema), unitsController.update); + +// DELETE /api/admin/units/:id - Supprime une unite +router.delete("/:id", validateUUID, unitsController.remove); + +export default router; diff --git a/backend/src/admin/schemas/auth.schema.ts b/backend/src/admin/schemas/auth.schema.ts new file mode 100644 index 00000000..919252e2 --- /dev/null +++ b/backend/src/admin/schemas/auth.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { ADMIN_003, ADMIN_005, VALIDATION_001_TYPE } from "../../constants/errorCodes"; + +/** Schema for admin login (email + password) */ +export const adminLoginSchema = z.object({ + email: z.string({ error: () => ADMIN_003 }).min(1, ADMIN_003), + password: z.string({ error: () => ADMIN_003 }).min(1, ADMIN_003), +}); + +/** Schema for TOTP verification */ +export const verifyTotpSchema = z.object({ + code: z.string({ error: () => VALIDATION_001_TYPE }).min(1, ADMIN_005), +}); + +export type AdminLoginInput = z.infer; +export type VerifyTotpInput = z.infer; diff --git a/backend/src/admin/schemas/community.schema.ts b/backend/src/admin/schemas/community.schema.ts new file mode 100644 index 00000000..77379680 --- /dev/null +++ b/backend/src/admin/schemas/community.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { COMMUNITY_VALIDATION } from "../../util/validation"; +import { ADMIN_COM_002 } from "../../constants/errorCodes"; + +/** Schema for updating a community via admin */ +export const adminUpdateCommunitySchema = z.object({ + name: z + .string({ error: () => ADMIN_COM_002 }) + .min(COMMUNITY_VALIDATION.NAME_MIN, ADMIN_COM_002) + .max(COMMUNITY_VALIDATION.NAME_MAX, ADMIN_COM_002) + .transform((val) => val.trim()), +}); + +export type AdminUpdateCommunityInput = z.infer; diff --git a/backend/src/admin/schemas/feature.schema.ts b/backend/src/admin/schemas/feature.schema.ts new file mode 100644 index 00000000..3d72bc64 --- /dev/null +++ b/backend/src/admin/schemas/feature.schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { ADMIN_FEAT_001, ADMIN_FEAT_002 } from "../../constants/errorCodes"; + +/** Schema for creating a feature */ +export const adminCreateFeatureSchema = z.object({ + code: z + .string({ error: () => ADMIN_FEAT_001 }) + .min(1, ADMIN_FEAT_001) + .transform((val) => val.trim().toUpperCase().replace(/\s+/g, "_")), + name: z + .string({ error: () => ADMIN_FEAT_002 }) + .min(1, ADMIN_FEAT_002) + .transform((val) => val.trim()), + description: z + .string() + .transform((val) => val?.trim() || null) + .optional(), + isDefault: z.boolean().optional().default(false), +}); + +/** Schema for updating a feature */ +export const adminUpdateFeatureSchema = z.object({ + name: z + .string({ error: () => ADMIN_FEAT_002 }) + .min(1, ADMIN_FEAT_002) + .transform((val) => val.trim()) + .optional(), + description: z + .string() + .transform((val) => val?.trim() || null) + .optional(), + isDefault: z.boolean().optional(), +}); + +export type AdminCreateFeatureInput = z.infer; +export type AdminUpdateFeatureInput = z.infer; diff --git a/backend/src/admin/schemas/ingredient.schema.ts b/backend/src/admin/schemas/ingredient.schema.ts new file mode 100644 index 00000000..f00c4861 --- /dev/null +++ b/backend/src/admin/schemas/ingredient.schema.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { ADMIN_ING_001, ADMIN_ING_004, ADMIN_ING_009 } from "../../constants/errorCodes"; +import { MAX_NAME_LENGTH, MAX_REASON_LENGTH } from "../../util/validation"; + +const uuidSchema = z.string().uuid(); + +const ingredientNameSchema = z + .string({ message: ADMIN_ING_001 }) + .min(1, ADMIN_ING_001) + .max(MAX_NAME_LENGTH, ADMIN_ING_001) + .transform((val) => val.trim().toLowerCase()); + +/** Schema for creating an ingredient */ +export const adminCreateIngredientSchema = z.object({ + name: ingredientNameSchema, + defaultUnitId: uuidSchema.optional(), +}); + +/** Schema for updating an ingredient */ +export const adminUpdateIngredientSchema = z.object({ + name: ingredientNameSchema.optional(), + defaultUnitId: uuidSchema.nullable().optional(), +}); + +/** Schema for approving an ingredient (optionally rename) */ +export const adminApproveIngredientSchema = z.object({ + newName: z + .string() + .min(1) + .max(MAX_NAME_LENGTH) + .transform((val) => val.trim().toLowerCase()) + .optional(), +}); + +/** Schema for rejecting an ingredient */ +export const adminRejectIngredientSchema = z.object({ + reason: z + .string({ message: ADMIN_ING_009 }) + .min(1, ADMIN_ING_009) + .max(MAX_REASON_LENGTH, ADMIN_ING_009) + .transform((val) => val.trim()), +}); + +/** Schema for merging an ingredient into another */ +export const adminMergeIngredientSchema = z.object({ + targetId: uuidSchema.refine((val) => val.length > 0, { message: ADMIN_ING_004 }), +}); + +export type AdminCreateIngredientInput = z.infer; +export type AdminUpdateIngredientInput = z.infer; +export type AdminApproveIngredientInput = z.infer; +export type AdminRejectIngredientInput = z.infer; +export type AdminMergeIngredientInput = z.infer; diff --git a/backend/src/admin/schemas/recipe.schema.ts b/backend/src/admin/schemas/recipe.schema.ts new file mode 100644 index 00000000..ba0211d1 --- /dev/null +++ b/backend/src/admin/schemas/recipe.schema.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { RECIPE_003, RECIPE_006, RECIPE_008 } from "../../constants/errorCodes"; +import { MAX_TITLE_LENGTH } from "../../util/validation"; + +/** Schema for admin updating a recipe */ +export const adminUpdateRecipeSchema = z.object({ + title: z + .string({ message: RECIPE_003 }) + .min(1, RECIPE_003) + .max(MAX_TITLE_LENGTH, RECIPE_003) + .transform((val) => val.trim()) + .optional(), + servings: z + .number({ message: RECIPE_006 }) + .int(RECIPE_006) + .min(1, RECIPE_006) + .max(100, RECIPE_006) + .optional(), + prepTime: z + .number({ message: RECIPE_008 }) + .int(RECIPE_008) + .min(0, RECIPE_008) + .max(10000, RECIPE_008) + .optional(), + cookTime: z + .number({ message: RECIPE_008 }) + .int(RECIPE_008) + .min(0, RECIPE_008) + .max(10000, RECIPE_008) + .optional(), + restTime: z + .number({ message: RECIPE_008 }) + .int(RECIPE_008) + .min(0, RECIPE_008) + .max(10000, RECIPE_008) + .optional(), +}); + +export type AdminUpdateRecipeInput = z.infer; diff --git a/backend/src/admin/schemas/tag.schema.ts b/backend/src/admin/schemas/tag.schema.ts new file mode 100644 index 00000000..7eb9a9f9 --- /dev/null +++ b/backend/src/admin/schemas/tag.schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { ADMIN_TAG_001, ADMIN_TAG_001_LENGTH, ADMIN_TAG_004 } from "../../constants/errorCodes"; +import { uuidSchema } from "../../schemas/common.schema"; + +/** Validation du nom de tag: 2-50 caracteres, normalise lowercase */ +const adminTagNameSchema = z + .string({ error: () => ADMIN_TAG_001 }) + .min(1, ADMIN_TAG_001) + .max(50, ADMIN_TAG_001_LENGTH) + .transform((val) => val.trim().toLowerCase()) + .refine((val) => val.length >= 2, { message: ADMIN_TAG_001_LENGTH }); + +/** Schema for creating an admin tag */ +export const adminCreateTagSchema = z.object({ + name: adminTagNameSchema, +}); + +/** Schema for updating an admin tag */ +export const adminUpdateTagSchema = z.object({ + name: adminTagNameSchema, +}); + +/** Schema for merging tags */ +export const adminMergeTagSchema = z.object({ + targetId: uuidSchema.refine((val) => val.trim().length > 0, { message: ADMIN_TAG_004 }), +}); + +export type AdminCreateTagInput = z.infer; +export type AdminUpdateTagInput = z.infer; +export type AdminMergeTagInput = z.infer; diff --git a/backend/src/admin/schemas/unit.schema.ts b/backend/src/admin/schemas/unit.schema.ts new file mode 100644 index 00000000..80b4f192 --- /dev/null +++ b/backend/src/admin/schemas/unit.schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { + ADMIN_UNIT_001, + ADMIN_UNIT_002, + ADMIN_UNIT_003, + VALIDATION_001, +} from "../../constants/errorCodes"; + +const VALID_CATEGORIES = ["WEIGHT", "VOLUME", "SPOON", "COUNT", "QUALITATIVE"] as const; + +const unitNameSchema = z + .string({ message: ADMIN_UNIT_001 }) + .min(1, ADMIN_UNIT_001) + .max(50, ADMIN_UNIT_001) + .transform((val) => val.trim().toLowerCase()); + +const abbreviationSchema = z + .string({ message: ADMIN_UNIT_002 }) + .min(1, ADMIN_UNIT_002) + .max(10, ADMIN_UNIT_002) + .transform((val) => val.trim().toLowerCase()); + +const categorySchema = z.enum(VALID_CATEGORIES, { message: ADMIN_UNIT_003 }); + +const sortOrderSchema = z + .number({ message: VALIDATION_001("sortOrder must be a number") }) + .int(VALIDATION_001("sortOrder must be an integer")) + .min(0, VALIDATION_001("sortOrder must be >= 0")) + .max(9999, VALIDATION_001("sortOrder must be <= 9999")) + .optional() + .default(0); + +/** Schema for creating a unit */ +export const adminCreateUnitSchema = z.object({ + name: unitNameSchema, + abbreviation: abbreviationSchema, + category: categorySchema, + sortOrder: sortOrderSchema, +}); + +/** Schema for updating a unit */ +export const adminUpdateUnitSchema = z.object({ + name: unitNameSchema.optional(), + abbreviation: abbreviationSchema.optional(), + category: categorySchema.optional(), + sortOrder: z.number().int().min(0).max(9999).optional(), +}); + +export type AdminCreateUnitInput = z.infer; +export type AdminUpdateUnitInput = z.infer; diff --git a/backend/src/app.ts b/backend/src/app.ts index 611d723f..2e33204f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,12 +1,29 @@ -import express, { NextFunction, Request, Response } from "express"; +import express from "express"; +import createHttpError from "http-errors"; +import cors from "cors"; +import env from "./util/validateEnv"; +import { httpLogger } from "./middleware/httpLogger"; +import { helmetMiddleware, adminRateLimiter, requireHttps } from "./middleware/security"; +import { csrfProtection } from "./middleware/csrf"; +import { requireAuth } from "./middleware/auth"; +import { requireSuperAdmin } from "./admin/middleware/requireSuperAdmin"; +import { userSession, adminSession } from "./config/session"; +import { errorHandler } from "./middleware/errorHandler"; + +// User routes 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"; import proposalsRoutes from "./routes/proposals"; +import tagSuggestionsRoutes from "./routes/tagSuggestions"; +import notificationsRoutes from "./routes/notifications"; + +// Admin routes import adminAuthRoutes from "./admin/routes/authRoutes"; import adminTagsRoutes from "./admin/routes/tagsRoutes"; import adminIngredientsRoutes from "./admin/routes/ingredientsRoutes"; @@ -14,98 +31,38 @@ 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 createHttpError, { isHttpError } from "http-errors"; -import { httpLogger } from "./middleware/httpLogger"; -import logger from "./util/logger"; -import cors from "cors"; -import session from "express-session"; -import env from "./util/validateEnv"; -import { PrismaSessionStore } from '@quixo3/prisma-session-store'; -import { requireAuth } from "./middleware/auth"; -import { requireSuperAdmin } from "./admin/middleware/requireSuperAdmin"; -import { helmetMiddleware, adminRateLimiter, requireHttps } from "./middleware/security"; -import prisma from "./util/db"; +import adminUnitsRoutes from "./admin/routes/unitsRoutes"; +import adminRecipesRoutes from "./admin/routes/recipesRoutes"; const app = express(); -// Security middlewares -app.use(requireHttps); // Force HTTPS en production -app.use(helmetMiddleware); // Headers de securite (CSP, X-Frame-Options, etc.) - -// Trust proxy for production (behind Traefik) -if (env.NODE_ENV === "production") { - app.set("trust proxy", 1); -} - -// CORS needed for dev environment (in prod, nginx proxy handles same-origin) -if (env.CORS_ORIGIN) { - app.use(cors({ credentials: true, origin: env.CORS_ORIGIN })); -} - +// Global middleware +app.use(requireHttps); +app.use(helmetMiddleware); +if (env.NODE_ENV === "production") app.set("trust proxy", 1); +if (env.CORS_ORIGIN) app.use(cors({ credentials: true, origin: env.CORS_ORIGIN })); app.use(httpLogger); +app.use(express.json({ limit: "50kb" })); +app.use(csrfProtection); -app.use(express.json()); - -// User session middleware (cookie: connect.sid, duree: 1h) -export const userSession = session({ - name: "connect.sid", - secret: env.SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 60 * 60 * 1000, // 1 hour - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "lax", - }, - rolling: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - store: new PrismaSessionStore(prisma as any, { - checkPeriod: 2 * 60 * 1000, - dbRecordIdIsSessionId: true, - dbRecordIdFunction: undefined, - }), -}); - -// Admin session middleware (cookie: admin.sid, duree: 30min) -const adminSession = session({ - name: "admin.sid", - secret: env.ADMIN_SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 30 * 60 * 1000, // 30 minutes - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict", - }, - rolling: false, // Pas de renouvellement automatique pour admin - // eslint-disable-next-line @typescript-eslint/no-explicit-any - store: new PrismaSessionStore(prisma as any, { - checkPeriod: 2 * 60 * 1000, - dbRecordIdIsSessionId: true, - dbRecordIdFunction: undefined, - sessionModelName: "AdminSession", - }), -}); +// Health check +app.get("/health", (_req, res) => res.status(200).json({ status: "ok" })); -// Health check endpoint (before auth, no logging) -app.get("/health", (req, res) => { - res.status(200).json({ status: "ok" }); -}); - -// User routes (avec user session) +// User routes 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); app.use("/api/proposals", userSession, requireAuth, proposalsRoutes); +app.use("/api/tag-suggestions", userSession, requireAuth, tagSuggestionsRoutes); +app.use("/api/notifications", userSession, requireAuth, notificationsRoutes); -// Admin routes (avec admin session isolee + rate limiting global) -app.use("/api/admin", adminRateLimiter); // Rate limit global admin (30 req/min) +// Admin routes +app.use("/api/admin", adminRateLimiter); app.use("/api/admin/auth", adminSession, adminAuthRoutes); app.use("/api/admin/tags", adminSession, requireSuperAdmin, adminTagsRoutes); app.use("/api/admin/ingredients", adminSession, requireSuperAdmin, adminIngredientsRoutes); @@ -113,21 +70,12 @@ 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("/api/admin/recipes", adminSession, requireSuperAdmin, adminRecipesRoutes); -app.use((req, res, next) => { - next(createHttpError(404, "Endpoint not found")); -}); - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -app.use((error: unknown, req: Request, res: Response, next: NextFunction) => { - logger.error({ err: error, path: req.path, method: req.method }, "Unhandled error"); - let errorMessage = "An unknown error occurred"; - let statusCode = 500; - if (isHttpError(error)) { - statusCode = error.status; - errorMessage = error.message; - } - res.status(statusCode).json({ error: errorMessage }); -}); +// 404 + error handler +app.use((_req, _res, next) => next(createHttpError(404, "Endpoint not found"))); +app.use(errorHandler); -export default app; \ No newline at end of file +export { userSession }; +export default app; diff --git a/backend/src/config/rateLimiter.ts b/backend/src/config/rateLimiter.ts new file mode 100644 index 00000000..917ec373 --- /dev/null +++ b/backend/src/config/rateLimiter.ts @@ -0,0 +1,34 @@ +import rateLimit, { Options } from "express-rate-limit"; +import { RequestHandler } from "express"; +import env from "../util/validateEnv"; + +interface RateLimiterOptions { + windowMs: number; + max: number; + message: string; + skip?: Options["skip"]; +} + +/** + * Factory pour creer un rate limiter avec les options standard. + * Desactive automatiquement en mode test. + */ +export function createRateLimiter({ + windowMs, + max, + message, + skip, +}: RateLimiterOptions): RequestHandler { + if (env.NODE_ENV === "test") { + return (_req, _res, next) => next(); + } + + return rateLimit({ + windowMs, + max, + message: { error: message }, + standardHeaders: true, + legacyHeaders: false, + skip, + }); +} diff --git a/backend/src/config/session.ts b/backend/src/config/session.ts new file mode 100644 index 00000000..68296efa --- /dev/null +++ b/backend/src/config/session.ts @@ -0,0 +1,47 @@ +import session from "express-session"; +import { PrismaSessionStore } from "@quixo3/prisma-session-store"; +import env from "../util/validateEnv"; +import prisma from "../util/db"; + +/** User session (cookie: forestmanager_user_session, duree: 1h) */ +export const userSession = session({ + name: "forestmanager_user_session", + secret: env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 60 * 60 * 1000, // 1 hour + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + }, + rolling: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: new PrismaSessionStore(prisma as any, { + checkPeriod: 2 * 60 * 1000, + dbRecordIdIsSessionId: true, + dbRecordIdFunction: undefined, + }), +}); + +/** Admin session (cookie: forestmanager_admin_session, duree: 30min) */ +export const adminSession = session({ + name: "forestmanager_admin_session", + secret: env.ADMIN_SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 30 * 60 * 1000, // 30 minutes + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + }, + rolling: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: new PrismaSessionStore(prisma as any, { + checkPeriod: 2 * 60 * 1000, + dbRecordIdIsSessionId: true, + dbRecordIdFunction: undefined, + sessionModelName: "AdminSession", + }), +}); diff --git a/backend/src/config/storage.ts b/backend/src/config/storage.ts new file mode 100644 index 00000000..97eb2fd7 --- /dev/null +++ b/backend/src/config/storage.ts @@ -0,0 +1,22 @@ +import env from "../util/validateEnv"; + +export const storageConfig = { + endpoint: env.MINIO_ENDPOINT, + port: env.MINIO_PORT, + accessKey: env.MINIO_ACCESS_KEY, + secretKey: env.MINIO_SECRET_KEY, + bucket: env.MINIO_BUCKET, + publicUrl: env.MINIO_PUBLIC_URL, + useSSL: env.MINIO_USE_SSL, + presignedUrlTTL: 60, // secondes + maxFileSize: 2 * 1024 * 1024, // 2 MB + allowedMimeTypes: ["image/webp", "image/jpeg", "image/png"], +}; + +/** + * Construit l'URL publique d'une image a partir de sa cle relative. + * Ex: "recipes/abc/cover.webp" -> "http://localhost:9000/forestmanager-images-dev/recipes/abc/cover.webp" + */ +export function buildImageUrl(imageKey: string): string { + return `${storageConfig.publicUrl}/${storageConfig.bucket}/${imageKey}`; +} diff --git a/backend/src/constants/errorCodes.ts b/backend/src/constants/errorCodes.ts new file mode 100644 index 00000000..37a3ce08 --- /dev/null +++ b/backend/src/constants/errorCodes.ts @@ -0,0 +1,211 @@ +// ===================================== +// Auth +// ===================================== +export const AUTH_001 = "AUTH_001: Not authenticated"; +export const AUTH_002 = "AUTH_002: Missing required parameters"; +export const AUTH_003 = "AUTH_003: Invalid email format"; +export const AUTH_004_LENGTH = (min: number, max: number) => + `AUTH_004: Username must be between ${min} and ${max} characters`; +export const AUTH_004_FORMAT = + "AUTH_004: Username can only contain letters, numbers, and underscores"; +export const AUTH_005 = (min: number, max: number) => + `AUTH_005: Password must be between ${min} and ${max} characters`; +export const AUTH_006 = "AUTH_006: Username already taken"; +export const AUTH_007 = "AUTH_007: Email already in use"; +export const AUTH_008 = "AUTH_008: Invalid credentials"; +export const AUTH_009 = "AUTH_009: Account deactivated"; +export const AUTH_010 = "AUTH_010: Current password is required to change password"; +export const AUTH_011 = "AUTH_011: Current password is incorrect"; +export const AUTH_012 = "AUTH_012: Too many attempts, please try again later"; + +// ===================================== +// User +// ===================================== +export const USER_001 = "USER_001: User not found"; + +// ===================================== +// Community +// ===================================== +export const COMMUNITY_001 = "COMMUNITY_001: Not a member of this community"; +export const COMMUNITY_002 = "COMMUNITY_002: Permission insufficient"; +export const COMMUNITY_003 = + "COMMUNITY_003: Last moderator cannot leave. Promote another member first"; +export const COMMUNITY_004 = "COMMUNITY_004: User already member"; +export const COMMUNITY_005 = "COMMUNITY_005: Invitation already pending"; +export const COMMUNITY_006 = "COMMUNITY_006: Cannot remove a moderator"; + +// ===================================== +// Recipe +// ===================================== +export const RECIPE_001 = "RECIPE_001: Recipe not found"; +export const RECIPE_002 = "RECIPE_002: Cannot access this recipe"; +export const RECIPE_003 = "RECIPE_003: Title required"; +export const RECIPE_005 = (msg: string) => `RECIPE_005: ${msg}`; +export const RECIPE_006 = "RECIPE_006: Servings must be an integer between 1 and 100"; +export const RECIPE_007 = + "RECIPE_007: At least one step required, each instruction non-empty (max 5000 chars)"; +export const RECIPE_008 = "RECIPE_008: Invalid prep time (integer 0-10000)"; +export const RECIPE_009 = (max: number) => `RECIPE_009: Too many tags (max ${max})`; + +// ===================================== +// Tag +// ===================================== +export const TAG_001 = (msg: string) => `TAG_001: ${msg}`; +export const TAG_002 = "TAG_002: A global tag with this name already exists"; +export const TAG_003 = "TAG_003: Maximum 10 tags per recipe"; +export const TAG_004 = "TAG_004: Tag is not pending"; +export const TAG_005 = "TAG_005: Cannot modify a tag that does not belong to this community"; +export const TAG_006 = "TAG_006: You already suggested this tag on this recipe"; +export const TAG_007 = "TAG_007: Cannot suggest tags on personal recipes"; + +// ===================================== +// Invite +// ===================================== +export const INVITE_001 = "INVITE_001: Invite not found"; +export const INVITE_002 = "INVITE_002: Invite already processed"; +export const INVITE_003 = "INVITE_003: User not found"; +export const INVITE_004 = "INVITE_004: One of email, username, or userId is required"; +export const INVITE_005 = "INVITE_005: Only one of email, username, or userId should be provided"; +export const INVITE_006 = "INVITE_006: Not authorized to accept/reject this invitation"; + +// ===================================== +// Member +// ===================================== +export const MEMBER_001 = "MEMBER_001: Role is required"; +export const MEMBER_002 = "MEMBER_002: Only promotion to MODERATOR is allowed"; +export const MEMBER_003 = "MEMBER_003: Member not found"; +export const MEMBER_004 = "MEMBER_004: User is already MODERATOR"; + +// ===================================== +// Proposal +// ===================================== +export const PROPOSAL_001 = "PROPOSAL_001: Cannot propose on personal recipe"; +export const PROPOSAL_002 = "PROPOSAL_002: Proposal already decided"; +export const PROPOSAL_003 = "PROPOSAL_003: Recipe has been modified since proposal was created"; +export const PROPOSAL_004 = "PROPOSAL_004: Proposal not found"; + +// ===================================== +// Share +// ===================================== +export const SHARE_001 = "SHARE_001: Target community ID required"; +export const SHARE_002 = "SHARE_002: Cannot share personal recipes"; +export const SHARE_003 = "SHARE_003: Cannot share to same community"; +export const SHARE_004 = "SHARE_004: Not a member of target community"; +export const SHARE_005 = "SHARE_005: Must be recipe creator or moderator in one of the communities"; +export const SHARE_006 = "SHARE_006: Recipe already shared with this community"; + +// ===================================== +// Publish +// ===================================== +export const PUBLISH_001 = "PUBLISH_001: At least one community ID required"; +export const PUBLISH_002 = "PUBLISH_002: Can only publish personal recipes"; +export const PUBLISH_003 = (communityId: string) => + `PUBLISH_003: Not a member of community ${communityId}`; + +// ===================================== +// Notification +// ===================================== +export const NOTIF_001 = "NOTIF_001: Notification not found"; +export const NOTIF_002 = "NOTIF_002: Notification belongs to another user"; +export const NOTIF_003 = "NOTIF_003: Invalid notification category"; +export const NOTIF_004 = "NOTIF_004: ids must be a non-empty array"; +export const NOTIF_005 = "NOTIF_005: enabled must be a boolean"; + +// ===================================== +// Ingredient +// ===================================== +export const INGREDIENT_003 = "INGREDIENT_003: Too many ingredients (max 50)"; + +// ===================================== +// Import +// ===================================== +export const IMPORT_001 = "IMPORT_001: Invalid URL format"; +export const IMPORT_002 = "IMPORT_002: Could not fetch URL"; +export const IMPORT_003 = "IMPORT_003: No recipe data found"; + +// ===================================== +// Validation +// ===================================== +export const VALIDATION_001 = (msg: string) => `VALIDATION_001: ${msg}`; +export const VALIDATION_001_TYPE = "VALIDATION_001: must be a string"; + +// ===================================== +// CSRF +// ===================================== +export const CSRF_001 = "CSRF_001: Invalid or missing CSRF token"; + +// ===================================== +// Admin Auth +// ===================================== +export const ADMIN_001 = "ADMIN_001: Not authenticated"; +export const ADMIN_002 = "ADMIN_002: TOTP not verified"; +export const ADMIN_003 = "ADMIN_003: Email and password required"; +export const ADMIN_004 = "ADMIN_004: Invalid credentials"; +export const ADMIN_005 = "ADMIN_005: TOTP code required"; +export const ADMIN_006 = "ADMIN_006: Too many failed attempts, please login again"; +export const ADMIN_007 = "ADMIN_007: Invalid TOTP code"; +export const ADMIN_008 = "ADMIN_008: Logout failed"; +export const ADMIN_009 = "ADMIN_009: Admin not found"; +export const ADMIN_010 = "ADMIN_010: Too many login attempts, please try again later"; +export const ADMIN_011 = "ADMIN_011: Too many requests, please slow down"; + +// ===================================== +// Admin Tags +// ===================================== +export const ADMIN_TAG_001 = "ADMIN_TAG_001: Tag name is required"; +export const ADMIN_TAG_001_LENGTH = "ADMIN_TAG_001: Tag name must be between 2 and 50 characters"; +export const ADMIN_TAG_002 = "ADMIN_TAG_002: Tag already exists"; +export const ADMIN_TAG_003 = "ADMIN_TAG_003: Tag not found"; +export const ADMIN_TAG_004 = "ADMIN_TAG_004: Target tag ID required"; +export const ADMIN_TAG_005 = "ADMIN_TAG_005: Cannot merge tag into itself"; +export const ADMIN_TAG_006 = "ADMIN_TAG_006: Target tag not found"; + +// ===================================== +// Admin Units +// ===================================== +export const ADMIN_UNIT_001 = "ADMIN_UNIT_001: Name is required"; +export const ADMIN_UNIT_002 = "ADMIN_UNIT_002: Abbreviation is required"; +export const ADMIN_UNIT_003 = + "ADMIN_UNIT_003: Valid category is required (WEIGHT, VOLUME, SPOON, COUNT, QUALITATIVE)"; +export const ADMIN_UNIT_004 = "ADMIN_UNIT_004: Unit name already exists"; +export const ADMIN_UNIT_005 = "ADMIN_UNIT_005: Abbreviation already exists"; +export const ADMIN_UNIT_006 = "ADMIN_UNIT_006: Unit not found"; +export const ADMIN_UNIT_007 = + "ADMIN_UNIT_007: Cannot delete unit that is in use. Migrate recipes to another unit first."; + +// ===================================== +// Admin Recipes +// ===================================== +export const ADMIN_REC_001 = "ADMIN_REC_001: Tag not found"; +export const ADMIN_REC_002 = "ADMIN_REC_002: Recipe not found"; +export const ADMIN_REC_003 = "ADMIN_REC_003: Recipe already deleted"; + +// ===================================== +// Admin Ingredients +// ===================================== +export const ADMIN_ING_001 = "ADMIN_ING_001: Name is required"; +export const ADMIN_ING_002 = "ADMIN_ING_002: Ingredient already exists"; +export const ADMIN_ING_003 = "ADMIN_ING_003: Ingredient not found"; +export const ADMIN_ING_004 = "ADMIN_ING_004: Target ingredient ID required"; +export const ADMIN_ING_005 = "ADMIN_ING_005: Cannot merge ingredient into itself"; +export const ADMIN_ING_006 = "ADMIN_ING_006: Target ingredient not found"; +export const ADMIN_ING_007 = "ADMIN_ING_007: Default unit not found"; +export const ADMIN_ING_008 = "ADMIN_ING_008: Ingredient is not pending"; +export const ADMIN_ING_009 = "ADMIN_ING_009: Reason is required"; + +// ===================================== +// Admin Communities +// ===================================== +export const ADMIN_COM_001 = "ADMIN_COM_001: Community not found"; +export const ADMIN_COM_002 = "ADMIN_COM_002: Name is required"; +export const ADMIN_COM_003 = "ADMIN_COM_003: Community already deleted"; + +// ===================================== +// Admin Features +// ===================================== +export const ADMIN_FEAT_001 = "ADMIN_FEAT_001: Code is required"; +export const ADMIN_FEAT_002 = "ADMIN_FEAT_002: Name is required"; +export const ADMIN_FEAT_003 = "ADMIN_FEAT_003: Feature code already exists"; +export const ADMIN_FEAT_004 = "ADMIN_FEAT_004: Feature not found"; +export const ADMIN_FEAT_005 = "ADMIN_FEAT_005: Feature already granted"; +export const ADMIN_FEAT_006 = "ADMIN_FEAT_006: Feature not granted to this community"; diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 24bb1e9f..2df41357 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -2,12 +2,8 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import prisma from "../util/db"; import bcrypt from "bcrypt"; -import { - EMAIL_REGEX, - USERNAME_REGEX, - MIN_USERNAME_LENGTH, - MIN_PASSWORD_LENGTH, -} from "../util/validation"; +import { AUTH_001, AUTH_006, AUTH_007, AUTH_008, AUTH_009 } from "../constants/errorCodes"; +import type { SignupInput, LoginInput } from "../schemas/auth.schema"; /** * GET /api/auth/me @@ -16,7 +12,7 @@ import { export const getMe: RequestHandler = async (req, res, next) => { try { if (!req.session.userId) { - throw createHttpError(401, "AUTH_001: Not authenticated"); + throw createHttpError(401, AUTH_001); } const user = await prisma.user.findUnique({ @@ -35,7 +31,7 @@ export const getMe: RequestHandler = async (req, res, next) => { if (!user) { // User was deleted or not found req.session.destroy(() => {}); - throw createHttpError(401, "AUTH_009: Account deactivated"); + throw createHttpError(401, AUTH_009); } res.status(200).json({ user }); @@ -44,43 +40,18 @@ export const getMe: RequestHandler = async (req, res, next) => { } }; -interface SignUpBody { - username?: string; - email?: string; - password?: string; -} - /** * POST /api/auth/signup - * Cree un nouvel utilisateur + * Cree un nouvel utilisateur (body valide par signupSchema) */ -export const signUp: RequestHandler = async (req, res, next) => { +export const signUp: RequestHandler = async ( + req, + res, + next +) => { const { username, email, password } = req.body; try { - // Validation des parametres requis - if (!username || !email || !password) { - throw createHttpError(400, "AUTH_002: Missing required parameters"); - } - - // Validation email - if (!EMAIL_REGEX.test(email)) { - throw createHttpError(400, "AUTH_003: Invalid email format"); - } - - // Validation username format et longueur - if (username.length < MIN_USERNAME_LENGTH) { - throw createHttpError(400, `AUTH_004: Username must be at least ${MIN_USERNAME_LENGTH} characters`); - } - if (!USERNAME_REGEX.test(username)) { - throw createHttpError(400, "AUTH_004: Username can only contain letters, numbers, and underscores"); - } - - // Validation password longueur - if (password.length < MIN_PASSWORD_LENGTH) { - throw createHttpError(400, `AUTH_005: Password must be at least ${MIN_PASSWORD_LENGTH} characters`); - } - // Verification username unique (excluant les comptes supprimes) const existingUsername = await prisma.user.findFirst({ where: { @@ -93,7 +64,7 @@ export const signUp: RequestHandler = asy }); if (existingUsername) { - throw createHttpError(409, "AUTH_006: Username already taken"); + throw createHttpError(409, AUTH_006); } // Verification email unique (excluant les comptes supprimes) @@ -108,7 +79,7 @@ export const signUp: RequestHandler = asy }); if (existingEmail) { - throw createHttpError(409, "AUTH_007: Email already in use"); + throw createHttpError(409, AUTH_007); } const passwordHashed = await bcrypt.hash(password, 10); @@ -127,31 +98,29 @@ export const signUp: RequestHandler = asy }, }); - req.session.userId = newUser.id; - - res.status(201).json({ user: newUser }); + // Regenerer la session pour prevenir la session fixation + req.session.regenerate((err) => { + if (err) return next(err); + req.session.userId = newUser.id; + res.status(201).json({ user: newUser }); + }); } catch (error) { next(error); } }; -interface LoginBody { - username?: string; - password?: string; -} - /** * POST /api/auth/login - * Authentifie un utilisateur existant + * Authentifie un utilisateur existant (body valide par loginSchema) */ -export const login: RequestHandler = async (req, res, next) => { +export const login: RequestHandler = async ( + req, + res, + next +) => { const { username, password } = req.body; try { - if (!username || !password) { - throw createHttpError(400, "AUTH_002: Missing required parameters"); - } - const user = await prisma.user.findUnique({ where: { username: username, @@ -167,30 +136,33 @@ export const login: RequestHandler = async }); if (!user) { - throw createHttpError(401, "AUTH_008: Invalid credentials"); + throw createHttpError(401, AUTH_008); } // Verifier si le compte est desactive (soft deleted) if (user.deletedAt !== null) { - throw createHttpError(401, "AUTH_009: Account deactivated"); + throw createHttpError(401, AUTH_009); } const passwordMatch = await bcrypt.compare(password, user.password); if (!passwordMatch) { - throw createHttpError(401, "AUTH_008: Invalid credentials"); + throw createHttpError(401, AUTH_008); } - req.session.userId = user.id; - - // Ne pas retourner le password - res.status(200).json({ - user: { - id: user.id, - username: user.username, - email: user.email, - createdAt: user.createdAt, - }, + // Regenerer la session pour prevenir la session fixation + req.session.regenerate((err) => { + if (err) return next(err); + req.session.userId = user.id; + + res.status(200).json({ + user: { + id: user.id, + username: user.username, + email: user.email, + createdAt: user.createdAt, + }, + }); }); } catch (error) { next(error); diff --git a/backend/src/controllers/communities.ts b/backend/src/controllers/communities.ts index f1a39473..945cc36b 100644 --- a/backend/src/controllers/communities.ts +++ b/backend/src/controllers/communities.ts @@ -3,7 +3,8 @@ import prisma from "../util/db"; import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; -import { COMMUNITY_VALIDATION as VALIDATION } from "../util/validation"; +import { buildImageUrl } from "../config/storage"; +import { CreateCommunityInput, UpdateCommunityInput } from "../schemas/community.schema"; export const getCommunities: RequestHandler = async (req, res, next) => { const authenticatedUserId = req.session.userId; @@ -26,6 +27,7 @@ export const getCommunities: RequestHandler = async (req, res, next) => { id: true, name: true, description: true, + imageKey: true, createdAt: true, updatedAt: true, _count: { @@ -48,6 +50,7 @@ export const getCommunities: RequestHandler = async (req, res, next) => { id: membership.community.id, name: membership.community.name, description: membership.community.description, + imageUrl: membership.community.imageKey ? buildImageUrl(membership.community.imageKey) : null, role: membership.role, membersCount: membership.community._count.members, recipesCount: membership.community._count.recipes, @@ -83,6 +86,7 @@ export const getCommunity: RequestHandler = async (req, res, next) => { id: true, name: true, description: true, + imageKey: true, visibility: true, createdAt: true, updatedAt: true, @@ -108,6 +112,7 @@ export const getCommunity: RequestHandler = async (req, res, next) => { id: community.id, name: community.name, description: community.description, + imageUrl: community.imageKey ? buildImageUrl(community.imageKey) : null, visibility: community.visibility, createdAt: community.createdAt, membersCount: community._count.members, @@ -117,17 +122,12 @@ export const getCommunity: RequestHandler = async (req, res, next) => { } catch (error) { next(error); } -} - -interface CreateCommunityBody { - name?: string; - description?: string; -} +}; export const createCommunity: RequestHandler< unknown, unknown, - CreateCommunityBody, + CreateCommunityInput, unknown > = async (req, res, next) => { const { name, description } = req.body; @@ -136,32 +136,6 @@ export const createCommunity: RequestHandler< try { assertIsDefine(authenticatedUserId); - // Validation - if (!name) { - throw createHttpError(400, "Community must have a name"); - } - - if (name.length < VALIDATION.NAME_MIN) { - throw createHttpError( - 400, - `Name must be at least ${VALIDATION.NAME_MIN} characters` - ); - } - - if (name.length > VALIDATION.NAME_MAX) { - throw createHttpError( - 400, - `Name must be at most ${VALIDATION.NAME_MAX} characters` - ); - } - - if (description && description.length > VALIDATION.DESCRIPTION_MAX) { - throw createHttpError( - 400, - `Description must be at most ${VALIDATION.DESCRIPTION_MAX} characters` - ); - } - // Get default features const defaultFeatures = await prisma.feature.findMany({ where: { isDefault: true }, @@ -200,16 +174,10 @@ export const createCommunity: RequestHandler< } }; - interface UpdateCommunityParams extends Record { communityId: string; } -interface UpdateCommunityBody { - name?: string; - description?: string; -} - /** * Update community details. * Requires memberOf and requireCommunityRole("MODERATOR") middlewares. @@ -217,7 +185,7 @@ interface UpdateCommunityBody { export const updateCommunity: RequestHandler< UpdateCommunityParams, unknown, - UpdateCommunityBody, + UpdateCommunityInput, unknown > = async (req, res, next) => { const communityId = req.params.communityId; @@ -231,36 +199,6 @@ export const updateCommunity: RequestHandler< throw createHttpError(500, "Middleware memberOf required"); } - // At least one field must be provided - if (name === undefined && description === undefined) { - throw createHttpError(400, "No fields to update"); - } - - // Validate name if provided - if (name !== undefined) { - if (name.length < VALIDATION.NAME_MIN) { - throw createHttpError( - 400, - `Name must be at least ${VALIDATION.NAME_MIN} characters` - ); - } - - if (name.length > VALIDATION.NAME_MAX) { - throw createHttpError( - 400, - `Name must be at most ${VALIDATION.NAME_MAX} characters` - ); - } - } - - // Validate description if provided - if (description !== undefined && description.length > VALIDATION.DESCRIPTION_MAX) { - throw createHttpError( - 400, - `Description must be at most ${VALIDATION.DESCRIPTION_MAX} characters` - ); - } - // Build update data const updateData: { name?: string; description?: string | null } = {}; if (name !== undefined) { @@ -280,6 +218,7 @@ export const updateCommunity: RequestHandler< id: true, name: true, description: true, + imageKey: true, visibility: true, createdAt: true, updatedAt: true, @@ -300,6 +239,7 @@ export const updateCommunity: RequestHandler< id: updatedCommunity.id, name: updatedCommunity.name, description: updatedCommunity.description, + imageUrl: updatedCommunity.imageKey ? buildImageUrl(updatedCommunity.imageKey) : null, visibility: updatedCommunity.visibility, createdAt: updatedCommunity.createdAt, updatedAt: updatedCommunity.updatedAt, diff --git a/backend/src/controllers/communityImage.ts b/backend/src/controllers/communityImage.ts new file mode 100644 index 00000000..16b634b1 --- /dev/null +++ b/backend/src/controllers/communityImage.ts @@ -0,0 +1,115 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import createHttpError from "http-errors"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { + generatePresignedUploadUrl, + validateUploadedFile, + deleteObject, +} from "../services/storageService"; +import { buildImageUrl } from "../config/storage"; +import { COMMUNITY_002 } from "../constants/errorCodes"; + +/** + * POST /api/communities/:communityId/upload-url + * Genere une presigned PUT URL pour uploader un avatar de communaute. + * Middleware memberOf + requireCommunityRole("MODERATOR") en amont. + */ +export const getUploadUrl: RequestHandler = async (req, res, next) => { + const communityId = req.params.communityId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const community = await prisma.community.findUnique({ + where: { id: communityId, deletedAt: null }, + }); + + if (!community) { + throw createHttpError(404, COMMUNITY_002); + } + + const imageKey = `communities/${communityId}/avatar.webp`; + const uploadUrl = await generatePresignedUploadUrl(imageKey); + + res.status(200).json({ uploadUrl, imageKey }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/confirm-upload + * Confirme l'upload et valide le fichier sur MinIO. + */ +export const confirmUpload: RequestHandler = async (req, res, next) => { + const communityId = req.params.communityId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const community = await prisma.community.findUnique({ + where: { id: communityId, deletedAt: null }, + }); + + if (!community) { + throw createHttpError(404, COMMUNITY_002); + } + + const imageKey = `communities/${communityId}/avatar.webp`; + + const validationError = await validateUploadedFile(imageKey); + if (validationError) { + await deleteObject(imageKey); + throw createHttpError(400, `COMMUNITY_006: ${validationError}`); + } + + await prisma.community.update({ + where: { id: communityId }, + data: { imageKey }, + }); + + res.status(200).json({ + imageKey, + imageUrl: buildImageUrl(imageKey), + }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/image + * Supprime l'avatar de la communaute (MinIO + DB). + */ +export const deleteImage: RequestHandler = async (req, res, next) => { + const communityId = req.params.communityId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const community = await prisma.community.findUnique({ + where: { id: communityId, deletedAt: null }, + }); + + if (!community) { + throw createHttpError(404, COMMUNITY_002); + } + + if (community.imageKey) { + await deleteObject(community.imageKey); + } + + await prisma.community.update({ + where: { id: communityId }, + data: { imageKey: null }, + }); + + res.sendStatus(204); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/communityRecipes.ts b/backend/src/controllers/communityRecipes.ts index 42c03d2d..e6051531 100644 --- a/backend/src/controllers/communityRecipes.ts +++ b/backend/src/controllers/communityRecipes.ts @@ -3,53 +3,43 @@ import prisma from "../util/db"; import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { Prisma } from "@prisma/client"; -import { isValidHttpUrl } from "../util/validation"; +import { MAX_FILTER_ITEMS, MAX_SEARCH_LENGTH } from "../util/validation"; +import { buildImageUrl } from "../config/storage"; import { parsePagination, buildPaginationMeta } from "../util/pagination"; import { RECIPE_TAGS_SELECT } from "../util/prismaSelects"; -import { formatTags, formatIngredients } from "../util/responseFormatters"; +import { formatTags, formatIngredients, formatSteps } from "../util/responseFormatters"; import { createCommunityRecipe as createCommunityRecipeService } from "../services/communityRecipeService"; +import { VALIDATION_001 } from "../constants/errorCodes"; import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; +import type { CreateRecipeInput } from "../schemas/recipe.schema"; -interface IngredientInput { - name: string; - quantity?: string; -} - -interface CreateCommunityRecipeBody { - title?: string; - content?: string; - imageUrl?: string; - tags?: string[]; - ingredients?: IngredientInput[]; -} - +/** + * POST /api/communities/:communityId/recipes + * Creer une recette communautaire (body valide par createRecipeSchema) + */ export const createCommunityRecipe: RequestHandler< { communityId: string }, unknown, - CreateCommunityRecipeBody, + CreateRecipeInput, unknown > = async (req, res, next) => { - const { title, content, imageUrl, tags = [], ingredients = [] } = req.body; + const { title, servings, prepTime, cookTime, restTime, steps, tags, ingredients } = req.body; const authenticatedUserId = req.session.userId; const communityId = req.params.communityId; try { assertIsDefine(authenticatedUserId); - if (!title?.trim()) { - throw createHttpError(400, "RECIPE_003: Title required"); - } - - if (!content?.trim()) { - throw createHttpError(400, "RECIPE_004: Content required"); - } - - if (!isValidHttpUrl(imageUrl)) { - throw createHttpError(400, "RECIPE_005: Invalid image URL"); - } - const result = await createCommunityRecipeService(authenticatedUserId, communityId, { - title, content, imageUrl, tags, ingredients, + title, + servings, + prepTime, + cookTime, + restTime, + steps, + tags, + ingredients, }); if (!result.personal || !result.community) { @@ -59,13 +49,17 @@ export const createCommunityRecipe: RequestHandler< const formatRecipe = (recipe: NonNullable) => ({ id: recipe.id, title: recipe.title, - content: recipe.content, - imageUrl: recipe.imageUrl, + servings: recipe.servings, + prepTime: recipe.prepTime, + cookTime: recipe.cookTime, + restTime: recipe.restTime, + imageUrl: recipe.imageKey ? buildImageUrl(recipe.imageKey) : null, createdAt: recipe.createdAt, updatedAt: recipe.updatedAt, creatorId: recipe.creatorId, communityId: recipe.communityId, originRecipeId: recipe.originRecipeId, + steps: formatSteps(recipe.steps), tags: formatTags(recipe.tags), ingredients: formatIngredients(recipe.ingredients), }); @@ -77,6 +71,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), @@ -115,9 +124,26 @@ export const getCommunityRecipes: RequestHandler< const searchFilter = req.query.search?.trim() || ""; try { + if (tagsFilter.length > MAX_FILTER_ITEMS) { + throw createHttpError(400, VALIDATION_001(`Too many tag filters (max ${MAX_FILTER_ITEMS})`)); + } + if (ingredientsFilter.length > MAX_FILTER_ITEMS) { + throw createHttpError( + 400, + VALIDATION_001(`Too many ingredient filters (max ${MAX_FILTER_ITEMS})`) + ); + } + if (searchFilter.length > MAX_SEARCH_LENGTH) { + throw createHttpError( + 400, + VALIDATION_001(`Search query too long (max ${MAX_SEARCH_LENGTH} chars)`) + ); + } + const whereClause: Prisma.RecipeWhereInput = { communityId, deletedAt: null, + isVariant: false, }; if (searchFilter) { @@ -167,7 +193,11 @@ export const getCommunityRecipes: RequestHandler< select: { id: true, title: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, createdAt: true, updatedAt: true, creatorId: true, @@ -198,7 +228,11 @@ export const getCommunityRecipes: RequestHandler< const data = recipes.map((recipe) => ({ id: recipe.id, title: recipe.title, - imageUrl: recipe.imageUrl, + servings: recipe.servings, + prepTime: recipe.prepTime, + cookTime: recipe.cookTime, + restTime: recipe.restTime, + imageUrl: recipe.imageKey ? buildImageUrl(recipe.imageKey) : null, createdAt: recipe.createdAt, updatedAt: recipe.updatedAt, creatorId: recipe.creatorId, diff --git a/backend/src/controllers/communityTags.ts b/backend/src/controllers/communityTags.ts new file mode 100644 index 00000000..6d530f0e --- /dev/null +++ b/backend/src/controllers/communityTags.ts @@ -0,0 +1,393 @@ +import { RequestHandler } from "express"; +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 { TAG_001, TAG_002, TAG_003, TAG_004, TAG_005 } from "../constants/errorCodes"; +import { CommunityTagInput } from "../schemas/tag.schema"; + +/** + * 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: { + where: { recipe: { communityId, deletedAt: null } }, + }, + }, + }, + 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< + { communityId: string }, + unknown, + CommunityTagInput +> = async (req, res, next) => { + const { communityId } = req.params; + const { name } = req.body; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + // name is already normalized by Zod schema + const normalized = name; + + // 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); + } + + // 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); + } + + // Verifier limite 100 tags par communaute + const count = await prisma.tag.count({ + where: { communityId, scope: "COMMUNITY" }, + }); + if (count >= 100) { + throw createHttpError(400, TAG_003); + } + + 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< + { communityId: string; tagId: string }, + unknown, + CommunityTagInput +> = async (req, res, next) => { + const { communityId, tagId } = req.params; + const { name } = req.body; + const userId = req.session.userId; + + try { + assertIsDefine(userId); + assertIsDefine(tagId); + + // name is already normalized by Zod schema + const normalized = name; + + 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); + } + + 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); + } + + // 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); + } + } + + 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); + } + + 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); + } + + if (tag.status !== "PENDING") { + throw createHttpError(400, TAG_004); + } + + const updated = await prisma.tag.update({ + where: { id: tagId }, + 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", + userId, + communityId, + metadata: { tagId, tagName: tag.name }, + }, + }); + + // 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, + 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); + } + + if (tag.status !== "PENDING") { + throw createHttpError(400, TAG_004); + } + + // 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 } }); + + await prisma.activityLog.create({ + data: { + type: "TAG_REJECTED", + userId, + communityId, + metadata: { tagId, tagName: tag.name, createdById: tag.createdById }, + }, + }); + + // 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/ingredients.ts b/backend/src/controllers/ingredients.ts index c6f299eb..98e78c8f 100644 --- a/backend/src/controllers/ingredients.ts +++ b/backend/src/controllers/ingredients.ts @@ -1,4 +1,5 @@ import { RequestHandler } from "express"; +import createHttpError from "http-errors"; import prisma from "../util/db"; import { assertIsDefine } from "../util/assertIsDefine"; import { parsePagination } from "../util/pagination"; @@ -8,7 +9,12 @@ interface SearchIngredientsQuery { limit?: string; } -export const searchIngredients: RequestHandler = async (req, res, next) => { +export const searchIngredients: RequestHandler< + unknown, + unknown, + unknown, + SearchIngredientsQuery +> = async (req, res, next) => { const authenticatedUserId = req.session.userId; const search = req.query.search?.trim().toLowerCase() || ""; const { limit } = parsePagination(req.query); @@ -16,6 +22,7 @@ export const searchIngredients: RequestHandler ({ id: ingredient.id, name: ingredient.name, + status: ingredient.status, recipeCount: ingredient._count.recipes, })); @@ -59,3 +68,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) { + throw createHttpError(404, "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/invites.ts b/backend/src/controllers/invites.ts index 8c33b25d..ab5a4ce8 100644 --- a/backend/src/controllers/invites.ts +++ b/backend/src/controllers/invites.ts @@ -4,16 +4,15 @@ import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { InviteStatus } from "@prisma/client"; import appEvents from "../services/eventEmitter"; - -// ===================================== -// Types -// ===================================== - -interface CreateInviteBody { - email?: string; - username?: string; - userId?: string; -} +import { + INVITE_001, + INVITE_002, + INVITE_003, + INVITE_006, + COMMUNITY_004, + COMMUNITY_005, +} from "../constants/errorCodes"; +import { CreateInviteInput } from "../schemas/invite.schema"; interface GetInvitesQuery { status?: string; @@ -30,7 +29,7 @@ interface GetMyInvitesQuery { export const createInvite: RequestHandler< { communityId: string }, unknown, - CreateInviteBody + CreateInviteInput > = async (req, res, next) => { const communityId = req.params.communityId; const email = req.body.email?.trim(); @@ -46,21 +45,6 @@ export const createInvite: RequestHandler< throw createHttpError(500, "Middleware memberOf required"); } - // Validate that exactly one search field is provided - const providedFields = [email, username, userId].filter(Boolean); - if (providedFields.length === 0) { - throw createHttpError( - 400, - "INVITE_004: One of email, username, or userId is required" - ); - } - if (providedFields.length > 1) { - throw createHttpError( - 400, - "INVITE_005: Only one of email, username, or userId should be provided" - ); - } - // Find the user to invite const invitee = await prisma.user.findFirst({ where: { @@ -77,7 +61,7 @@ export const createInvite: RequestHandler< }); if (!invitee) { - throw createHttpError(404, "INVITE_003: User not found"); + throw createHttpError(404, INVITE_003); } // Check if user is already a member @@ -90,7 +74,7 @@ export const createInvite: RequestHandler< }); if (existingMembership) { - throw createHttpError(409, "COMMUNITY_004: User already member"); + throw createHttpError(409, COMMUNITY_004); } // Check if there's already a pending invite @@ -104,7 +88,7 @@ export const createInvite: RequestHandler< }); if (existingInvite) { - throw createHttpError(409, "COMMUNITY_005: Invitation already pending"); + throw createHttpError(409, COMMUNITY_005); } // Create the invite and log activity in a transaction @@ -187,12 +171,7 @@ export const getInvites: RequestHandler< // Build status filter let statusFilter: { status?: InviteStatus } = {}; if (status && status !== "all") { - const validStatuses: InviteStatus[] = [ - "PENDING", - "ACCEPTED", - "REJECTED", - "CANCELLED", - ]; + const validStatuses: InviteStatus[] = ["PENDING", "ACCEPTED", "REJECTED", "CANCELLED"]; if (validStatuses.includes(status as InviteStatus)) { statusFilter = { status: status as InviteStatus }; } @@ -246,7 +225,11 @@ export const getInvites: RequestHandler< // DELETE /api/communities/:communityId/invites/:inviteId // Cancel an invitation (MODERATOR only) // ===================================== -export const cancelInvite: RequestHandler<{ communityId: string; inviteId: string }> = async (req, res, next) => { +export const cancelInvite: RequestHandler<{ communityId: string; inviteId: string }> = async ( + req, + res, + next +) => { const communityId = req.params.communityId; const inviteId = req.params.inviteId; const userId = req.session.userId; @@ -277,20 +260,17 @@ export const cancelInvite: RequestHandler<{ communityId: string; inviteId: strin }); if (!invite) { - throw createHttpError(404, "INVITE_001: Invite not found"); + throw createHttpError(404, INVITE_001); } // Check if invite is still pending if (invite.status !== "PENDING") { - throw createHttpError(400, "INVITE_002: Invite already processed"); + throw createHttpError(400, INVITE_002); } // Only the inviter or a moderator can cancel if (invite.inviterId !== userId && userCommunity.role !== "MODERATOR") { - throw createHttpError( - 403, - "INVITE_003: Only the inviter or a moderator can cancel" - ); + throw createHttpError(403, INVITE_003); } // Cancel the invite and log activity in a transaction @@ -334,12 +314,11 @@ export const cancelInvite: RequestHandler<{ communityId: string; inviteId: strin // GET /api/users/me/invites // Get my received invitations // ===================================== -export const getMyInvites: RequestHandler< - unknown, - unknown, - unknown, - GetMyInvitesQuery -> = async (req, res, next) => { +export const getMyInvites: RequestHandler = async ( + req, + res, + next +) => { const userId = req.session.userId; const { status } = req.query; @@ -432,17 +411,17 @@ export const acceptInvite: RequestHandler<{ inviteId: string }> = async (req, re }); if (!invite) { - throw createHttpError(404, "INVITE_001: Invite not found"); + throw createHttpError(404, INVITE_001); } // Check if user is the invitee if (invite.inviteeId !== userId) { - throw createHttpError(403, "INVITE_006: Not authorized to accept this invitation"); + throw createHttpError(403, INVITE_006); } // Check if invite is still pending if (invite.status !== "PENDING") { - throw createHttpError(400, "INVITE_002: Invite already processed"); + throw createHttpError(400, INVITE_002); } // Check if community is not deleted @@ -534,17 +513,17 @@ export const rejectInvite: RequestHandler<{ inviteId: string }> = async (req, re }); if (!invite) { - throw createHttpError(404, "INVITE_001: Invite not found"); + throw createHttpError(404, INVITE_001); } // Check if user is the invitee if (invite.inviteeId !== userId) { - throw createHttpError(403, "INVITE_006: Not authorized to reject this invitation"); + throw createHttpError(403, INVITE_006); } // Check if invite is still pending if (invite.status !== "PENDING") { - throw createHttpError(400, "INVITE_002: Invite already processed"); + throw createHttpError(400, INVITE_002); } // Reject invite and log activity in a transaction diff --git a/backend/src/controllers/members.ts b/backend/src/controllers/members.ts index fe00093f..0ba74c46 100644 --- a/backend/src/controllers/members.ts +++ b/backend/src/controllers/members.ts @@ -4,6 +4,14 @@ import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { handleOrphanedRecipes } from "../services/orphanHandling"; import appEvents from "../services/eventEmitter"; +import { + MEMBER_003, + MEMBER_004, + COMMUNITY_002, + COMMUNITY_003, + COMMUNITY_006, +} from "../constants/errorCodes"; +import { PromoteMemberInput } from "../schemas/member.schema"; // ===================================== // GET /api/communities/:communityId/members @@ -54,32 +62,18 @@ export const getMembers: RequestHandler<{ communityId: string }> = async (req, r // Promote a member (MODERATOR only) // ===================================== -interface PromoteMemberBody { - role?: string; -} - export const promoteMember: RequestHandler< { communityId: string; userId: string }, unknown, - PromoteMemberBody + PromoteMemberInput > = async (req, res, next) => { const communityId = req.params.communityId; const targetUserId = req.params.userId; const userId = req.session.userId; - const { role } = req.body; try { assertIsDefine(userId); - // Validate role field - if (!role) { - throw createHttpError(400, "MEMBER_001: Role is required"); - } - - if (role !== "MODERATOR") { - throw createHttpError(400, "MEMBER_002: Only promotion to MODERATOR is allowed"); - } - // Find the target membership const targetMembership = await prisma.userCommunity.findFirst({ where: { @@ -90,11 +84,11 @@ export const promoteMember: RequestHandler< }); if (!targetMembership) { - throw createHttpError(404, "MEMBER_003: Member not found"); + throw createHttpError(404, MEMBER_003); } if (targetMembership.role === "MODERATOR") { - throw createHttpError(400, "MEMBER_004: User is already MODERATOR"); + throw createHttpError(400, MEMBER_004); } // Promote and log in a transaction @@ -132,7 +126,11 @@ export const promoteMember: RequestHandler< // DELETE /api/communities/:communityId/members/:userId // Leave community (self) or kick member (moderator) // ===================================== -export const removeMember: RequestHandler<{ communityId: string; userId: string }> = async (req, res, next) => { +export const removeMember: RequestHandler<{ communityId: string; userId: string }> = async ( + req, + res, + next +) => { const communityId = req.params.communityId; const targetUserId = req.params.userId; const userId = req.session.userId; @@ -187,10 +185,7 @@ async function handleLeave( if (isLastModerator) { // Cannot leave as last moderator when other members exist - throw createHttpError( - 403, - "COMMUNITY_003: Last moderator cannot leave. Promote another member first" - ); + throw createHttpError(403, COMMUNITY_003); } // Regular leave - use interactive transaction for orphan handling @@ -236,7 +231,7 @@ async function handleKick( ) { // Only MODERATOR can kick if (requesterRole !== "MODERATOR") { - throw createHttpError(403, "COMMUNITY_002: Permission insufficient"); + throw createHttpError(403, COMMUNITY_002); } // Find the target membership @@ -254,7 +249,7 @@ async function handleKick( // Cannot kick another MODERATOR if (targetMembership.role === "MODERATOR") { - throw createHttpError(403, "COMMUNITY_006: Cannot remove a moderator"); + throw createHttpError(403, COMMUNITY_006); } // Kick the member - use interactive transaction for orphan handling diff --git a/backend/src/controllers/notifications.ts b/backend/src/controllers/notifications.ts new file mode 100644 index 00000000..55fd1e7d --- /dev/null +++ b/backend/src/controllers/notifications.ts @@ -0,0 +1,529 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import createHttpError from "http-errors"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { NotificationCategory, Notification } from "@prisma/client"; +import { NOTIF_001, NOTIF_002, NOTIF_003, COMMUNITY_001 } from "../constants/errorCodes"; +import { + MarkBatchAsReadInput, + MarkAllAsReadInput, + UpdateNotificationPreferenceInput, +} from "../schemas/notification.schema"; + +const ALL_CATEGORIES = Object.values(NotificationCategory); +const VALID_CATEGORIES: Set = new Set(ALL_CATEGORIES); + +const GROUP_WINDOW_MS = 60 * 60 * 1000; // 60 minutes + +// ============================================================================= +// GET /api/notifications +// ============================================================================= + +interface NotificationGroup { + id: string; + type: string; + category: NotificationCategory; + title: string; + message: string; + actionUrl: string | null; + actor: { id: string; username: string } | null; + community: { id: string; name: string } | null; + readAt: Date | null; + createdAt: Date; + group: { + count: number; + notificationIds: string[]; + items: { + id: string; + message: string; + createdAt: Date; + readAt: Date | null; + }[]; + } | null; +} + +function groupNotifications( + notifications: (Notification & { + actor: { id: string; username: string } | null; + community: { id: string; name: string } | null; + })[] +): NotificationGroup[] { + const result: NotificationGroup[] = []; + const grouped = new Map(); + + // Separer les notifications groupables des individuelles + for (const notif of notifications) { + if (notif.groupKey) { + const existing = grouped.get(notif.groupKey) ?? []; + existing.push(notif); + grouped.set(notif.groupKey, existing); + } else { + result.push({ + id: notif.id, + type: notif.type, + category: notif.category, + title: notif.title, + message: notif.message, + actionUrl: notif.actionUrl, + actor: notif.actor, + community: notif.community, + readAt: notif.readAt, + createdAt: notif.createdAt, + group: null, + }); + } + } + + // Pour chaque groupKey, fusionner les notifications dans la fenetre de 60min + for (const [, notifs] of grouped) { + // Trier par date decroissante + notifs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + // Decouper en sous-groupes par fenetre temporelle + const subGroups: (typeof notifs)[] = []; + let currentGroup: typeof notifs = []; + + for (const notif of notifs) { + if ( + currentGroup.length === 0 || + currentGroup[currentGroup.length - 1].createdAt.getTime() - notif.createdAt.getTime() <= + GROUP_WINDOW_MS + ) { + currentGroup.push(notif); + } else { + subGroups.push(currentGroup); + currentGroup = [notif]; + } + } + if (currentGroup.length > 0) subGroups.push(currentGroup); + + // Convertir chaque sous-groupe + for (const sg of subGroups) { + if (sg.length === 1) { + // Pas de groupement pour une seule notification + const n = sg[0]; + result.push({ + id: n.id, + type: n.type, + category: n.category, + title: n.title, + message: n.message, + actionUrl: n.actionUrl, + actor: n.actor, + community: n.community, + readAt: n.readAt, + createdAt: n.createdAt, + group: null, + }); + } else { + // Groupe de plusieurs notifications + const newest = sg[0]; + const allRead = sg.every((n) => n.readAt !== null); + result.push({ + id: `group:${newest.groupKey}:${newest.createdAt.toISOString()}`, + type: newest.type, + category: newest.category, + title: newest.title, + message: `${sg.length} ${getGroupMessage(newest.type, newest.community?.name ?? "")}`, + actionUrl: newest.community ? `/communities/${newest.community.id}` : null, + actor: null, + community: newest.community, + readAt: allRead ? newest.readAt : null, + createdAt: newest.createdAt, + group: { + count: sg.length, + notificationIds: sg.map((n) => n.id), + items: sg.map((n) => ({ + id: n.id, + message: n.message, + createdAt: n.createdAt, + readAt: n.readAt, + })), + }, + }); + } + } + } + + // Trier par date decroissante + result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return result; +} + +function getGroupMessage(type: string, communityName: string): string { + switch (type) { + case "RECIPE_CREATED": + return `nouvelles recettes dans ${communityName}`; + case "RECIPE_SHARED": + return `recettes partagees dans ${communityName}`; + case "USER_JOINED": + return `nouveaux membres dans ${communityName}`; + case "USER_LEFT": + return `membres ont quitte ${communityName}`; + default: + return `notifications dans ${communityName}`; + } +} + +export const getNotifications: RequestHandler = async (req, res, next) => { + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(50, Math.max(1, parseInt(req.query.limit as string) || 20)); + const category = req.query.category as string | undefined; + const unreadOnly = req.query.unreadOnly === "true"; + const grouped = req.query.grouped !== "false"; // default true + + // Validation categorie + if (category && !VALID_CATEGORIES.has(category)) { + throw createHttpError(400, NOTIF_003); + } + + // Construire le filtre + const where: Record = { userId }; + if (category) where.category = category as NotificationCategory; + if (unreadOnly) where.readAt = null; + + // Compter le total + const total = await prisma.notification.count({ where }); + + // Fetch les notifications avec relations + const notifications = await prisma.notification.findMany({ + where, + include: { + actor: { select: { id: true, username: true } }, + community: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + }); + + // Compteur non-lues + const unreadCount = await prisma.notification.count({ + where: { userId, readAt: null }, + }); + + // Groupement optionnel + const data = grouped + ? groupNotifications(notifications) + : notifications.map((n) => ({ + id: n.id, + type: n.type, + category: n.category, + title: n.title, + message: n.message, + actionUrl: n.actionUrl, + actor: n.actor, + community: n.community, + readAt: n.readAt, + createdAt: n.createdAt, + group: null, + })); + + res.status(200).json({ + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + unreadCount, + }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// GET /api/notifications/unread-count +// ============================================================================= + +export const getUnreadCount: RequestHandler = async (req, res, next) => { + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + const [total, ...categoryCounts] = await Promise.all([ + prisma.notification.count({ where: { userId, readAt: null } }), + ...ALL_CATEGORIES.map((cat) => + prisma.notification.count({ + where: { userId, readAt: null, category: cat }, + }) + ), + ]); + + const byCategory: Record = {}; + ALL_CATEGORIES.forEach((cat, i) => { + byCategory[cat] = categoryCounts[i]; + }); + + res.status(200).json({ count: total, byCategory }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// PATCH /api/notifications/:id/read +// ============================================================================= + +export const markAsRead: RequestHandler<{ id: string }> = async (req, res, next) => { + const userId = req.session.userId; + const { id } = req.params; + + try { + assertIsDefine(userId); + + const notification = await prisma.notification.findUnique({ + where: { id }, + }); + + if (!notification) { + throw createHttpError(404, NOTIF_001); + } + + if (notification.userId !== userId) { + throw createHttpError(403, NOTIF_002); + } + + if (notification.readAt) { + // Deja lue + res.status(200).json({ id: notification.id, readAt: notification.readAt }); + return; + } + + const updated = await prisma.notification.update({ + where: { id }, + data: { readAt: new Date() }, + }); + + res.status(200).json({ id: updated.id, readAt: updated.readAt }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// PATCH /api/notifications/read (batch) +// ============================================================================= + +export const markBatchAsRead: RequestHandler = async ( + req, + res, + next +) => { + const userId = req.session.userId; + const { ids } = req.body; + + try { + assertIsDefine(userId); + + // Verifier que toutes les notifications appartiennent au user + const notifications = await prisma.notification.findMany({ + where: { id: { in: ids } }, + select: { id: true, userId: true }, + }); + + const invalidIds = notifications.filter((n) => n.userId !== userId); + if (invalidIds.length > 0) { + throw createHttpError(403, NOTIF_002); + } + + const { count } = await prisma.notification.updateMany({ + where: { + id: { in: ids }, + userId, + readAt: null, + }, + data: { readAt: new Date() }, + }); + + res.status(200).json({ updated: count }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// PATCH /api/notifications/read-all +// ============================================================================= + +export const markAllAsRead: RequestHandler = async ( + req, + res, + next +) => { + const userId = req.session.userId; + const { category } = req.body; + + try { + assertIsDefine(userId); + + const where: Record = { userId, readAt: null }; + if (category) where.category = category as NotificationCategory; + + const { count } = await prisma.notification.updateMany({ + where, + data: { readAt: new Date() }, + }); + + res.status(200).json({ updated: count }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// GET /api/notifications/preferences +// ============================================================================= + +export const getPreferences: RequestHandler = async (req, res, next) => { + const userId = req.session.userId; + + try { + assertIsDefine(userId); + + // Toutes les communautes de l'utilisateur + const memberships = await prisma.userCommunity.findMany({ + where: { userId, deletedAt: null }, + select: { + communityId: true, + community: { select: { id: true, name: true } }, + }, + }); + + // Toutes les preferences existantes + const prefs = await prisma.notificationPreference.findMany({ + where: { userId }, + }); + + // Construire les preferences globales (defaut true si pas de row) + const allCategories: NotificationCategory[] = [ + "INVITATION", + "RECIPE_PROPOSAL", + "TAG", + "INGREDIENT", + "MODERATION", + ]; + + const globalPrefs: Record = {}; + for (const cat of allCategories) { + const pref = prefs.find((p) => p.communityId === null && p.category === cat); + globalPrefs[cat] = pref?.enabled ?? true; + } + + // Construire les preferences par communaute + const communities = memberships.map((m) => { + const communityPrefs: Record = {}; + for (const cat of allCategories) { + const pref = prefs.find((p) => p.communityId === m.communityId && p.category === cat); + // Si pas de pref communaute, heriter de la globale + communityPrefs[cat] = pref?.enabled ?? globalPrefs[cat]; + } + return { + communityId: m.communityId, + communityName: m.community.name, + preferences: communityPrefs, + }; + }); + + res.status(200).json({ + global: globalPrefs, + communities, + }); + } catch (error) { + next(error); + } +}; + +// ============================================================================= +// PUT /api/notifications/preferences +// ============================================================================= + +export const updatePreference: RequestHandler< + unknown, + unknown, + UpdateNotificationPreferenceInput +> = async (req, res, next) => { + const userId = req.session.userId; + const { category, enabled, communityId } = req.body; + + try { + assertIsDefine(userId); + + // Si communityId fourni, verifier le membership + if (communityId) { + const membership = await prisma.userCommunity.findFirst({ + where: { userId, communityId, deletedAt: null }, + }); + if (!membership) { + throw createHttpError(403, COMMUNITY_001); + } + } + + const resolvedCommunityId = communityId ?? null; + + // Upsert : findFirst + update/create (communityId nullable dans contrainte unique) + if (resolvedCommunityId) { + const pref = await prisma.notificationPreference.upsert({ + where: { + userId_communityId_category: { + userId, + communityId: resolvedCommunityId, + category: category as NotificationCategory, + }, + }, + update: { enabled }, + create: { + userId, + communityId: resolvedCommunityId, + category: category as NotificationCategory, + enabled, + }, + }); + + res.status(200).json({ + category: pref.category, + enabled: pref.enabled, + communityId: pref.communityId, + }); + } else { + // communityId null : upsert impossible sur contrainte unique avec null + const existing = await prisma.notificationPreference.findFirst({ + where: { userId, communityId: null, category: category as NotificationCategory }, + }); + + let pref; + if (existing) { + pref = await prisma.notificationPreference.update({ + where: { id: existing.id }, + data: { enabled }, + }); + } else { + pref = await prisma.notificationPreference.create({ + data: { + userId, + communityId: null, + category: category as NotificationCategory, + enabled, + }, + }); + } + + res.status(200).json({ + category: pref.category, + enabled: pref.enabled, + communityId: pref.communityId, + }); + } + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/proposals.ts b/backend/src/controllers/proposals.ts index 2ce0b4c6..623ce76a 100644 --- a/backend/src/controllers/proposals.ts +++ b/backend/src/controllers/proposals.ts @@ -5,39 +5,70 @@ import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { parsePagination, buildPaginationMeta } from "../util/pagination"; import { requireMembership } from "../services/membershipService"; -import { acceptProposal as acceptProposalService, rejectProposal as rejectProposalService } from "../services/proposalService"; +import { + acceptProposal as acceptProposalService, + rejectProposal as rejectProposalService, +} from "../services/proposalService"; import appEvents from "../services/eventEmitter"; - -interface CreateProposalBody { - proposedTitle?: string; - proposedContent?: string; -} +import { upsertProposalIngredients, upsertProposalSteps } from "../services/recipeService"; +import { PROPOSAL_INGREDIENTS_SELECT, PROPOSAL_STEPS_SELECT } from "../util/prismaSelects"; +import { + RECIPE_001, + RECIPE_002, + PROPOSAL_001, + PROPOSAL_002, + PROPOSAL_003, + PROPOSAL_004, +} from "../constants/errorCodes"; +import type { CreateProposalInput } from "../schemas/proposal.schema"; + +const PROPOSAL_RESPONSE_SELECT = { + id: true, + proposedTitle: true, + proposedServings: true, + proposedPrepTime: true, + proposedCookTime: true, + proposedRestTime: true, + status: true, + createdAt: true, + decidedAt: true, + recipeId: true, + proposerId: true, + proposer: { + select: { + id: true, + username: true, + }, + }, + proposedSteps: PROPOSAL_STEPS_SELECT, + proposedIngredients: PROPOSAL_INGREDIENTS_SELECT, +}; /** * POST /api/recipes/:recipeId/proposals - * Creer une proposition de modification sur une recette communautaire + * Creer une proposition de modification (body valide par createProposalSchema) */ export const createProposal: RequestHandler< { recipeId: string }, unknown, - CreateProposalBody, + CreateProposalInput, unknown > = async (req, res, next) => { - const { proposedTitle, proposedContent } = req.body; + const { + proposedTitle, + proposedServings, + proposedPrepTime, + proposedCookTime, + proposedRestTime, + proposedSteps, + proposedIngredients, + } = req.body; const authenticatedUserId = req.session.userId; const { recipeId } = req.params; try { assertIsDefine(authenticatedUserId); - // Validation des champs requis - if (!proposedTitle?.trim()) { - throw createHttpError(400, "RECIPE_003: Title required"); - } - if (!proposedContent?.trim()) { - throw createHttpError(400, "RECIPE_004: Content required"); - } - // Recuperer la recette avec sa communaute const recipe = await prisma.recipe.findFirst({ where: { @@ -52,25 +83,19 @@ export const createProposal: RequestHandler< }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } // Verifier que c'est une recette communautaire if (!recipe.communityId) { - throw createHttpError( - 400, - "PROPOSAL_001: Cannot propose on personal recipe" - ); + throw createHttpError(400, PROPOSAL_001); } await requireMembership(authenticatedUserId, recipe.communityId!); // Verifier que l'utilisateur ne propose pas sur sa propre recette if (recipe.creatorId === authenticatedUserId) { - throw createHttpError( - 400, - "PROPOSAL_001: Cannot propose on your own recipe" - ); + throw createHttpError(400, PROPOSAL_001); } // Creer la proposition @@ -78,28 +103,29 @@ export const createProposal: RequestHandler< const newProposal = await tx.recipeUpdateProposal.create({ data: { proposedTitle: proposedTitle.trim(), - proposedContent: proposedContent.trim(), + proposedServings: proposedServings ?? null, + proposedPrepTime: proposedPrepTime ?? null, + proposedCookTime: proposedCookTime ?? null, + proposedRestTime: proposedRestTime ?? null, 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 steps proposes + await upsertProposalSteps(tx, newProposal.id, proposedSteps); + + // 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 +137,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({ @@ -166,15 +197,12 @@ export const getProposals: RequestHandler< }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } // Verifier que c'est une recette communautaire if (!recipe.communityId) { - throw createHttpError( - 400, - "PROPOSAL_001: Cannot list proposals on personal recipe" - ); + throw createHttpError(400, PROPOSAL_001); } await requireMembership(authenticatedUserId, recipe.communityId!); @@ -193,22 +221,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 +263,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, @@ -276,7 +276,7 @@ export const getProposal: RequestHandler< }); if (!proposal) { - throw createHttpError(404, "PROPOSAL_004: Proposal not found"); + throw createHttpError(404, PROPOSAL_004); } if (proposal.recipe.communityId) { @@ -314,17 +314,24 @@ export const acceptProposal: RequestHandler< select: { id: true, proposedTitle: true, - proposedContent: true, + proposedServings: true, + proposedPrepTime: true, + proposedCookTime: true, + proposedRestTime: true, status: true, createdAt: true, recipeId: true, proposerId: true, + proposedSteps: PROPOSAL_STEPS_SELECT, recipe: { select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, communityId: true, creatorId: true, originRecipeId: true, @@ -335,33 +342,27 @@ export const acceptProposal: RequestHandler< }); if (!proposal) { - throw createHttpError(404, "PROPOSAL_004: Proposal not found"); + throw createHttpError(404, PROPOSAL_004); } // Verifier que c'est une recette communautaire if (!proposal.recipe.communityId) { - throw createHttpError(400, "PROPOSAL_001: Cannot accept proposal on personal recipe"); + throw createHttpError(400, PROPOSAL_001); } // Verifier que l'utilisateur est le createur de la recette if (proposal.recipe.creatorId !== authenticatedUserId) { - throw createHttpError( - 403, - "RECIPE_002: Only the recipe creator can accept proposals" - ); + throw createHttpError(403, RECIPE_002); } // Verifier que la proposition est en status PENDING if (proposal.status !== "PENDING") { - throw createHttpError(400, "PROPOSAL_002: Proposal already decided"); + throw createHttpError(400, PROPOSAL_002); } // Verifier que la recette n'a pas ete modifiee depuis la creation de la proposition if (proposal.recipe.updatedAt > proposal.createdAt) { - throw createHttpError( - 409, - "PROPOSAL_003: Recipe has been modified since proposal was created" - ); + throw createHttpError(409, PROPOSAL_003); } const result = await acceptProposalService(proposalId, proposal, authenticatedUserId); @@ -406,16 +407,23 @@ export const rejectProposal: RequestHandler< select: { id: true, proposedTitle: true, - proposedContent: true, + proposedServings: true, + proposedPrepTime: true, + proposedCookTime: true, + proposedRestTime: true, status: true, recipeId: true, proposerId: true, + proposedSteps: PROPOSAL_STEPS_SELECT, recipe: { select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, communityId: true, creatorId: true, }, @@ -424,25 +432,22 @@ export const rejectProposal: RequestHandler< }); if (!proposal) { - throw createHttpError(404, "PROPOSAL_004: Proposal not found"); + throw createHttpError(404, PROPOSAL_004); } // Verifier que c'est une recette communautaire if (!proposal.recipe.communityId) { - throw createHttpError(400, "PROPOSAL_001: Cannot reject proposal on personal recipe"); + throw createHttpError(400, PROPOSAL_001); } // Verifier que l'utilisateur est le createur de la recette if (proposal.recipe.creatorId !== authenticatedUserId) { - throw createHttpError( - 403, - "RECIPE_002: Only the recipe creator can reject proposals" - ); + throw createHttpError(403, RECIPE_002); } // Verifier que la proposition est en status PENDING if (proposal.status !== "PENDING") { - throw createHttpError(400, "PROPOSAL_002: Proposal already decided"); + throw createHttpError(400, PROPOSAL_002); } const result = await rejectProposalService(proposalId, proposal); diff --git a/backend/src/controllers/recipeImage.ts b/backend/src/controllers/recipeImage.ts new file mode 100644 index 00000000..a88b7189 --- /dev/null +++ b/backend/src/controllers/recipeImage.ts @@ -0,0 +1,123 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import createHttpError from "http-errors"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { requireRecipeOwnership } from "../services/membershipService"; +import { + generatePresignedUploadUrl, + validateUploadedFile, + deleteObject, +} from "../services/storageService"; +import { buildImageUrl } from "../config/storage"; +import { RECIPE_001, RECIPE_005 } from "../constants/errorCodes"; + +/** + * POST /api/recipes/:recipeId/upload-url + * Genere une presigned PUT URL pour uploader une image de recette. + * Seul l'auteur de la recette peut uploader. + */ +export const getUploadUrl: RequestHandler = async (req, res, next) => { + const recipeId = req.params.recipeId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const recipe = await prisma.recipe.findUnique({ + where: { id: recipeId, deletedAt: null }, + }); + + if (!recipe) { + throw createHttpError(404, RECIPE_001); + } + + await requireRecipeOwnership(authenticatedUserId, recipe); + + const imageKey = `recipes/${recipeId}/cover.webp`; + const uploadUrl = await generatePresignedUploadUrl(imageKey); + + res.status(200).json({ uploadUrl, imageKey }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/recipes/:recipeId/confirm-upload + * Confirme l'upload et valide le fichier sur MinIO. + * Si invalide, supprime le fichier et renvoie une erreur. + */ +export const confirmUpload: RequestHandler = async (req, res, next) => { + const recipeId = req.params.recipeId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const recipe = await prisma.recipe.findUnique({ + where: { id: recipeId, deletedAt: null }, + }); + + if (!recipe) { + throw createHttpError(404, RECIPE_001); + } + + await requireRecipeOwnership(authenticatedUserId, recipe); + + const imageKey = `recipes/${recipeId}/cover.webp`; + + const validationError = await validateUploadedFile(imageKey); + if (validationError) { + await deleteObject(imageKey); + throw createHttpError(400, RECIPE_005(validationError)); + } + + await prisma.recipe.update({ + where: { id: recipeId }, + data: { imageKey }, + }); + + res.status(200).json({ + imageKey, + imageUrl: buildImageUrl(imageKey), + }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/recipes/:recipeId/image + * Supprime l'image de la recette (MinIO + DB). + */ +export const deleteImage: RequestHandler = async (req, res, next) => { + const recipeId = req.params.recipeId; + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const recipe = await prisma.recipe.findUnique({ + where: { id: recipeId, deletedAt: null }, + }); + + if (!recipe) { + throw createHttpError(404, RECIPE_001); + } + + await requireRecipeOwnership(authenticatedUserId, recipe); + + if (recipe.imageKey) { + await deleteObject(recipe.imageKey); + } + + await prisma.recipe.update({ + where: { id: recipeId }, + data: { imageKey: null }, + }); + + res.sendStatus(204); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/recipeImport.ts b/backend/src/controllers/recipeImport.ts new file mode 100644 index 00000000..8ecb518c --- /dev/null +++ b/backend/src/controllers/recipeImport.ts @@ -0,0 +1,25 @@ +import { RequestHandler } from "express"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { importFromUrl } from "../services/recipeImportService"; +import { ImportRecipeUrlInput } from "../schemas/recipeImport.schema"; + +export const importRecipeFromUrl: RequestHandler< + unknown, + unknown, + ImportRecipeUrlInput, + unknown +> = async (req, res, next) => { + const authenticatedUserId = req.session.userId; + + try { + assertIsDefine(authenticatedUserId); + + const { url } = req.body; + + const parsedRecipe = await importFromUrl(url); + + res.status(200).json({ data: parsedRecipe }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/recipeShare.ts b/backend/src/controllers/recipeShare.ts index 1b7dc7fc..ca5692ed 100644 --- a/backend/src/controllers/recipeShare.ts +++ b/backend/src/controllers/recipeShare.ts @@ -2,17 +2,26 @@ import { RequestHandler } from "express"; import prisma from "../util/db"; import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; -import { formatTags, formatIngredients } from "../util/responseFormatters"; -import { - forkRecipe, - publishRecipe, - getRecipeFamilyCommunities, -} from "../services/shareService"; +import { formatTags, formatIngredients, formatSteps } from "../util/responseFormatters"; +import { buildImageUrl } from "../config/storage"; +import { forkRecipe, publishRecipe, getRecipeFamilyCommunities } from "../services/shareService"; +import { requireRecipeAccess } from "../services/membershipService"; import appEvents from "../services/eventEmitter"; - -interface ShareRecipeBody { - targetCommunityId: string; -} +import { getModeratorIdsForTagNotification } from "../services/notificationService"; +import { + SHARE_002, + SHARE_003, + SHARE_004, + SHARE_005, + SHARE_006, + RECIPE_001, + RECIPE_002, + COMMUNITY_001, + COMMUNITY_002, + PUBLISH_002, + PUBLISH_003, +} from "../constants/errorCodes"; +import { ShareRecipeInput, PublishToCommunityInput } from "../schemas/recipeShare.schema"; /** * POST /api/recipes/:recipeId/share @@ -21,7 +30,7 @@ interface ShareRecipeBody { export const shareRecipe: RequestHandler< { recipeId: string }, unknown, - ShareRecipeBody, + ShareRecipeInput, unknown > = async (req, res, next) => { const authenticatedUserId = req.session.userId; @@ -31,38 +40,46 @@ export const shareRecipe: RequestHandler< try { assertIsDefine(authenticatedUserId); - if (!targetCommunityId?.trim()) { - throw createHttpError(400, "SHARE_001: Target community ID required"); - } - // 1. Recuperer la recette source avec ses relations const sourceRecipe = await prisma.recipe.findFirst({ where: { id: recipeId, deletedAt: null }, select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: 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 }, + select: { ingredientId: true, quantity: true, unitId: true, order: true }, + orderBy: { order: "asc" }, + }, + steps: { + select: { order: true, instruction: true }, orderBy: { order: "asc" }, }, }, }); if (!sourceRecipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } if (sourceRecipe.communityId === null) { - throw createHttpError(400, "SHARE_002: Cannot share personal recipes"); + throw createHttpError(400, SHARE_002); } if (sourceRecipe.communityId === targetCommunityId) { - throw createHttpError(400, "SHARE_003: Cannot share to same community"); + throw createHttpError(400, SHARE_003); } // Verifier que la communaute cible existe @@ -71,13 +88,17 @@ export const shareRecipe: RequestHandler< }); if (!targetCommunity) { - throw createHttpError(404, "COMMUNITY_002: Target community not found"); + throw createHttpError(404, COMMUNITY_002); } // Verifier membership dans les deux communautes const [sourceMembership, targetMembership] = await Promise.all([ prisma.userCommunity.findFirst({ - where: { userId: authenticatedUserId, communityId: sourceRecipe.communityId, deletedAt: null }, + where: { + userId: authenticatedUserId, + communityId: sourceRecipe.communityId, + deletedAt: null, + }, }), prisma.userCommunity.findFirst({ where: { userId: authenticatedUserId, communityId: targetCommunityId, deletedAt: null }, @@ -85,11 +106,11 @@ export const shareRecipe: RequestHandler< ]); if (!sourceMembership) { - throw createHttpError(403, "COMMUNITY_001: Not a member of source community"); + throw createHttpError(403, COMMUNITY_001); } if (!targetMembership) { - throw createHttpError(403, "SHARE_004: Not a member of target community"); + throw createHttpError(403, SHARE_004); } // Verifier permission: MODERATOR dans une des deux OU createur de la recette @@ -98,10 +119,7 @@ export const shareRecipe: RequestHandler< const isModeratorInTarget = targetMembership.role === "MODERATOR"; if (!isRecipeCreator && !isModeratorInSource && !isModeratorInTarget) { - throw createHttpError( - 403, - "SHARE_005: Must be recipe creator or moderator in one of the communities" - ); + throw createHttpError(403, SHARE_005); } // Verifier qu'il n'existe pas deja un partage vers cette communaute @@ -110,35 +128,39 @@ export const shareRecipe: RequestHandler< }); if (existingShare) { - throw createHttpError(400, "SHARE_006: Recipe already shared with this community"); + throw createHttpError(400, SHARE_006); } - 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, + servings: forkResult.servings, + prepTime: forkResult.prepTime, + cookTime: forkResult.cookTime, + restTime: forkResult.restTime, + imageUrl: forkResult.imageKey ? buildImageUrl(forkResult.imageKey) : null, + createdAt: forkResult.createdAt, + updatedAt: forkResult.updatedAt, + creatorId: forkResult.creatorId, + communityId: forkResult.communityId, + community: forkResult.community, + originRecipeId: forkResult.originRecipeId, + sharedFromCommunityId: forkResult.sharedFromCommunityId, + isVariant: forkResult.isVariant, + steps: formatSteps(forkResult.steps), + tags: formatTags(forkResult.tags), + ingredients: formatIngredients(forkResult.ingredients), }; // Emit to both source and target communities @@ -152,19 +174,30 @@ 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); } }; -interface PublishToCommunityBody { - communityIds: string[]; -} - /** * POST /api/recipes/:recipeId/publish * Publier une recette personnelle vers une ou plusieurs communautes @@ -172,7 +205,7 @@ interface PublishToCommunityBody { export const publishToCommunities: RequestHandler< { recipeId: string }, unknown, - PublishToCommunityBody, + PublishToCommunityInput, unknown > = async (req, res, next) => { const authenticatedUserId = req.session.userId; @@ -182,37 +215,45 @@ export const publishToCommunities: RequestHandler< try { assertIsDefine(authenticatedUserId); - if (!communityIds || !Array.isArray(communityIds) || communityIds.length === 0) { - throw createHttpError(400, "PUBLISH_001: At least one community ID required"); - } - const sourceRecipe = await prisma.recipe.findFirst({ where: { id: recipeId, deletedAt: null }, select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, creatorId: true, communityId: 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 }, + select: { ingredientId: true, quantity: true, unitId: true, order: true }, + orderBy: { order: "asc" }, + }, + steps: { + select: { order: true, instruction: true }, orderBy: { order: "asc" }, }, }, }); if (!sourceRecipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } if (sourceRecipe.communityId !== null) { - throw createHttpError(400, "PUBLISH_002: Can only publish personal recipes"); + throw createHttpError(400, PUBLISH_002); } if (sourceRecipe.creatorId !== authenticatedUserId) { - throw createHttpError(403, "RECIPE_002: Cannot access this recipe"); + throw createHttpError(403, RECIPE_002); } // Verifier membership @@ -223,7 +264,7 @@ export const publishToCommunities: RequestHandler< const memberCommunityIds = new Set(memberships.map((m) => m.communityId)); for (const cid of communityIds) { if (!memberCommunityIds.has(cid)) { - throw createHttpError(403, `PUBLISH_003: Not a member of community ${cid}`); + throw createHttpError(403, PUBLISH_003(cid)); } } @@ -236,11 +277,34 @@ export const publishToCommunities: RequestHandler< const newCommunityIds = communityIds.filter((cid) => !alreadySharedCommunityIds.has(cid)); if (newCommunityIds.length === 0) { - res.status(200).json({ data: [], message: "Recipe already shared to all selected communities" }); + res + .status(200) + .json({ data: [], message: "Recipe already shared to all selected communities" }); return; } - const createdRecipes = await publishRecipe(authenticatedUserId, sourceRecipe, newCommunityIds); + const { recipes: createdRecipes, pendingTagIds } = await publishRecipe( + authenticatedUserId, + sourceRecipe, + newCommunityIds + ); + + // Notifier les moderateurs si des tags PENDING ont ete crees + if (pendingTagIds.length > 0) { + for (const cid of newCommunityIds) { + const moderatorIds = await getModeratorIdsForTagNotification(cid); + if (moderatorIds.length > 0) { + appEvents.emitActivity({ + type: "tag:pending", + userId: authenticatedUserId, + communityId: cid, + recipeId: recipeId, + targetUserIds: moderatorIds, + metadata: { pendingTagIds }, + }); + } + } + } res.status(201).json({ data: createdRecipes.filter(Boolean) }); } catch (error) { @@ -264,10 +328,22 @@ export const getRecipeCommunities: RequestHandler< try { assertIsDefine(authenticatedUserId); + // Verifier que l'utilisateur a acces a la recette + const recipe = await prisma.recipe.findFirst({ + where: { id: recipeId, deletedAt: null }, + select: { creatorId: true, communityId: true }, + }); + + if (!recipe) { + throw createHttpError(404, RECIPE_001); + } + + await requireRecipeAccess(authenticatedUserId, recipe); + const communities = await getRecipeFamilyCommunities(recipeId); if (communities === null) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } res.status(200).json({ data: communities }); diff --git a/backend/src/controllers/recipeVariants.ts b/backend/src/controllers/recipeVariants.ts index b35c48f0..e438ddb7 100644 --- a/backend/src/controllers/recipeVariants.ts +++ b/backend/src/controllers/recipeVariants.ts @@ -7,6 +7,8 @@ import { parsePagination, buildPaginationMeta } from "../util/pagination"; import { RECIPE_TAGS_SELECT } from "../util/prismaSelects"; import { requireRecipeAccess } from "../services/membershipService"; import { formatTags } from "../util/responseFormatters"; +import { buildImageUrl } from "../config/storage"; +import { RECIPE_001 } from "../constants/errorCodes"; interface GetVariantsQuery { limit?: string; @@ -31,7 +33,7 @@ export const getVariants: RequestHandler< try { assertIsDefine(authenticatedUserId); - // Recuperer la recette parent + // Recuperer la recette courante const recipe = await prisma.recipe.findFirst({ where: { id: recipeId, @@ -41,67 +43,73 @@ export const getVariants: RequestHandler< id: true, communityId: true, creatorId: true, + isVariant: true, + originRecipeId: true, }, }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } await requireRecipeAccess(authenticatedUserId, recipe); - // Construire la clause where pour les variantes + // Remonter a la recette originale si on est sur une variante + const rootId = recipe.isVariant && recipe.originRecipeId ? recipe.originRecipeId : recipe.id; + + // Lister toute la famille (original + variantes) sauf la recette courante const whereClause: Prisma.RecipeWhereInput = { - originRecipeId: recipeId, - isVariant: true, deletedAt: null, + id: { not: recipeId }, + OR: [{ id: rootId }, { originRecipeId: rootId, isVariant: true }], }; - // Si c'est une recette communautaire, ne retourner que les variantes de la meme communaute + // Si c'est une recette communautaire, ne retourner que celles de la meme communaute if (recipe.communityId !== null) { whereClause.communityId = recipe.communityId; } - // Recuperer les variantes - const variants = await prisma.recipe.findMany({ - where: whereClause, - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - updatedAt: true, - creatorId: true, - communityId: true, - originRecipeId: true, - isVariant: true, - creator: { - select: { - id: true, - username: true, + // Compter le total et recuperer les variantes paginées + const [variants, total] = await Promise.all([ + prisma.recipe.findMany({ + where: whereClause, + select: { + id: true, + title: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, + createdAt: true, + updatedAt: true, + creatorId: true, + communityId: true, + originRecipeId: true, + isVariant: true, + creator: { + select: { + id: true, + username: true, + }, }, + tags: RECIPE_TAGS_SELECT, }, - tags: RECIPE_TAGS_SELECT, - }, - }); - - // Trier par MAX(createdAt, updatedAt) DESC - const sortedVariants = variants.sort((a, b) => { - const maxA = a.updatedAt > a.createdAt ? a.updatedAt : a.createdAt; - const maxB = b.updatedAt > b.createdAt ? b.updatedAt : b.createdAt; - return maxB.getTime() - maxA.getTime(); - }); - - // Appliquer pagination - const total = sortedVariants.length; - const paginatedVariants = sortedVariants.slice(offset, offset + limit); + orderBy: { updatedAt: "desc" }, + skip: offset, + take: limit, + }), + prisma.recipe.count({ where: whereClause }), + ]); - const data = paginatedVariants.map((variant) => ({ + const data = variants.map((variant) => ({ id: variant.id, title: variant.title, - content: variant.content, - imageUrl: variant.imageUrl, + servings: variant.servings, + prepTime: variant.prepTime, + cookTime: variant.cookTime, + restTime: variant.restTime, + imageUrl: variant.imageKey ? buildImageUrl(variant.imageKey) : null, createdAt: variant.createdAt, updatedAt: variant.updatedAt, creatorId: variant.creatorId, @@ -114,7 +122,7 @@ export const getVariants: RequestHandler< res.status(200).json({ data, - pagination: buildPaginationMeta(total, limit, offset, paginatedVariants.length), + pagination: buildPaginationMeta(total, limit, offset, variants.length), }); } catch (error) { next(error); diff --git a/backend/src/controllers/recipes.ts b/backend/src/controllers/recipes.ts index 30a850c8..6db4d572 100644 --- a/backend/src/controllers/recipes.ts +++ b/backend/src/controllers/recipes.ts @@ -3,12 +3,24 @@ import prisma from "../util/db"; import createHttpError from "http-errors"; import { assertIsDefine } from "../util/assertIsDefine"; import { Prisma } from "@prisma/client"; -import { isValidHttpUrl } from "../util/validation"; +import { MAX_FILTER_ITEMS, MAX_SEARCH_LENGTH } from "../util/validation"; +import { buildImageUrl } from "../config/storage"; import { parsePagination, buildPaginationMeta } from "../util/pagination"; -import { RECIPE_TAGS_SELECT } from "../util/prismaSelects"; +import { + RECIPE_TAGS_SELECT, + RECIPE_STEPS_SELECT, + RECIPE_INGREDIENTS_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 { VALIDATION_001, RECIPE_001 } from "../constants/errorCodes"; +import { formatTags, formatIngredients, formatSteps } from "../util/responseFormatters"; +import { + createRecipe as createRecipeService, + updateRecipe as updateRecipeService, +} from "../services/recipeService"; +import appEvents from "../services/eventEmitter"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; +import type { CreateRecipeInput, UpdateRecipeInput } from "../schemas/recipe.schema"; interface GetRecipesQuery { limit?: string; @@ -18,16 +30,44 @@ interface GetRecipesQuery { search?: string; } -export const getRecipes: RequestHandler = async (req, res, next) => { +export const getRecipes: RequestHandler = async ( + req, + res, + next +) => { const authenticatedUserId = req.session.userId; const { limit, offset } = parsePagination(req.query); - const tagsFilter = req.query.tags?.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean) || []; - const ingredientsFilter = req.query.ingredients?.split(",").map((i) => i.trim().toLowerCase()).filter(Boolean) || []; + const tagsFilter = + req.query.tags + ?.split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) || []; + const ingredientsFilter = + req.query.ingredients + ?.split(",") + .map((i) => i.trim().toLowerCase()) + .filter(Boolean) || []; const searchFilter = req.query.search?.trim() || ""; try { assertIsDefine(authenticatedUserId); + if (tagsFilter.length > MAX_FILTER_ITEMS) { + throw createHttpError(400, VALIDATION_001(`Too many tag filters (max ${MAX_FILTER_ITEMS})`)); + } + if (ingredientsFilter.length > MAX_FILTER_ITEMS) { + throw createHttpError( + 400, + VALIDATION_001(`Too many ingredient filters (max ${MAX_FILTER_ITEMS})`) + ); + } + if (searchFilter.length > MAX_SEARCH_LENGTH) { + throw createHttpError( + 400, + VALIDATION_001(`Search query too long (max ${MAX_SEARCH_LENGTH} chars)`) + ); + } + const whereClause: Prisma.RecipeWhereInput = { creatorId: authenticatedUserId, communityId: null, @@ -44,27 +84,31 @@ export const getRecipes: RequestHandler 0) { - andConditions.push(...tagsFilter.map((tagName) => ({ - tags: { - some: { - tag: { - name: tagName, + andConditions.push( + ...tagsFilter.map((tagName) => ({ + tags: { + some: { + tag: { + name: tagName, + }, }, }, - }, - }))); + })) + ); } if (ingredientsFilter.length > 0) { - andConditions.push(...ingredientsFilter.map((ingredientName) => ({ - ingredients: { - some: { - ingredient: { - name: ingredientName, + andConditions.push( + ...ingredientsFilter.map((ingredientName) => ({ + ingredients: { + some: { + ingredient: { + name: ingredientName, + }, }, }, - }, - }))); + })) + ); } if (andConditions.length > 0) { @@ -77,7 +121,11 @@ export const getRecipes: RequestHandler ({ id: recipe.id, title: recipe.title, - imageUrl: recipe.imageUrl, + servings: recipe.servings, + prepTime: recipe.prepTime, + cookTime: recipe.cookTime, + restTime: recipe.restTime, + imageUrl: recipe.imageKey ? buildImageUrl(recipe.imageKey) : null, createdAt: recipe.createdAt, updatedAt: recipe.updatedAt, tags: formatTags(recipe.tags), @@ -124,8 +176,11 @@ export const getRecipe: RequestHandler = async (req, res, next) => { select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, createdAt: true, updatedAt: true, creatorId: true, @@ -151,37 +206,14 @@ export const getRecipe: RequestHandler = async (req, res, next) => { name: true, }, }, - tags: { - select: { - tag: { - select: { - id: true, - name: true, - }, - }, - }, - }, - ingredients: { - select: { - id: true, - quantity: true, - order: true, - ingredient: { - select: { - id: true, - name: true, - }, - }, - }, - orderBy: { - order: "asc", - }, - }, + steps: RECIPE_STEPS_SELECT, + tags: RECIPE_TAGS_SELECT, + ingredients: RECIPE_INGREDIENTS_SELECT, }, }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } await requireRecipeAccess(authenticatedUserId, recipe); @@ -189,8 +221,11 @@ export const getRecipe: RequestHandler = async (req, res, next) => { const responseData = { id: recipe.id, title: recipe.title, - content: recipe.content, - imageUrl: recipe.imageUrl, + servings: recipe.servings, + prepTime: recipe.prepTime, + cookTime: recipe.cookTime, + restTime: recipe.restTime, + imageUrl: recipe.imageKey ? buildImageUrl(recipe.imageKey) : null, createdAt: recipe.createdAt, updatedAt: recipe.updatedAt, creatorId: recipe.creatorId, @@ -201,6 +236,7 @@ export const getRecipe: RequestHandler = async (req, res, next) => { isVariant: recipe.isVariant, sharedFromCommunityId: recipe.sharedFromCommunityId, sharedFromCommunity: recipe.sharedFromCommunity, + steps: formatSteps(recipe.steps), tags: formatTags(recipe.tags), ingredients: formatIngredients(recipe.ingredients), }; @@ -211,40 +247,30 @@ export const getRecipe: RequestHandler = async (req, res, next) => { } }; -interface IngredientInput { - name: string; - quantity?: string; -} - -interface CreateRecipeBody { - title?: string; - content?: string; - imageUrl?: string; - tags?: string[]; - ingredients?: IngredientInput[]; -} - -export const createRecipe: RequestHandler = async (req, res, next) => { - const { title, content, imageUrl, tags = [], ingredients = [] } = req.body; +/** + * POST /api/recipes + * Creer une recette personnelle (body valide par createRecipeSchema) + */ +export const createRecipe: RequestHandler = async ( + req, + res, + next +) => { + const { title, servings, prepTime, cookTime, restTime, steps, tags, ingredients } = req.body; const authenticatedUserId = req.session.userId; try { assertIsDefine(authenticatedUserId); - if (!title?.trim()) { - throw createHttpError(400, "RECIPE_003: Title required"); - } - - if (!content?.trim()) { - throw createHttpError(400, "RECIPE_004: Content required"); - } - - if (!isValidHttpUrl(imageUrl)) { - throw createHttpError(400, "RECIPE_005: Invalid image URL"); - } - const newRecipe = await createRecipeService(authenticatedUserId, { - title, content, imageUrl, tags, ingredients, + title, + servings, + prepTime, + cookTime, + restTime, + steps, + tags, + ingredients, }); if (!newRecipe) { @@ -254,11 +280,15 @@ export const createRecipe: RequestHandler = async (req, res, next) => { +/** + * PATCH /api/recipes/:recipeId + * Modifier une recette (body valide par updateRecipeSchema) + */ +export const updateRecipe: RequestHandler< + { recipeId: string }, + unknown, + UpdateRecipeInput, + unknown +> = async (req, res, next) => { const recipeId = req.params.recipeId; - const { title, content, imageUrl, tags, ingredients } = req.body; + const { title, servings, prepTime, cookTime, restTime, steps, tags, ingredients } = req.body; const authenticatedUserId = req.session.userId; try { assertIsDefine(authenticatedUserId); - if (title !== undefined && !title?.trim()) { - throw createHttpError(400, "RECIPE_003: Title required"); - } - - if (content !== undefined && !content?.trim()) { - throw createHttpError(400, "RECIPE_004: Content required"); - } - const recipe = await prisma.recipe.findUnique({ where: { id: recipeId, deletedAt: null }, }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } await requireRecipeOwnership(authenticatedUserId, recipe); - if (imageUrl !== undefined && !isValidHttpUrl(imageUrl)) { - throw createHttpError(400, "RECIPE_005: Invalid image URL"); - } - - const updatedRecipe = await updateRecipeService(recipeId, { - title, content, imageUrl, tags, ingredients, - }, recipe); + const { result: updatedRecipe, pendingTagIds } = await updateRecipeService( + recipeId, + { + title, + servings, + prepTime, + cookTime, + restTime, + steps, + tags, + ingredients, + }, + recipe, + authenticatedUserId + ); if (!updatedRecipe) { throw createHttpError(500, "Failed to update recipe"); } + // Notifier les moderateurs si des tags PENDING ont ete crees + if (pendingTagIds.length > 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, - content: updatedRecipe.content, - imageUrl: updatedRecipe.imageUrl, + servings: updatedRecipe.servings, + prepTime: updatedRecipe.prepTime, + cookTime: updatedRecipe.cookTime, + restTime: updatedRecipe.restTime, + imageUrl: updatedRecipe.imageKey ? buildImageUrl(updatedRecipe.imageKey) : null, createdAt: updatedRecipe.createdAt, updatedAt: updatedRecipe.updatedAt, creatorId: updatedRecipe.creatorId, + steps: formatSteps(updatedRecipe.steps), tags: formatTags(updatedRecipe.tags), ingredients: formatIngredients(updatedRecipe.ingredients), }; @@ -349,7 +395,7 @@ export const deleteRecipe: RequestHandler = async (req, res, next) => { }); if (!recipe) { - throw createHttpError(404, "RECIPE_001: Recipe not found"); + throw createHttpError(404, RECIPE_001); } await requireRecipeOwnership(authenticatedUserId, recipe); diff --git a/backend/src/controllers/tagPreferences.ts b/backend/src/controllers/tagPreferences.ts new file mode 100644 index 00000000..d7c82cd7 --- /dev/null +++ b/backend/src/controllers/tagPreferences.ts @@ -0,0 +1,82 @@ +import { RequestHandler } from "express"; +import prisma from "../util/db"; +import { assertIsDefine } from "../util/assertIsDefine"; +import { requireMembership } from "../services/membershipService"; +import { UpdateTagPreferenceInput } from "../schemas/tag.schema"; +// ============================================================================= +// 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, + UpdateTagPreferenceInput, + unknown +> = async (req, res, next) => { + const userId = req.session.userId; + const { communityId } = req.params; + const { showTags } = req.body; + + try { + assertIsDefine(userId); + + // 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); + } +}; diff --git a/backend/src/controllers/tagSuggestions.ts b/backend/src/controllers/tagSuggestions.ts new file mode 100644 index 00000000..0e420e6c --- /dev/null +++ b/backend/src/controllers/tagSuggestions.ts @@ -0,0 +1,332 @@ +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"; +import { getModeratorIdsForTagNotification } from "../services/notificationService"; +import { RECIPE_001, RECIPE_002, TAG_003, TAG_006, TAG_007 } from "../constants/errorCodes"; +import { CreateTagSuggestionInput } from "../schemas/tag.schema"; + +const MAX_TAGS_PER_RECIPE = 10; + +/** + * POST /api/recipes/:recipeId/tag-suggestions + * Suggerer un tag sur une recette communautaire d'autrui + */ +export const createTagSuggestion: RequestHandler< + { recipeId: string }, + unknown, + CreateTagSuggestionInput, + unknown +> = async (req, res, next) => { + const { tagName } = req.body; + const authenticatedUserId = req.session.userId; + const { recipeId } = req.params; + + try { + assertIsDefine(authenticatedUserId); + + // tagName is already normalized by Zod schema + const normalized = tagName; + + // 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); + } + + // Doit etre une recette communautaire + if (!recipe.communityId) { + throw createHttpError(400, TAG_007); + } + + // Verifier membership + await requireMembership(authenticatedUserId, recipe.communityId); + + // Bloquer auto-suggestion + if (recipe.creatorId === authenticatedUserId) { + throw createHttpError(400, TAG_007); + } + + // Verifier doublon + const existing = await prisma.tagSuggestion.findUnique({ + where: { + recipeId_tagName_suggestedById: { + recipeId, + tagName: normalized, + suggestedById: authenticatedUserId, + }, + }, + }); + if (existing) { + throw createHttpError(409, TAG_006); + } + + // Verifier max tags sur la recette + if (recipe._count.tags >= MAX_TAGS_PER_RECIPE) { + throw createHttpError(400, TAG_003); + } + + // 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); + } + + if (!recipe.communityId) { + throw createHttpError(400, TAG_007); + } + + 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 Prisma.TagSuggestionWhereInput["status"]; + } + + 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); + } + + // 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); + } + + // Verifier que c'est le owner + if (suggestion.recipe.creatorId !== authenticatedUserId) { + throw createHttpError(403, RECIPE_002); + } + + // Verifier statut + if (suggestion.status !== "PENDING_OWNER") { + throw createHttpError(400, TAG_007); + } + + 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 }, + }); + + // 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); + } +}; + +/** + * 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); + } + + // 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); + } + + // Verifier que c'est le owner + if (suggestion.recipe.creatorId !== authenticatedUserId) { + throw createHttpError(403, RECIPE_002); + } + + // Verifier statut + if (suggestion.status !== "PENDING_OWNER") { + throw createHttpError(400, TAG_007); + } + + 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/controllers/tags.ts b/backend/src/controllers/tags.ts index 36671143..2625c294 100644 --- a/backend/src/controllers/tags.ts +++ b/backend/src/controllers/tags.ts @@ -2,58 +2,75 @@ import { RequestHandler } from "express"; import prisma from "../util/db"; import { assertIsDefine } from "../util/assertIsDefine"; import { parsePagination } from "../util/pagination"; +import { getAutocompleteTags } from "../services/tagService"; interface SearchTagsQuery { search?: string; limit?: string; + communityId?: string; } -export const searchTags: RequestHandler = async (req, res, next) => { +export const searchTags: 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", + // Over-fetch en contexte perso pour compenser les doublons potentiels (meme nom, communityId differents) + const fetchLimit = communityId ? limit : limit * 3; + const tags = await getAutocompleteTags(authenticatedUserId, communityId, search, fetchLimit); + + // 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 data = tags.map((tag) => ({ + const countMap = new Map(counts.map((c) => [c.tagId, c._count.tagId])); + + const enriched = 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, })); + // En contexte perso : agreger par nom pour dedupliquer les tags identiques venant de communautes differentes + let data; + if (!communityId) { + const nameMap = new Map(); + for (const tag of enriched) { + const existing = nameMap.get(tag.name); + if (existing) { + existing.recipeCount += tag.recipeCount; + // GLOBAL prend le dessus si au moins un tag du groupe est GLOBAL + if (tag.scope === "GLOBAL") existing.scope = "GLOBAL"; + } else { + nameMap.set(tag.name, { ...tag, communityId: null }); + } + } + data = Array.from(nameMap.values()).slice(0, limit); + } else { + data = enriched; + } + res.status(200).json({ data }); } catch (error) { next(error); 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/controllers/users.ts b/backend/src/controllers/users.ts index ed472af6..37f3278b 100644 --- a/backend/src/controllers/users.ts +++ b/backend/src/controllers/users.ts @@ -2,16 +2,12 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; import bcrypt from "bcrypt"; import prisma from "../util/db"; -import { - EMAIL_REGEX, - USERNAME_REGEX, - MIN_USERNAME_LENGTH, - MIN_PASSWORD_LENGTH, -} from "../util/validation"; +import { AUTH_001, AUTH_006, AUTH_007, AUTH_011, USER_001 } from "../constants/errorCodes"; +import { UpdateProfileInput } from "../schemas/user.schema"; export const searchUsers: RequestHandler = async (req, res, next) => { try { - const query = (req.query.q as string || "").trim(); + const query = ((req.query.q as string) || "").trim(); if (query.length < 3) { res.status(200).json({ data: [] }); @@ -34,68 +30,53 @@ export const searchUsers: RequestHandler = async (req, res, next) => { } }; -interface UpdateProfileBody { - username?: string; - email?: string; - currentPassword?: string; - newPassword?: string; -} - -export const updateProfile: RequestHandler = async (req, res, next) => { +export const updateProfile: RequestHandler = async ( + req, + res, + next +) => { try { const userId = req.session.userId; - if (!userId) throw createHttpError(401, "AUTH_001: Not authenticated"); + if (!userId) throw createHttpError(401, AUTH_001); const { username, email, currentPassword, newPassword } = req.body; const user = await prisma.user.findUnique({ where: { id: userId, deletedAt: null }, }); - if (!user) throw createHttpError(404, "USER_001: User not found"); + if (!user) throw createHttpError(404, USER_001); const updates: { username?: string; email?: string; password?: string } = {}; if (username && username !== user.username) { - if (username.length < MIN_USERNAME_LENGTH) { - throw createHttpError(400, `AUTH_004: Username must be at least ${MIN_USERNAME_LENGTH} characters`); - } - if (!USERNAME_REGEX.test(username)) { - throw createHttpError(400, "AUTH_004: Username can only contain letters, numbers, and underscores"); - } const existing = await prisma.user.findFirst({ where: { username, deletedAt: null, id: { not: userId } }, }); - if (existing) throw createHttpError(409, "AUTH_006: Username already taken"); + if (existing) throw createHttpError(409, AUTH_006); updates.username = username; } if (email && email !== user.email) { - if (!EMAIL_REGEX.test(email)) { - throw createHttpError(400, "AUTH_003: Invalid email format"); - } const existing = await prisma.user.findFirst({ where: { email, deletedAt: null, id: { not: userId } }, }); - if (existing) throw createHttpError(409, "AUTH_007: Email already in use"); + if (existing) throw createHttpError(409, AUTH_007); updates.email = email; } - if (newPassword) { - if (!currentPassword) { - throw createHttpError(400, "AUTH_010: Current password is required to change password"); - } + if (newPassword && currentPassword) { const passwordMatch = await bcrypt.compare(currentPassword, user.password); if (!passwordMatch) { - throw createHttpError(401, "AUTH_011: Current password is incorrect"); - } - if (newPassword.length < MIN_PASSWORD_LENGTH) { - throw createHttpError(400, `AUTH_005: Password must be at least ${MIN_PASSWORD_LENGTH} characters`); + throw createHttpError(401, AUTH_011); } updates.password = await bcrypt.hash(newPassword, 10); } if (Object.keys(updates).length === 0) { - res.status(200).json({ message: "No changes", user: { id: user.id, username: user.username, email: user.email } }); + res.status(200).json({ + message: "No changes", + user: { id: user.id, username: user.username, email: user.email }, + }); return; } diff --git a/backend/src/jobs/imageCleanup.ts b/backend/src/jobs/imageCleanup.ts new file mode 100644 index 00000000..75e60575 --- /dev/null +++ b/backend/src/jobs/imageCleanup.ts @@ -0,0 +1,108 @@ +import cron from "node-cron"; +import prisma from "../util/db"; +import logger from "../util/logger"; +import { deleteObject } from "../services/storageService"; + +const RETENTION_DAYS = 7; +const BATCH_SIZE = 100; + +/** + * Supprime les images MinIO des recettes soft-deleted depuis > RETENTION_DAYS jours. + * Met imageKey a null en DB apres suppression. + */ +async function cleanupRecipeImages(): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS); + + const recipes = await prisma.recipe.findMany({ + where: { + deletedAt: { not: null, lte: cutoffDate }, + imageKey: { not: null }, + }, + select: { id: true, imageKey: true }, + take: BATCH_SIZE, + }); + + let deleted = 0; + for (const recipe of recipes) { + try { + await deleteObject(recipe.imageKey!); + await prisma.recipe.update({ + where: { id: recipe.id }, + data: { imageKey: null }, + }); + deleted++; + } catch (err) { + logger.error({ err, recipeId: recipe.id }, "Failed to cleanup recipe image"); + } + } + + return deleted; +} + +/** + * Supprime les images MinIO des communautes soft-deleted depuis > RETENTION_DAYS jours. + * Met imageKey a null en DB apres suppression. + */ +async function cleanupCommunityImages(): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS); + + const communities = await prisma.community.findMany({ + where: { + deletedAt: { not: null, lte: cutoffDate }, + imageKey: { not: null }, + }, + select: { id: true, imageKey: true }, + take: BATCH_SIZE, + }); + + let deleted = 0; + for (const community of communities) { + try { + await deleteObject(community.imageKey!); + await prisma.community.update({ + where: { id: community.id }, + data: { imageKey: null }, + }); + deleted++; + } catch (err) { + logger.error({ err, communityId: community.id }, "Failed to cleanup community image"); + } + } + + return deleted; +} + +/** + * Execute le nettoyage complet des images orphelines. + */ +export async function cleanupOrphanImages(): Promise { + const recipeCount = await cleanupRecipeImages(); + const communityCount = await cleanupCommunityImages(); + const total = recipeCount + communityCount; + + if (total > 0) { + logger.info({ recipeCount, communityCount, total }, "Image cleanup completed"); + } else { + logger.debug("Image cleanup: nothing to delete"); + } + + return total; +} + +/** + * Demarre le job cron de nettoyage des images. + * Execute tous les jours a 3h30. + */ +export function startImageCleanupJob() { + cron.schedule("30 3 * * *", async () => { + try { + await cleanupOrphanImages(); + } catch (err) { + logger.error({ err }, "Image cleanup job failed"); + } + }); + + logger.info("Image cleanup job scheduled (daily at 03:30)"); +} diff --git a/backend/src/jobs/notificationCleanup.ts b/backend/src/jobs/notificationCleanup.ts new file mode 100644 index 00000000..d3dc797e --- /dev/null +++ b/backend/src/jobs/notificationCleanup.ts @@ -0,0 +1,70 @@ +import cron from "node-cron"; +import prisma from "../util/db"; +import logger from "../util/logger"; + +const RETENTION_DAYS = 30; +const BATCH_SIZE = 500; + +/** + * Supprime les notifications lues depuis plus de RETENTION_DAYS jours. + * Execute en batches pour eviter de verrouiller la table longtemps. + * Retourne le nombre total de notifications supprimees. + */ +export async function cleanupReadNotifications(): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS); + + let totalDeleted = 0; + let batchDeleted: number; + + do { + // Trouver un batch d'IDs a supprimer + const batch = await prisma.notification.findMany({ + where: { + readAt: { not: null, lte: cutoffDate }, + }, + select: { id: true }, + take: BATCH_SIZE, + }); + + if (batch.length === 0) break; + + const result = await prisma.notification.deleteMany({ + where: { + id: { in: batch.map((n) => n.id) }, + }, + }); + + batchDeleted = result.count; + totalDeleted += batchDeleted; + + logger.debug({ batchDeleted, totalDeleted }, "Notification cleanup batch completed"); + } while (batchDeleted === BATCH_SIZE); + + if (totalDeleted > 0) { + logger.info( + { totalDeleted, cutoffDate: cutoffDate.toISOString() }, + "Notification cleanup completed" + ); + } else { + logger.debug("Notification cleanup: nothing to delete"); + } + + return totalDeleted; +} + +/** + * Demarre le job cron de nettoyage des notifications. + * Execute tous les jours a 3h du matin. + */ +export function startNotificationCleanupJob() { + cron.schedule("0 3 * * *", async () => { + try { + await cleanupReadNotifications(); + } catch (err) { + logger.error({ err }, "Notification cleanup job failed"); + } + }); + + logger.info("Notification cleanup job scheduled (daily at 03:00)"); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 62824fbd..15c64bf3 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,10 +1,11 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; +import { AUTH_001 } from "../constants/errorCodes"; export const requireAuth: RequestHandler = (req, res, next) => { if (req.session.userId) { next(); } else { - next(createHttpError(401, "AUTH_001: Not authenticated")); + next(createHttpError(401, AUTH_001)); } -}; \ No newline at end of file +}; diff --git a/backend/src/middleware/community.ts b/backend/src/middleware/community.ts index 003f1170..58880257 100644 --- a/backend/src/middleware/community.ts +++ b/backend/src/middleware/community.ts @@ -1,22 +1,19 @@ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import prisma from "../util/db"; +import { AUTH_001, COMMUNITY_001, COMMUNITY_002 } from "../constants/errorCodes"; /** * Middleware pour verifier que l'utilisateur est membre de la communaute. * Attend communityId dans req.params. * Ajoute req.userCommunity avec les infos du membership. */ -export const memberOf = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { +export const memberOf = async (req: Request, res: Response, next: NextFunction): Promise => { const userId = req.session.userId; const communityId = req.params.communityId; if (!userId) { - return next(createHttpError(401, "AUTH_001: Not authenticated")); + return next(createHttpError(401, AUTH_001)); } if (!communityId) { @@ -56,7 +53,7 @@ export const memberOf = async ( return next(createHttpError(404, "Community not found")); } - return next(createHttpError(403, "COMMUNITY_001: Not a member")); + return next(createHttpError(403, COMMUNITY_001)); } // Attach membership info to request for use in controllers @@ -77,9 +74,7 @@ export const requireCommunityRole = (requiredRole: "MEMBER" | "MODERATOR") => { const userCommunity = req.userCommunity; if (!userCommunity) { - return next( - createHttpError(500, "requireCommunityRole must be used after memberOf") - ); + return next(createHttpError(500, "requireCommunityRole must be used after memberOf")); } // Role hierarchy: MODERATOR > MEMBER @@ -92,9 +87,7 @@ export const requireCommunityRole = (requiredRole: "MEMBER" | "MODERATOR") => { const requiredRoleLevel = roleHierarchy[requiredRole] || 0; if (userRoleLevel < requiredRoleLevel) { - return next( - createHttpError(403, "COMMUNITY_002: Permission insufficient") - ); + return next(createHttpError(403, COMMUNITY_002)); } next(); diff --git a/backend/src/middleware/csrf.ts b/backend/src/middleware/csrf.ts new file mode 100644 index 00000000..367f7bab --- /dev/null +++ b/backend/src/middleware/csrf.ts @@ -0,0 +1,64 @@ +import crypto from "crypto"; +import { Request, RequestHandler } from "express"; +import env from "../util/validateEnv"; +import { CSRF_001 } from "../constants/errorCodes"; + +const SAFE_METHODS = ["GET", "HEAD", "OPTIONS"]; +const CSRF_COOKIE_NAME = "XSRF-TOKEN"; +const CSRF_HEADER_NAME = "x-xsrf-token"; + +/** + * Parse un cookie specifique depuis le header Cookie de la requete. + */ +function getCookieValue(req: Request, name: string): string | undefined { + const header = req.headers.cookie; + if (!header) return undefined; + + for (const cookie of header.split(";")) { + const [key, ...rest] = cookie.split("="); + if (key.trim() === name) { + return decodeURIComponent(rest.join("=").trim()); + } + } + return undefined; +} + +/** + * CSRF Protection - Double Submit Cookie Pattern + * + * - Sur chaque requete, s'assure qu'un cookie XSRF-TOKEN existe (le cree sinon) + * - Sur les requetes mutantes (POST, PATCH, PUT, DELETE), verifie que le header + * X-XSRF-TOKEN correspond au cookie + * - Un attaquant cross-origin ne peut pas lire le cookie (same-origin policy) + * donc ne peut pas forger le header + * - Desactive en environnement de test (comme les rate limiters) + */ +export const csrfProtection: RequestHandler = (req, res, next) => { + // Desactive en test (coherent avec les rate limiters) + if (env.NODE_ENV === "test") { + return next(); + } + + // S'assurer que le cookie CSRF existe + let token = getCookieValue(req, CSRF_COOKIE_NAME); + if (!token) { + token = crypto.randomBytes(32).toString("hex"); + res.cookie(CSRF_COOKIE_NAME, token, { + httpOnly: false, // Le frontend JS doit pouvoir le lire + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + } + + // Valider sur les requetes mutantes + if (!SAFE_METHODS.includes(req.method)) { + const headerToken = req.headers[CSRF_HEADER_NAME] as string | undefined; + if (!headerToken || headerToken !== token) { + res.status(403).json({ error: CSRF_001 }); + return; + } + } + + next(); +}; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 00000000..b56dd1cc --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,22 @@ +import { NextFunction, Request, Response } from "express"; +import { isHttpError } from "http-errors"; +import logger from "../util/logger"; +import { ValidationError } from "../util/validation"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (error: unknown, req: Request, res: Response, next: NextFunction) => { + // ValidationError → 400 (input validation) + if (error instanceof ValidationError) { + res.status(400).json({ error: error.message }); + return; + } + + logger.error({ err: error, path: req.path, method: req.method }, "Unhandled error"); + let errorMessage = "An unknown error occurred"; + let statusCode = 500; + if (isHttpError(error)) { + statusCode = error.status; + errorMessage = error.message; + } + res.status(statusCode).json({ error: errorMessage }); +}; diff --git a/backend/src/middleware/httpLogger.ts b/backend/src/middleware/httpLogger.ts index 5e8887bd..21f27b10 100644 --- a/backend/src/middleware/httpLogger.ts +++ b/backend/src/middleware/httpLogger.ts @@ -1,14 +1,14 @@ +import { Request } from "express"; import pinoHttp from "pino-http"; import logger from "../util/logger"; export const httpLogger = pinoHttp({ logger, autoLogging: { - ignore: (req) => (req.url === "/health"), + ignore: (req) => req.url === "/health", }, customProps: (req) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userId = (req as any).session?.userId; + const userId = (req as unknown as Request).session?.userId; return userId ? { userId } : {}; }, }); diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index d6a856e9..cc77bfd4 100644 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -1,7 +1,8 @@ import helmet from "helmet"; -import rateLimit from "express-rate-limit"; import { RequestHandler } from "express"; import env from "../util/validateEnv"; +import { ADMIN_011, AUTH_012 } from "../constants/errorCodes"; +import { createRateLimiter } from "../config/rateLimiter"; /** * Helmet configuration with strict security headers @@ -26,41 +27,29 @@ export const helmetMiddleware = helmet({ xFrameOptions: { action: "deny" }, xContentTypeOptions: true, // X-Content-Type-Options: nosniff referrerPolicy: { policy: "strict-origin-when-cross-origin" }, - hsts: env.NODE_ENV === "production" ? { - maxAge: 31536000, // 1 an - includeSubDomains: true, - preload: true, - } : false, + hsts: + env.NODE_ENV === "production" + ? { + maxAge: 31536000, // 1 an + includeSubDomains: true, + preload: true, + } + : false, }); -/** - * Rate limiter global pour les routes admin (hors auth) - * Plus permissif que le rate limiter auth (30 req/min) - */ -/** - * Rate limiter pour les routes d'authentification user (signup/login). - * 10 tentatives par IP par fenetre de 15 minutes. - */ -export const authRateLimiter: RequestHandler = env.NODE_ENV === "test" - ? ((_req, _res, next) => next()) - : rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10, - message: { error: "AUTH_002: Too many attempts, please try again later" }, - standardHeaders: true, - legacyHeaders: false, - }); +/** Rate limiter user auth (signup/login) : 10 req / 15 min */ +export const authRateLimiter: RequestHandler = createRateLimiter({ + windowMs: 15 * 60 * 1000, + max: 10, + message: AUTH_012, +}); -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"); - }, +/** Rate limiter global admin (hors auth) : 30 req / min */ +export const adminRateLimiter: RequestHandler = createRateLimiter({ + windowMs: 60 * 1000, + max: 30, + message: ADMIN_011, + skip: (req) => req.path.startsWith("/auth"), }); /** diff --git a/backend/src/middleware/validateBody.ts b/backend/src/middleware/validateBody.ts new file mode 100644 index 00000000..be5cf5a5 --- /dev/null +++ b/backend/src/middleware/validateBody.ts @@ -0,0 +1,20 @@ +import { RequestHandler } from "express"; +import { ZodSchema } from "zod"; +import createHttpError from "http-errors"; + +/** + * Middleware qui valide req.body avec un schema Zod. + * En cas d'echec, renvoie 400 avec le premier message d'erreur. + * En cas de succes, remplace req.body par les donnees parsees (stripping des champs inconnus). + */ +export function validateBody(schema: ZodSchema): RequestHandler { + return (req, _res, next) => { + const result = schema.safeParse(req.body); + if (!result.success) { + const firstIssue = result.error.issues[0]; + return next(createHttpError(400, firstIssue.message)); + } + req.body = result.data; + next(); + }; +} diff --git a/backend/src/middleware/validateUUID.ts b/backend/src/middleware/validateUUID.ts new file mode 100644 index 00000000..f20ff3d2 --- /dev/null +++ b/backend/src/middleware/validateUUID.ts @@ -0,0 +1,22 @@ +import { RequestHandler } from "express"; +import { VALIDATION_001 } from "../constants/errorCodes"; + +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Middleware qui valide que tous les params de route qui sont des UUID + * (noms finissant par "Id" ou egal a "id") respectent le format UUID v4. + * Retourne 400 VALIDATION_001 si invalide. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const validateUUID: RequestHandler = (req, res, next) => { + for (const [key, value] of Object.entries(req.params as Record)) { + if ((key === "id" || key.endsWith("Id")) && !UUID_V4_REGEX.test(value)) { + res.status(400).json({ + error: VALIDATION_001(`Invalid UUID format for parameter '${key}'`), + }); + return; + } + } + next(); +}; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index cc6fa514..274c6961 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,14 +1,16 @@ import express from "express"; import * as authController from "../controllers/auth"; import { authRateLimiter } from "../middleware/security"; +import { validateBody } from "../middleware/validateBody"; +import { signupSchema, loginSchema } from "../schemas/auth.schema"; const router = express.Router(); // POST /api/auth/signup - Creer un nouvel utilisateur -router.post("/signup", authRateLimiter, authController.signUp); +router.post("/signup", authRateLimiter, validateBody(signupSchema), authController.signUp); // POST /api/auth/login - Authentifier un utilisateur -router.post("/login", authRateLimiter, authController.login); +router.post("/login", authRateLimiter, validateBody(loginSchema), authController.login); // POST /api/auth/logout - Deconnecter l'utilisateur router.post("/logout", authController.logout); diff --git a/backend/src/routes/communities.ts b/backend/src/routes/communities.ts index 5ac97b20..aa1e6786 100644 --- a/backend/src/routes/communities.ts +++ b/backend/src/routes/communities.ts @@ -1,10 +1,19 @@ import express from "express"; import * as CommunitiesController from "../controllers/communities"; +import * as CommunityImageController from "../controllers/communityImage"; 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"; import { memberOf, requireCommunityRole } from "../middleware/community"; +import { validateUUID } from "../middleware/validateUUID"; +import { validateBody } from "../middleware/validateBody"; +import { createRecipeSchema } from "../schemas/recipe.schema"; +import { createCommunitySchema, updateCommunitySchema } from "../schemas/community.schema"; +import { createInviteSchema } from "../schemas/invite.schema"; +import { promoteMemberSchema } from "../schemas/member.schema"; +import { communityTagSchema } from "../schemas/tag.schema"; const router = express.Router(); @@ -12,47 +21,91 @@ const router = express.Router(); router.get("/", CommunitiesController.getCommunities); // Create a new community -router.post("/", CommunitiesController.createCommunity); +router.post("/", validateBody(createCommunitySchema), CommunitiesController.createCommunity); // Get community details (requires membership) -router.get("/:communityId", memberOf, CommunitiesController.getCommunity); +router.get("/:communityId", validateUUID, memberOf, CommunitiesController.getCommunity); // Update community (requires MODERATOR role) router.patch( "/:communityId", + validateUUID, memberOf, requireCommunityRole("MODERATOR"), + validateBody(updateCommunitySchema), CommunitiesController.updateCommunity ); +// ===================================== +// Image upload routes (MODERATOR only) +// ===================================== + +router.post( + "/:communityId/upload-url", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityImageController.getUploadUrl +); + +router.post( + "/:communityId/confirm-upload", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityImageController.confirmUpload +); + +router.delete( + "/:communityId/image", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityImageController.deleteImage +); + // ===================================== // Recipe routes (any member) // ===================================== // List community recipes -router.get("/:communityId/recipes", memberOf, CommunityRecipesController.getCommunityRecipes); +router.get( + "/:communityId/recipes", + validateUUID, + memberOf, + CommunityRecipesController.getCommunityRecipes +); // Create a community recipe -router.post("/:communityId/recipes", memberOf, CommunityRecipesController.createCommunityRecipe); +router.post( + "/:communityId/recipes", + validateUUID, + memberOf, + validateBody(createRecipeSchema), + CommunityRecipesController.createCommunityRecipe +); // ===================================== // Member routes // ===================================== // List community members (any member) -router.get("/:communityId/members", memberOf, MembersController.getMembers); +router.get("/:communityId/members", validateUUID, memberOf, MembersController.getMembers); // Promote a member (MODERATOR only) router.patch( "/:communityId/members/:userId", + validateUUID, memberOf, requireCommunityRole("MODERATOR"), + validateBody(promoteMemberSchema), MembersController.promoteMember ); // Leave community (self) or kick member (MODERATOR) router.delete( "/:communityId/members/:userId", + validateUUID, memberOf, MembersController.removeMember ); @@ -64,6 +117,7 @@ router.delete( // List invitations for a community router.get( "/:communityId/invites", + validateUUID, memberOf, requireCommunityRole("MODERATOR"), InvitesController.getInvites @@ -72,24 +126,92 @@ router.get( // Create an invitation router.post( "/:communityId/invites", + validateUUID, memberOf, requireCommunityRole("MODERATOR"), + validateBody(createInviteSchema), InvitesController.createInvite ); // Cancel an invitation router.delete( "/:communityId/invites/:inviteId", + validateUUID, memberOf, requireCommunityRole("MODERATOR"), InvitesController.cancelInvite ); +// ===================================== +// Tag management routes (MODERATOR only) +// ===================================== + +// List community tags (APPROVED + PENDING) +router.get( + "/:communityId/tags", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.getCommunityTags +); + +// Create a community tag +router.post( + "/:communityId/tags", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + validateBody(communityTagSchema), + CommunityTagsController.createCommunityTag +); + +// Rename a community tag +router.patch( + "/:communityId/tags/:tagId", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + validateBody(communityTagSchema), + CommunityTagsController.updateCommunityTag +); + +// Delete a community tag +router.delete( + "/:communityId/tags/:tagId", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.deleteCommunityTag +); + +// Approve a pending tag +router.post( + "/:communityId/tags/:tagId/approve", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.approveCommunityTag +); + +// Reject a pending tag +router.post( + "/:communityId/tags/:tagId/reject", + validateUUID, + memberOf, + requireCommunityRole("MODERATOR"), + CommunityTagsController.rejectCommunityTag +); + // ===================================== // Activity feed // ===================================== // Get community activity feed (any member) -router.get("/:communityId/activity", memberOf, ActivityController.getCommunityActivity); +router.get( + "/:communityId/activity", + validateUUID, + memberOf, + ActivityController.getCommunityActivity +); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/ingredients.ts b/backend/src/routes/ingredients.ts index d67dd8d9..3997eebf 100644 --- a/backend/src/routes/ingredients.ts +++ b/backend/src/routes/ingredients.ts @@ -1,8 +1,10 @@ import express from "express"; import * as IngredientsController from "../controllers/ingredients"; +import { validateUUID } from "../middleware/validateUUID"; const router = express.Router(); router.get("/", IngredientsController.searchIngredients); +router.get("/:id/suggested-unit", validateUUID, IngredientsController.getSuggestedUnit); export default router; diff --git a/backend/src/routes/invites.ts b/backend/src/routes/invites.ts index 788e9f62..e5d52965 100644 --- a/backend/src/routes/invites.ts +++ b/backend/src/routes/invites.ts @@ -1,12 +1,13 @@ import express from "express"; import * as InvitesController from "../controllers/invites"; +import { validateUUID } from "../middleware/validateUUID"; const router = express.Router(); // Accept an invitation -router.post("/:inviteId/accept", InvitesController.acceptInvite); +router.post("/:inviteId/accept", validateUUID, InvitesController.acceptInvite); // Reject an invitation -router.post("/:inviteId/reject", InvitesController.rejectInvite); +router.post("/:inviteId/reject", validateUUID, InvitesController.rejectInvite); export default router; diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 00000000..308f9759 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,38 @@ +import express from "express"; +import * as notificationsController from "../controllers/notifications"; +import { validateUUID } from "../middleware/validateUUID"; +import { validateBody } from "../middleware/validateBody"; +import { + markBatchAsReadSchema, + markAllAsReadSchema, + updateNotificationPreferenceSchema, +} from "../schemas/notification.schema"; + +const router = express.Router(); + +// GET /api/notifications +router.get("/", notificationsController.getNotifications); + +// GET /api/notifications/unread-count +router.get("/unread-count", notificationsController.getUnreadCount); + +// PATCH /api/notifications/read (batch) - doit etre avant /:id/read +router.patch("/read", validateBody(markBatchAsReadSchema), notificationsController.markBatchAsRead); + +// PATCH /api/notifications/read-all +router.patch("/read-all", validateBody(markAllAsReadSchema), notificationsController.markAllAsRead); + +// PATCH /api/notifications/:id/read +router.patch("/:id/read", validateUUID, notificationsController.markAsRead); + +// GET /api/notifications/preferences +router.get("/preferences", notificationsController.getPreferences); + +// PUT /api/notifications/preferences +router.put( + "/preferences", + validateBody(updateNotificationPreferenceSchema), + notificationsController.updatePreference +); + +export default router; diff --git a/backend/src/routes/proposals.ts b/backend/src/routes/proposals.ts index 2174017b..074590a5 100644 --- a/backend/src/routes/proposals.ts +++ b/backend/src/routes/proposals.ts @@ -1,15 +1,16 @@ import express from "express"; import * as ProposalsController from "../controllers/proposals"; +import { validateUUID } from "../middleware/validateUUID"; const router = express.Router(); // GET /api/proposals/:proposalId - Detail d'une proposition -router.get("/:proposalId", ProposalsController.getProposal); +router.get("/:proposalId", validateUUID, ProposalsController.getProposal); // POST /api/proposals/:proposalId/accept - Accepter une proposition -router.post("/:proposalId/accept", ProposalsController.acceptProposal); +router.post("/:proposalId/accept", validateUUID, ProposalsController.acceptProposal); // POST /api/proposals/:proposalId/reject - Refuser une proposition -router.post("/:proposalId/reject", ProposalsController.rejectProposal); +router.post("/:proposalId/reject", validateUUID, ProposalsController.rejectProposal); export default router; diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 3747557e..b16b0e73 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -1,36 +1,88 @@ import express from "express"; import * as RecipesController from "../controllers/recipes"; +import * as RecipeImageController from "../controllers/recipeImage"; import * as RecipeVariantsController from "../controllers/recipeVariants"; import * as RecipeShareController from "../controllers/recipeShare"; +import * as RecipeImportController from "../controllers/recipeImport"; import * as ProposalsController from "../controllers/proposals"; +import * as TagSuggestionsController from "../controllers/tagSuggestions"; +import { validateUUID } from "../middleware/validateUUID"; +import { validateBody } from "../middleware/validateBody"; +import { createRecipeSchema, updateRecipeSchema } from "../schemas/recipe.schema"; +import { createProposalSchema } from "../schemas/proposal.schema"; +import { shareRecipeSchema, publishToCommunitySchema } from "../schemas/recipeShare.schema"; +import { createTagSuggestionSchema } from "../schemas/tag.schema"; +import { importRecipeUrlSchema } from "../schemas/recipeImport.schema"; const router = express.Router(); +// Import route (must be before /:recipeId to avoid UUID validation) +router.post( + "/import-url", + validateBody(importRecipeUrlSchema), + RecipeImportController.importRecipeFromUrl +); + router.get("/", RecipesController.getRecipes); -router.get("/:recipeId", RecipesController.getRecipe); +router.get("/:recipeId", validateUUID, RecipesController.getRecipe); + +router.post("/", validateBody(createRecipeSchema), RecipesController.createRecipe); -router.post("/", RecipesController.createRecipe); +router.patch( + "/:recipeId", + validateUUID, + validateBody(updateRecipeSchema), + RecipesController.updateRecipe +); -router.patch("/:recipeId", RecipesController.updateRecipe); +router.delete("/:recipeId", validateUUID, RecipesController.deleteRecipe); -router.delete("/:recipeId", RecipesController.deleteRecipe); +// Image upload routes +router.post("/:recipeId/upload-url", validateUUID, RecipeImageController.getUploadUrl); +router.post("/:recipeId/confirm-upload", validateUUID, RecipeImageController.confirmUpload); +router.delete("/:recipeId/image", validateUUID, RecipeImageController.deleteImage); // Variants routes on recipes -router.get("/:recipeId/variants", RecipeVariantsController.getVariants); +router.get("/:recipeId/variants", validateUUID, RecipeVariantsController.getVariants); // Proposals routes on recipes -router.get("/:recipeId/proposals", ProposalsController.getProposals); +router.get("/:recipeId/proposals", validateUUID, ProposalsController.getProposals); + +router.post( + "/:recipeId/proposals", + validateUUID, + validateBody(createProposalSchema), + ProposalsController.createProposal +); + +// Tag suggestions routes on recipes +router.get("/:recipeId/tag-suggestions", validateUUID, TagSuggestionsController.getTagSuggestions); -router.post("/:recipeId/proposals", ProposalsController.createProposal); +router.post( + "/:recipeId/tag-suggestions", + validateUUID, + validateBody(createTagSuggestionSchema), + TagSuggestionsController.createTagSuggestion +); // Share recipe to another community (fork) -router.post("/:recipeId/share", RecipeShareController.shareRecipe); +router.post( + "/:recipeId/share", + validateUUID, + validateBody(shareRecipeSchema), + RecipeShareController.shareRecipe +); // Publish personal recipe to communities -router.post("/:recipeId/publish", RecipeShareController.publishToCommunities); +router.post( + "/:recipeId/publish", + validateUUID, + validateBody(publishToCommunitySchema), + RecipeShareController.publishToCommunities +); // Get communities where a recipe has copies -router.get("/:recipeId/communities", RecipeShareController.getRecipeCommunities); +router.get("/:recipeId/communities", validateUUID, RecipeShareController.getRecipeCommunities); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/tagSuggestions.ts b/backend/src/routes/tagSuggestions.ts new file mode 100644 index 00000000..2d75e164 --- /dev/null +++ b/backend/src/routes/tagSuggestions.ts @@ -0,0 +1,13 @@ +import express from "express"; +import * as TagSuggestionsController from "../controllers/tagSuggestions"; +import { validateUUID } from "../middleware/validateUUID"; + +const router = express.Router(); + +// POST /api/tag-suggestions/:id/accept +router.post("/:id/accept", validateUUID, TagSuggestionsController.acceptTagSuggestion); + +// POST /api/tag-suggestions/:id/reject +router.post("/:id/reject", validateUUID, TagSuggestionsController.rejectTagSuggestion); + +export default router; 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/routes/users.ts b/backend/src/routes/users.ts index 21e3a1da..08b08e74 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -2,6 +2,11 @@ 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"; +import { validateUUID } from "../middleware/validateUUID"; +import { validateBody } from "../middleware/validateBody"; +import { updateProfileSchema } from "../schemas/user.schema"; +import { updateTagPreferenceSchema } from "../schemas/tag.schema"; const router = express.Router(); @@ -9,7 +14,7 @@ const router = express.Router(); router.get("/search", UsersController.searchUsers); // Update my profile -router.patch("/me", UsersController.updateProfile); +router.patch("/me", validateBody(updateProfileSchema), UsersController.updateProfile); // Get my received invitations router.get("/me/invites", InvitesController.getMyInvites); @@ -17,4 +22,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", + validateUUID, + validateBody(updateTagPreferenceSchema), + TagPreferencesController.updateTagPreference +); + export default router; diff --git a/backend/src/schemas/auth.schema.ts b/backend/src/schemas/auth.schema.ts new file mode 100644 index 00000000..79994d3a --- /dev/null +++ b/backend/src/schemas/auth.schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { + EMAIL_REGEX, + USERNAME_REGEX, + MIN_USERNAME_LENGTH, + MAX_USERNAME_LENGTH, + MIN_PASSWORD_LENGTH, + MAX_PASSWORD_LENGTH, +} from "../util/validation"; +import { + AUTH_002, + AUTH_003, + AUTH_004_LENGTH, + AUTH_004_FORMAT, + AUTH_005, + VALIDATION_001_TYPE, +} from "../constants/errorCodes"; + +export const signupSchema = z.object({ + username: z + .string({ + error: (issue) => (issue.input === undefined ? AUTH_002 : VALIDATION_001_TYPE), + }) + .min(MIN_USERNAME_LENGTH, AUTH_004_LENGTH(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH)) + .max(MAX_USERNAME_LENGTH, AUTH_004_LENGTH(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH)) + .regex(USERNAME_REGEX, AUTH_004_FORMAT), + email: z + .string({ + error: (issue) => (issue.input === undefined ? AUTH_002 : VALIDATION_001_TYPE), + }) + .regex(EMAIL_REGEX, AUTH_003), + password: z + .string({ + error: (issue) => (issue.input === undefined ? AUTH_002 : VALIDATION_001_TYPE), + }) + .min(MIN_PASSWORD_LENGTH, AUTH_005(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH)) + .max(MAX_PASSWORD_LENGTH, AUTH_005(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH)), +}); + +export const loginSchema = z.object({ + username: z.string({ + error: (issue) => (issue.input === undefined ? AUTH_002 : VALIDATION_001_TYPE), + }), + password: z.string({ + error: (issue) => (issue.input === undefined ? AUTH_002 : VALIDATION_001_TYPE), + }), +}); + +export type SignupInput = z.infer; +export type LoginInput = z.infer; diff --git a/backend/src/schemas/common.schema.ts b/backend/src/schemas/common.schema.ts new file mode 100644 index 00000000..a2331bb8 --- /dev/null +++ b/backend/src/schemas/common.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +/** Schema UUID v4 */ +export const uuidSchema = z.uuid("VALIDATION_001: Invalid UUID format"); + +/** Schema pagination (query params) */ +export const paginationSchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +export type PaginationInput = z.infer; diff --git a/backend/src/schemas/community.schema.ts b/backend/src/schemas/community.schema.ts new file mode 100644 index 00000000..ce535d00 --- /dev/null +++ b/backend/src/schemas/community.schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { COMMUNITY_VALIDATION } from "../util/validation"; +import { VALIDATION_001_TYPE } from "../constants/errorCodes"; + +const nameSchema = z + .string({ error: () => VALIDATION_001_TYPE }) + .min( + COMMUNITY_VALIDATION.NAME_MIN, + `VALIDATION_001: Name must be at least ${COMMUNITY_VALIDATION.NAME_MIN} characters` + ) + .max( + COMMUNITY_VALIDATION.NAME_MAX, + `VALIDATION_001: Name must be at most ${COMMUNITY_VALIDATION.NAME_MAX} characters` + ); + +const descriptionSchema = z + .string({ error: () => VALIDATION_001_TYPE }) + .max( + COMMUNITY_VALIDATION.DESCRIPTION_MAX, + `VALIDATION_001: Description must be at most ${COMMUNITY_VALIDATION.DESCRIPTION_MAX} characters` + ) + .optional(); + +/** Schema for creating a community */ +export const createCommunitySchema = z.object({ + name: nameSchema, + description: descriptionSchema, +}); + +/** Schema for updating a community */ +export const updateCommunitySchema = z + .object({ + name: nameSchema.optional(), + description: z + .string({ error: () => VALIDATION_001_TYPE }) + .max( + COMMUNITY_VALIDATION.DESCRIPTION_MAX, + `VALIDATION_001: Description must be at most ${COMMUNITY_VALIDATION.DESCRIPTION_MAX} characters` + ) + .optional(), + }) + .refine((data) => data.name !== undefined || data.description !== undefined, { + message: "VALIDATION_001: No fields to update", + }); + +export type CreateCommunityInput = z.infer; +export type UpdateCommunityInput = z.infer; diff --git a/backend/src/schemas/invite.schema.ts b/backend/src/schemas/invite.schema.ts new file mode 100644 index 00000000..70614b8d --- /dev/null +++ b/backend/src/schemas/invite.schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { EMAIL_REGEX } from "../util/validation"; +import { AUTH_003, INVITE_004, INVITE_005, VALIDATION_001_TYPE } from "../constants/errorCodes"; +import { uuidSchema } from "./common.schema"; + +/** Schema for creating an invite (exactly one of email, username, userId required) */ +export const createInviteSchema = z + .object({ + email: z + .string({ error: () => VALIDATION_001_TYPE }) + .regex(EMAIL_REGEX, AUTH_003) + .optional(), + username: z.string({ error: () => VALIDATION_001_TYPE }).optional(), + userId: uuidSchema.optional(), + }) + .superRefine((data, ctx) => { + const providedFields = [data.email, data.username, data.userId].filter(Boolean); + + if (providedFields.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: INVITE_004, + }); + } else if (providedFields.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: INVITE_005, + }); + } + }); + +export type CreateInviteInput = z.infer; diff --git a/backend/src/schemas/member.schema.ts b/backend/src/schemas/member.schema.ts new file mode 100644 index 00000000..013e1b7e --- /dev/null +++ b/backend/src/schemas/member.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { MEMBER_001, MEMBER_002 } from "../constants/errorCodes"; + +/** Schema for promoting a member to MODERATOR */ +export const promoteMemberSchema = z.object({ + role: z + .string({ error: () => MEMBER_001 }) + .refine((val) => val === "MODERATOR", { message: MEMBER_002 }), +}); + +export type PromoteMemberInput = z.infer; diff --git a/backend/src/schemas/notification.schema.ts b/backend/src/schemas/notification.schema.ts new file mode 100644 index 00000000..5e6ac176 --- /dev/null +++ b/backend/src/schemas/notification.schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { NOTIF_003, NOTIF_004, NOTIF_005 } from "../constants/errorCodes"; +import { uuidSchema } from "./common.schema"; + +const VALID_CATEGORIES = ["INVITATION", "RECIPE_PROPOSAL", "TAG", "INGREDIENT", "MODERATION"]; + +/** Schema for batch marking notifications as read */ +export const markBatchAsReadSchema = z.object({ + ids: z.array(z.string()).min(1, NOTIF_004).max(100, NOTIF_004), +}); + +/** Schema for marking all notifications as read */ +export const markAllAsReadSchema = z.object({ + category: z + .string() + .refine((val) => VALID_CATEGORIES.includes(val), { message: NOTIF_003 }) + .optional(), +}); + +/** Schema for updating notification preferences */ +export const updateNotificationPreferenceSchema = z.object({ + category: z.string().refine((val) => VALID_CATEGORIES.includes(val), { message: NOTIF_003 }), + enabled: z.boolean({ error: () => NOTIF_005 }), + communityId: uuidSchema.nullable().optional(), +}); + +export type MarkBatchAsReadInput = z.infer; +export type MarkAllAsReadInput = z.infer; +export type UpdateNotificationPreferenceInput = z.infer; diff --git a/backend/src/schemas/proposal.schema.ts b/backend/src/schemas/proposal.schema.ts new file mode 100644 index 00000000..73813817 --- /dev/null +++ b/backend/src/schemas/proposal.schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { MAX_TITLE_LENGTH } from "../util/validation"; +import { + RECIPE_003, + RECIPE_006, + RECIPE_007, + RECIPE_008, + INGREDIENT_003, +} from "../constants/errorCodes"; + +const stepSchema = z.object({ + instruction: z.string({ error: RECIPE_007 }).trim().min(1, RECIPE_007).max(5000, RECIPE_007), +}); + +const ingredientSchema = z.object({ + name: z.string(), + quantity: z.number().positive().max(99999).nullable().optional(), + unitId: z.string().optional(), +}); + +const timeSchema = z + .number({ error: RECIPE_008 }) + .int(RECIPE_008) + .min(0, RECIPE_008) + .max(10000, RECIPE_008) + .nullable(); + +export const createProposalSchema = z.object({ + proposedTitle: z + .string({ error: RECIPE_003 }) + .trim() + .min(1, RECIPE_003) + .max(MAX_TITLE_LENGTH, RECIPE_003), + proposedServings: z + .number({ error: RECIPE_006 }) + .int(RECIPE_006) + .min(1, RECIPE_006) + .max(100, RECIPE_006) + .nullable() + .optional(), + proposedPrepTime: timeSchema.optional(), + proposedCookTime: timeSchema.optional(), + proposedRestTime: timeSchema.optional(), + proposedSteps: z.array(stepSchema, { error: RECIPE_007 }).min(1, RECIPE_007), + proposedIngredients: z.array(ingredientSchema).max(50, INGREDIENT_003).optional(), +}); + +export type CreateProposalInput = z.infer; diff --git a/backend/src/schemas/recipe.schema.ts b/backend/src/schemas/recipe.schema.ts new file mode 100644 index 00000000..15a2f83e --- /dev/null +++ b/backend/src/schemas/recipe.schema.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { MAX_TITLE_LENGTH, MAX_TAGS_PER_RECIPE } from "../util/validation"; +import { + VALIDATION_001, + RECIPE_003, + RECIPE_006, + RECIPE_007, + RECIPE_008, + RECIPE_009, + TAG_003, +} from "../constants/errorCodes"; + +// --- Building blocks --- + +const stepSchema = z.object({ + instruction: z.string({ error: RECIPE_007 }).trim().min(1, RECIPE_007).max(5000, RECIPE_007), +}); + +const ingredientSchema = z.object({ + name: z.string(), + quantity: z + .number({ error: VALIDATION_001("ingredient quantity must be a number") }) + .positive(VALIDATION_001("ingredient quantity must be positive")) + .max(99999, VALIDATION_001("ingredient quantity must be <= 99999")) + .nullable() + .optional(), + unitId: z.string().optional(), +}); + +const timeSchema = z + .number({ error: RECIPE_008 }) + .int(RECIPE_008) + .min(0, RECIPE_008) + .max(10000, RECIPE_008) + .nullable(); + +// Title: type error → VALIDATION_001, missing/empty/too long → RECIPE_003 +const titleSchema = z + .string({ + error: (issue) => + issue.input === undefined ? RECIPE_003 : VALIDATION_001("title must be a string"), + }) + .trim() + .min(1, RECIPE_003) + .max(MAX_TITLE_LENGTH, VALIDATION_001(`title must be at most ${MAX_TITLE_LENGTH} characters`)); + +// --- Create recipe (personal + community) --- + +export const createRecipeSchema = z.object({ + title: titleSchema, + servings: z.number({ error: RECIPE_006 }).int(RECIPE_006).min(1, RECIPE_006).max(100, RECIPE_006), + prepTime: timeSchema.optional(), + cookTime: timeSchema.optional(), + restTime: timeSchema.optional(), + steps: z.array(stepSchema, { error: RECIPE_007 }).min(1, RECIPE_007), + tags: z + .array(z.string(), { error: VALIDATION_001("tags must be an array") }) + .max(MAX_TAGS_PER_RECIPE, TAG_003) + .optional() + .default([]), + ingredients: z.array(ingredientSchema).optional().default([]), +}); + +// --- Update recipe (all optional) --- + +export const updateRecipeSchema = z.object({ + title: titleSchema.optional(), + servings: z + .number({ error: RECIPE_006 }) + .int(RECIPE_006) + .min(1, RECIPE_006) + .max(100, RECIPE_006) + .optional(), + prepTime: timeSchema.optional(), + cookTime: timeSchema.optional(), + restTime: timeSchema.optional(), + steps: z.array(stepSchema, { error: RECIPE_007 }).min(1, RECIPE_007).optional(), + tags: z + .array(z.string(), { error: VALIDATION_001("tags must be an array") }) + .max(MAX_TAGS_PER_RECIPE, RECIPE_009(MAX_TAGS_PER_RECIPE)) + .optional(), + ingredients: z.array(ingredientSchema).optional(), +}); + +export type CreateRecipeInput = z.infer; +export type UpdateRecipeInput = z.infer; diff --git a/backend/src/schemas/recipeImport.schema.ts b/backend/src/schemas/recipeImport.schema.ts new file mode 100644 index 00000000..2c4e6460 --- /dev/null +++ b/backend/src/schemas/recipeImport.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { IMPORT_001 } from "../constants/errorCodes"; + +/** Schema for importing a recipe from a URL */ +export const importRecipeUrlSchema = z.object({ + url: z.string({ error: () => IMPORT_001 }).url(IMPORT_001), +}); + +export type ImportRecipeUrlInput = z.infer; diff --git a/backend/src/schemas/recipeShare.schema.ts b/backend/src/schemas/recipeShare.schema.ts new file mode 100644 index 00000000..1d5f3226 --- /dev/null +++ b/backend/src/schemas/recipeShare.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { SHARE_001, PUBLISH_001 } from "../constants/errorCodes"; +import { uuidSchema } from "./common.schema"; + +/** Schema for sharing a recipe to another community */ +export const shareRecipeSchema = z.object({ + targetCommunityId: uuidSchema.refine((val) => val.trim().length > 0, { message: SHARE_001 }), +}); + +/** Schema for publishing a personal recipe to communities */ +export const publishToCommunitySchema = z.object({ + communityIds: z.array(uuidSchema).min(1, PUBLISH_001), +}); + +export type ShareRecipeInput = z.infer; +export type PublishToCommunityInput = z.infer; diff --git a/backend/src/schemas/tag.schema.ts b/backend/src/schemas/tag.schema.ts new file mode 100644 index 00000000..eb439127 --- /dev/null +++ b/backend/src/schemas/tag.schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { TAG_001, VALIDATION_001_TYPE } from "../constants/errorCodes"; + +/** Validation du nom de tag: 2-50 caracteres, normalise lowercase */ +const tagNameSchema = z + .string({ error: () => VALIDATION_001_TYPE }) + .min(1, TAG_001("Tag name is required")) + .max(50, TAG_001("Tag name must be between 2 and 50 characters")) + .transform((val) => val.trim().toLowerCase()) + .refine((val) => val.length >= 2, { + message: TAG_001("Tag name must be between 2 and 50 characters"), + }); + +/** Schema for creating a tag suggestion on a recipe */ +export const createTagSuggestionSchema = z.object({ + tagName: tagNameSchema, +}); + +/** Schema for creating/updating a community tag */ +export const communityTagSchema = z.object({ + name: tagNameSchema, +}); + +/** Schema for updating tag visibility preference */ +export const updateTagPreferenceSchema = z.object({ + showTags: z.boolean({ error: () => TAG_001("showTags must be a boolean") }), +}); + +export type CreateTagSuggestionInput = z.infer; +export type CommunityTagInput = z.infer; +export type UpdateTagPreferenceInput = z.infer; diff --git a/backend/src/schemas/user.schema.ts b/backend/src/schemas/user.schema.ts new file mode 100644 index 00000000..8d9e6ea2 --- /dev/null +++ b/backend/src/schemas/user.schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { + EMAIL_REGEX, + USERNAME_REGEX, + MIN_USERNAME_LENGTH, + MAX_USERNAME_LENGTH, + MIN_PASSWORD_LENGTH, + MAX_PASSWORD_LENGTH, +} from "../util/validation"; +import { + AUTH_003, + AUTH_004_LENGTH, + AUTH_004_FORMAT, + AUTH_005, + AUTH_010, + VALIDATION_001_TYPE, +} from "../constants/errorCodes"; + +/** Schema for updating user profile */ +export const updateProfileSchema = z + .object({ + username: z + .string({ error: () => VALIDATION_001_TYPE }) + .min(MIN_USERNAME_LENGTH, AUTH_004_LENGTH(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH)) + .max(MAX_USERNAME_LENGTH, AUTH_004_LENGTH(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH)) + .regex(USERNAME_REGEX, AUTH_004_FORMAT) + .optional(), + email: z + .string({ error: () => VALIDATION_001_TYPE }) + .regex(EMAIL_REGEX, AUTH_003) + .optional(), + currentPassword: z.string({ error: () => VALIDATION_001_TYPE }).optional(), + newPassword: z + .string({ error: () => VALIDATION_001_TYPE }) + .min(MIN_PASSWORD_LENGTH, AUTH_005(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH)) + .max(MAX_PASSWORD_LENGTH, AUTH_005(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH)) + .optional(), + }) + .superRefine((data, ctx) => { + // If newPassword is provided, currentPassword is required + if (data.newPassword && !data.currentPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: AUTH_010, + path: ["currentPassword"], + }); + } + }); + +export type UpdateProfileInput = z.infer; diff --git a/backend/src/scripts/createAdmin.ts b/backend/src/scripts/createAdmin.ts index ba4181c5..ea1d64a9 100644 --- a/backend/src/scripts/createAdmin.ts +++ b/backend/src/scripts/createAdmin.ts @@ -119,7 +119,6 @@ async function main() { console.log(` Email: ${adminUser.email}`); console.log("========================================"); console.log("\n⚠️ A la premiere connexion, vous devrez configurer le 2FA (TOTP).\n"); - } catch (error) { console.error("\n❌ Erreur lors de la creation:", error); process.exit(1); diff --git a/backend/src/server.ts b/backend/src/server.ts index 288d1c28..edfb6be7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,12 +4,16 @@ import env from "./util/validateEnv"; import prisma from "./util/db"; import { initSocketServer } from "./services/socketServer"; import logger from "./util/logger"; +import { startNotificationCleanupJob } from "./jobs/notificationCleanup"; +import { startImageCleanupJob } from "./jobs/imageCleanup"; const port = env.PORT; async function main() { const server = http.createServer(app); initSocketServer(server, userSession); + startNotificationCleanupJob(); + startImageCleanupJob(); server.listen(port, () => { logger.info({ port }, "Server started"); diff --git a/backend/src/services/communityRecipeService.ts b/backend/src/services/communityRecipeService.ts index cd7812cd..f6c6619e 100644 --- a/backend/src/services/communityRecipeService.ts +++ b/backend/src/services/communityRecipeService.ts @@ -1,12 +1,26 @@ 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 { + RECIPE_TAGS_SELECT, + RECIPE_INGREDIENTS_SELECT, + RECIPE_STEPS_SELECT, +} from "../util/prismaSelects"; +import { + IngredientInput, + upsertTags, + linkTagsToRecipe, + upsertIngredients, + upsertSteps, +} from "./recipeService"; +import { StepInput } from "../util/validation"; interface CreateCommunityRecipeData { title: string; - content: string; - imageUrl?: string | null; + servings: number; + prepTime?: number | null; + cookTime?: number | null; + restTime?: number | null; + steps: StepInput[]; + imageKey?: string | null; tags: string[]; ingredients: IngredientInput[]; } @@ -14,8 +28,11 @@ interface CreateCommunityRecipeData { const COMMUNITY_RECIPE_SELECT = { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, createdAt: true, updatedAt: true, creatorId: true, @@ -23,6 +40,7 @@ const COMMUNITY_RECIPE_SELECT = { originRecipeId: true, tags: RECIPE_TAGS_SELECT, ingredients: RECIPE_INGREDIENTS_SELECT, + steps: RECIPE_STEPS_SELECT, }; /** @@ -34,13 +52,18 @@ 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: { title: data.title.trim(), - content: data.content.trim(), - imageUrl: data.imageUrl?.trim() || null, + servings: data.servings, + prepTime: data.prepTime ?? null, + cookTime: data.cookTime ?? null, + restTime: data.restTime ?? null, + imageKey: data.imageKey?.trim() || null, creatorId: userId, communityId: null, }, @@ -50,63 +73,39 @@ export async function createCommunityRecipe( const communityRecipe = await tx.recipe.create({ data: { title: data.title.trim(), - content: data.content.trim(), - imageUrl: data.imageUrl?.trim() || null, + servings: data.servings, + prepTime: data.prepTime ?? null, + cookTime: data.cookTime ?? null, + restTime: data.restTime ?? null, + imageKey: data.imageKey?.trim() || null, creatorId: userId, communityId, originRecipeId: personalRecipe.id, }, }); - // 3. Gerer tags/ingredients sur les DEUX recettes + // 3. Gerer steps/tags/ingredients sur les DEUX recettes + await upsertSteps(tx, personalRecipe.id, data.steps); + await upsertSteps(tx, communityRecipe.id, data.steps); + // Resoudre les tags pour la recette communautaire, puis lier les memes + // tagIds a la copie perso (evite de creer des tags GLOBAL en doublon) if (data.tags.length > 0) { - const normalizedTags = normalizeNames(data.tags); - - for (const tagName of normalizedTags) { - const tag = await tx.tag.upsert({ - where: { name: tagName }, - create: { name: tagName }, - update: {}, - }); - - await tx.recipeTag.createMany({ - data: [ - { recipeId: personalRecipe.id, tagId: tag.id }, - { recipeId: communityRecipe.id, tagId: tag.id }, - ], - }); - } + pendingTagIds = await upsertTags(tx, communityRecipe.id, data.tags, userId, communityId); + // Recuperer les tagIds resolus pour les lier a la copie perso + const communityRecipeTags = await tx.recipeTag.findMany({ + where: { recipeId: communityRecipe.id }, + select: { tagId: true }, + }); + await linkTagsToRecipe( + tx, + personalRecipe.id, + communityRecipeTags.map((rt) => rt.tagId) + ); } 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, userId); + await upsertIngredients(tx, communityRecipe.id, data.ingredients, userId); } // 4. Creer ActivityLog @@ -133,4 +132,6 @@ export async function createCommunityRecipe( return { personal, community }; }); + + return { ...result, pendingTagIds }; } diff --git a/backend/src/services/membershipService.ts b/backend/src/services/membershipService.ts index 78406353..742e1732 100644 --- a/backend/src/services/membershipService.ts +++ b/backend/src/services/membershipService.ts @@ -1,5 +1,6 @@ import prisma from "../util/db"; import createHttpError from "http-errors"; +import { COMMUNITY_001, RECIPE_002 } from "../constants/errorCodes"; /** * Verifie qu'un utilisateur est membre d'une communaute. @@ -9,7 +10,7 @@ import createHttpError from "http-errors"; export async function requireMembership( userId: string, communityId: string, - errorMessage = "COMMUNITY_001: Not a member of this community" + errorMessage = COMMUNITY_001 ) { const membership = await prisma.userCommunity.findFirst({ where: { @@ -38,16 +39,12 @@ export async function requireRecipeAccess( ) { if (recipe.communityId === null) { if (recipe.creatorId !== userId) { - throw createHttpError(403, "RECIPE_002: Cannot access this recipe"); + throw createHttpError(403, RECIPE_002); } return null; } - return requireMembership( - userId, - recipe.communityId, - "RECIPE_002: Cannot access this recipe" - ); + return requireMembership(userId, recipe.communityId, RECIPE_002); } /** @@ -61,15 +58,11 @@ export async function requireRecipeOwnership( recipe: { creatorId: string; communityId: string | null } ) { if (recipe.creatorId !== userId) { - throw createHttpError(403, "RECIPE_002: Cannot access this recipe"); + throw createHttpError(403, RECIPE_002); } if (recipe.communityId !== null) { - return requireMembership( - userId, - recipe.communityId, - "RECIPE_002: Cannot access this recipe" - ); + return requireMembership(userId, recipe.communityId, RECIPE_002); } return null; diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts new file mode 100644 index 00000000..3d572795 --- /dev/null +++ b/backend/src/services/notificationService.ts @@ -0,0 +1,525 @@ +import { NotificationCategory, Prisma } from "@prisma/client"; +import prisma from "../util/db"; +import logger from "../util/logger"; + +// ============================================================================= +// TYPE -> CATEGORY MAPPING +// ============================================================================= + +const typeCategoryMap: Record = { + // Invitations + INVITE_SENT: "INVITATION", + INVITE_ACCEPTED: "INVITATION", + INVITE_REJECTED: "INVITATION", + INVITE_CANCELLED: "INVITATION", + + // Recettes & Proposals + VARIANT_PROPOSED: "RECIPE_PROPOSAL", + PROPOSAL_ACCEPTED: "RECIPE_PROPOSAL", + PROPOSAL_REJECTED: "RECIPE_PROPOSAL", + RECIPE_CREATED: "RECIPE_PROPOSAL", + RECIPE_SHARED: "RECIPE_PROPOSAL", + + // Tags + TAG_SUGGESTION_CREATED: "TAG", + TAG_SUGGESTION_ACCEPTED: "TAG", + TAG_SUGGESTION_REJECTED: "TAG", + "tag-suggestion:pending-mod": "TAG", + "tag:pending": "TAG", + "tag:approved": "TAG", + "tag:rejected": "TAG", + + // Ingredients + INGREDIENT_APPROVED: "INGREDIENT", + INGREDIENT_MODIFIED: "INGREDIENT", + INGREDIENT_MERGED: "INGREDIENT", + INGREDIENT_REJECTED: "INGREDIENT", + + // Moderation + USER_PROMOTED: "MODERATION", + USER_KICKED: "MODERATION", + USER_LEFT: "MODERATION", + USER_JOINED: "MODERATION", +}; + +export function getCategoryForType(type: string): NotificationCategory | null { + return typeCategoryMap[type] ?? null; +} + +// ============================================================================= +// NOTIFICATION TEMPLATES +// ============================================================================= + +interface NotificationTemplate { + title: string; + message: (vars: Record) => string; + actionUrl: ((vars: Record) => string) | null; + groupKey: ((vars: Record) => string) | null; +} + +const templates: Record = { + // --- Invitations --- + INVITE_SENT: { + title: "Nouvelle invitation", + message: (v) => `${v.actorName} vous invite a rejoindre ${v.communityName}`, + actionUrl: () => "/invitations", + groupKey: null, + }, + INVITE_ACCEPTED: { + title: "Invitation acceptee", + message: (v) => `${v.actorName} a accepte votre invitation pour ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: null, + }, + INVITE_REJECTED: { + title: "Invitation refusee", + message: (v) => `${v.actorName} a decline votre invitation pour ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: null, + }, + INVITE_CANCELLED: { + title: "Invitation annulee", + message: (v) => `L'invitation pour ${v.communityName} a ete annulee`, + actionUrl: null, + groupKey: null, + }, + + // --- Recettes & Proposals --- + VARIANT_PROPOSED: { + title: "Nouvelle proposal", + message: (v) => `${v.actorName} propose une modification sur '${v.recipeName}'`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + PROPOSAL_ACCEPTED: { + title: "Proposal acceptee", + message: (v) => `Votre proposal sur '${v.recipeName}' a ete acceptee`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + PROPOSAL_REJECTED: { + title: "Proposal refusee", + message: (v) => `Votre proposal sur '${v.recipeName}' a ete refusee`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + RECIPE_CREATED: { + title: "Nouvelle recette", + message: (v) => `${v.actorName} a cree '${v.recipeName}' dans ${v.communityName}`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: (v) => `community:${v.communityId}:RECIPE_CREATED`, + }, + RECIPE_SHARED: { + title: "Recette partagee", + message: (v) => `${v.actorName} a partage '${v.recipeName}' dans ${v.communityName}`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: (v) => `community:${v.communityId}:RECIPE_SHARED`, + }, + + // --- Tags --- + TAG_SUGGESTION_CREATED: { + title: "Suggestion de tag", + message: (v) => `${v.actorName} suggere le tag '${v.tagName}' sur '${v.recipeName}'`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + TAG_SUGGESTION_ACCEPTED: { + title: "Suggestion acceptee", + message: (v) => `Votre suggestion de tag '${v.tagName}' a ete acceptee`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + TAG_SUGGESTION_REJECTED: { + title: "Suggestion refusee", + message: (v) => `Votre suggestion de tag '${v.tagName}' a ete refusee`, + actionUrl: (v) => `/recipes/${v.recipeId}`, + groupKey: null, + }, + "tag-suggestion:pending-mod": { + title: "Tag en attente", + message: (v) => `Un tag suggere attend votre validation dans ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}?panel=tags`, + groupKey: null, + }, + "tag:pending": { + title: "Tag en attente", + message: (v) => `Un nouveau tag attend validation dans ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}?panel=tags`, + groupKey: null, + }, + "tag:approved": { + title: "Tag valide", + message: (v) => `Votre tag '${v.tagName}' a ete valide dans ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: null, + }, + "tag:rejected": { + title: "Tag rejete", + message: (v) => `Votre tag '${v.tagName}' a ete rejete dans ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: null, + }, + + // --- Ingredients --- + INGREDIENT_APPROVED: { + title: "Ingredient valide", + message: (v) => `Votre ingredient '${v.ingredientName}' a ete valide`, + actionUrl: null, + groupKey: null, + }, + INGREDIENT_MODIFIED: { + title: "Ingredient renomme", + message: (v) => `Votre ingredient a ete valide sous le nom '${v.newName}'`, + actionUrl: null, + groupKey: null, + }, + INGREDIENT_MERGED: { + title: "Ingredient fusionne", + message: (v) => `Votre ingredient '${v.ingredientName}' a ete fusionne avec '${v.targetName}'`, + actionUrl: null, + groupKey: null, + }, + INGREDIENT_REJECTED: { + title: "Ingredient rejete", + message: (v) => `Votre ingredient '${v.ingredientName}' a ete rejete : ${v.reason}`, + actionUrl: null, + groupKey: null, + }, + + // --- Moderation --- + USER_PROMOTED: { + title: "Promotion moderateur", + message: (v) => `Vous etes maintenant moderateur de ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: null, + }, + USER_KICKED: { + title: "Exclusion", + message: (v) => `Vous avez ete retire de ${v.communityName}`, + actionUrl: null, + groupKey: null, + }, + USER_JOINED: { + title: "Nouveau membre", + message: (v) => `${v.actorName} a rejoint ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: (v) => `community:${v.communityId}:USER_JOINED`, + }, + USER_LEFT: { + title: "Depart", + message: (v) => `${v.actorName} a quitte ${v.communityName}`, + actionUrl: (v) => `/communities/${v.communityId}`, + groupKey: (v) => `community:${v.communityId}:USER_LEFT`, + }, +}; + +// Types de notifications toujours envoyees (ignorent les preferences) +const NON_DISABLEABLE_TYPES = new Set(["USER_KICKED", "INVITE_SENT"]); + +// ============================================================================= +// PREFERENCE CHECK +// ============================================================================= + +/** + * Verifie si un utilisateur a la categorie de notification activee. + * Hierarchie : pref communaute > pref globale > defaut (true). + */ +export async function isNotificationEnabled( + userId: string, + category: NotificationCategory, + communityId: string | null +): Promise { + const whereConditions = communityId + ? [ + { userId, communityId: null, category }, + { userId, communityId, category }, + ] + : [{ userId, communityId: null, category }]; + + const prefs = await prisma.notificationPreference.findMany({ + where: { OR: whereConditions }, + }); + + let globalEnabled: boolean | undefined; + let communityEnabled: boolean | undefined; + + for (const pref of prefs) { + if (pref.communityId === null) { + globalEnabled = pref.enabled; + } else { + communityEnabled = pref.enabled; + } + } + + // Communaute > global > defaut (true) + if (communityEnabled !== undefined) return communityEnabled; + if (globalEnabled !== undefined) return globalEnabled; + return true; +} + +/** + * Filtre un tableau d'userIds en ne gardant que ceux ayant la categorie activee. + * Optimise : une seule requete pour tous les users. + */ +export async function filterByPreference( + userIds: string[], + category: NotificationCategory, + communityId: string | null +): Promise { + if (userIds.length === 0) return []; + + const whereConditions = communityId + ? { userId: { in: userIds }, category, OR: [{ communityId: null }, { communityId }] } + : { userId: { in: userIds }, category, communityId: null }; + + const prefs = await prisma.notificationPreference.findMany({ + where: whereConditions, + }); + + // Map userId -> { global, community } + const prefMap = new Map(); + for (const pref of prefs) { + const entry = prefMap.get(pref.userId) ?? {}; + if (pref.communityId === null) { + entry.global = pref.enabled; + } else { + entry.community = pref.enabled; + } + prefMap.set(pref.userId, entry); + } + + return userIds.filter((userId) => { + const entry = prefMap.get(userId); + if (!entry) return true; + if (entry.community !== undefined) return entry.community; + if (entry.global !== undefined) return entry.global; + return true; + }); +} + +// ============================================================================= +// NOTIFICATION CREATION +// ============================================================================= + +interface CreateNotificationInput { + userId: string; + type: string; + actorId: string | null; + communityId: string | null; + recipeId?: string | null; + metadata?: Record; + /** Variables de template pre-resolues (actorName, communityName, etc.) */ + templateVars: Record; +} + +/** + * Cree une notification pour un utilisateur. + * Verifie les preferences sauf pour les types non-desactivables. + * Retourne la notification creee ou null si desactivee par preference. + */ +export async function createNotification(input: CreateNotificationInput) { + const { userId, type, actorId, communityId, recipeId, metadata, templateVars } = input; + + const category = getCategoryForType(type); + if (!category) { + logger.warn({ type }, "Unknown notification type, skipping"); + return null; + } + + const template = templates[type]; + if (!template) { + logger.warn({ type }, "No template for notification type, skipping"); + return null; + } + + // Verifier preferences (sauf pour les non-desactivables) + if (!NON_DISABLEABLE_TYPES.has(type)) { + const enabled = await isNotificationEnabled(userId, category, communityId); + if (!enabled) return null; + } + + const title = template.title; + const message = template.message(templateVars); + const actionUrl = template.actionUrl ? template.actionUrl(templateVars) : null; + const groupKey = template.groupKey ? template.groupKey(templateVars) : null; + + const notification = await prisma.notification.create({ + data: { + userId, + type, + category, + title, + message, + actionUrl, + metadata: (metadata as Prisma.InputJsonValue) ?? undefined, + actorId, + communityId, + recipeId: recipeId ?? null, + groupKey, + }, + }); + + return notification; +} + +/** + * Cree des notifications broadcast pour tous les membres d'une communaute (sauf l'acteur). + * Retourne le tableau de notifications creees. + */ +export async function createBroadcastNotifications(input: { + type: string; + actorId: string; + communityId: string; + recipeId?: string | null; + metadata?: Record; + templateVars: Record; +}) { + const { type, actorId, communityId, recipeId, metadata, templateVars } = input; + + const category = getCategoryForType(type); + if (!category) { + logger.warn({ type }, "Unknown notification type for broadcast, skipping"); + return []; + } + + const template = templates[type]; + if (!template) { + logger.warn({ type }, "No template for broadcast notification type, skipping"); + return []; + } + + // Recuperer tous les membres de la communaute sauf l'acteur + const members = await prisma.userCommunity.findMany({ + where: { + communityId, + deletedAt: null, + userId: { not: actorId }, + }, + select: { userId: true }, + }); + + if (members.length === 0) return []; + + const memberIds = members.map((m) => m.userId); + + // Filtrer par preferences + const enabledUserIds = await filterByPreference(memberIds, category, communityId); + + if (enabledUserIds.length === 0) return []; + + const title = template.title; + const message = template.message(templateVars); + const actionUrl = template.actionUrl ? template.actionUrl(templateVars) : null; + const groupKey = template.groupKey ? template.groupKey(templateVars) : null; + + // Batch insert + const data = enabledUserIds.map((userId) => ({ + userId, + type, + category, + title, + message, + actionUrl, + metadata: (metadata as Prisma.InputJsonValue) ?? undefined, + actorId, + communityId, + recipeId: recipeId ?? null, + groupKey, + })); + + await prisma.notification.createMany({ data }); + + // Recuperer les notifications creees pour les retourner (createMany ne retourne pas les objets) + const notifications = await prisma.notification.findMany({ + where: { + type, + communityId, + actorId, + userId: { in: enabledUserIds }, + createdAt: { gte: new Date(Date.now() - 5000) }, + }, + orderBy: { createdAt: "desc" }, + take: enabledUserIds.length, + }); + + return notifications; +} + +// ============================================================================= +// TEMPLATE VARS RESOLUTION +// ============================================================================= + +/** + * Resout les variables de template a partir de l'evenement et des lookups DB. + * Charge actorName, communityName, recipeName au besoin. + */ +export async function resolveTemplateVars(event: { + type: string; + userId: string; + communityId: string | null; + recipeId?: string; + metadata?: Record; +}): Promise> { + const vars: Record = {}; + const meta = event.metadata ?? {}; + + // Lookup actor + const actor = await prisma.user.findUnique({ + where: { id: event.userId }, + select: { username: true }, + }); + vars.actorName = actor?.username ?? "Utilisateur"; + + // Lookup community + if (event.communityId) { + vars.communityId = event.communityId; + const community = await prisma.community.findUnique({ + where: { id: event.communityId }, + select: { name: true }, + }); + vars.communityName = community?.name ?? "Communaute"; + } + + // Lookup recipe + if (event.recipeId) { + vars.recipeId = event.recipeId; + const recipe = await prisma.recipe.findUnique({ + where: { id: event.recipeId }, + select: { title: true }, + }); + vars.recipeName = recipe?.title ?? "Recette"; + } + + // Metadata passthrough pour les templates + if (meta.tagName) vars.tagName = String(meta.tagName); + if (meta.ingredientName) vars.ingredientName = String(meta.ingredientName); + if (meta.newName) vars.newName = String(meta.newName); + if (meta.targetName) vars.targetName = String(meta.targetName); + if (meta.reason) vars.reason = String(meta.reason); + + return vars; +} + +// ============================================================================= +// LEGACY FUNCTION (kept for backward compatibility during migration) +// ============================================================================= + +/** + * Retourne les IDs des moderateurs d'une communaute qui ont les notifications tags activees. + * Filtre par NotificationPreference (category=TAG, global puis par communaute). + */ +export async function getModeratorIdsForTagNotification(communityId: string): Promise { + 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); + return filterByPreference(moderatorIds, "TAG", communityId); +} diff --git a/backend/src/services/orphanHandling.ts b/backend/src/services/orphanHandling.ts index 5f952b11..eec01419 100644 --- a/backend/src/services/orphanHandling.ts +++ b/backend/src/services/orphanHandling.ts @@ -43,7 +43,11 @@ export async function handleOrphanedRecipes( select: { id: true, title: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, proposals: { where: { status: "PENDING", @@ -52,8 +56,15 @@ export async function handleOrphanedRecipes( select: { id: true, proposedTitle: true, - proposedContent: true, + proposedServings: true, + proposedPrepTime: true, + proposedCookTime: true, + proposedRestTime: true, proposerId: true, + proposedSteps: { + select: { order: true, instruction: true }, + orderBy: { order: "asc" as const }, + }, }, }, }, @@ -62,9 +73,10 @@ export async function handleOrphanedRecipes( // Collecter toutes les propositions et preparer les variants const allProposalIds: string[] = []; const variantDataList: { - proposal: typeof recipes[0]["proposals"][0]; + proposal: (typeof recipes)[0]["proposals"][0]; recipeId: string; - imageUrl: string | null; + imageKey: string | null; + recipe: (typeof recipes)[0]; }[] = []; for (const recipe of recipes) { @@ -73,7 +85,8 @@ export async function handleOrphanedRecipes( variantDataList.push({ proposal, recipeId: recipe.id, - imageUrl: recipe.imageUrl, + imageKey: recipe.imageKey, + recipe, }); } } @@ -99,12 +112,15 @@ export async function handleOrphanedRecipes( metadata: Prisma.InputJsonValue; }[] = []; - for (const { proposal, recipeId, imageUrl } of variantDataList) { + for (const { proposal, recipeId, imageKey, recipe } of variantDataList) { const variant = await client.recipe.create({ data: { title: proposal.proposedTitle, - content: proposal.proposedContent, - imageUrl, + servings: proposal.proposedServings ?? recipe.servings, + prepTime: proposal.proposedPrepTime !== null ? proposal.proposedPrepTime : recipe.prepTime, + cookTime: proposal.proposedCookTime !== null ? proposal.proposedCookTime : recipe.cookTime, + restTime: proposal.proposedRestTime !== null ? proposal.proposedRestTime : recipe.restTime, + imageKey, isVariant: true, creatorId: proposal.proposerId, communityId, @@ -112,6 +128,19 @@ export async function handleOrphanedRecipes( }, }); + // Copier les steps proposes dans la variante + if (proposal.proposedSteps.length > 0) { + for (const ps of proposal.proposedSteps) { + await client.recipeStep.create({ + data: { + recipeId: variant.id, + order: ps.order, + instruction: ps.instruction, + }, + }); + } + } + activityLogData.push({ type: "VARIANT_CREATED", userId: proposal.proposerId, @@ -134,4 +163,3 @@ export async function handleOrphanedRecipes( createdVariants: variantDataList.length, }; } - diff --git a/backend/src/services/proposalService.ts b/backend/src/services/proposalService.ts index 51ccae2f..be5df039 100644 --- a/backend/src/services/proposalService.ts +++ b/backend/src/services/proposalService.ts @@ -1,10 +1,14 @@ import { ActivityType } from "@prisma/client"; import prisma from "../util/db"; +import { PROPOSAL_INGREDIENTS_SELECT, PROPOSAL_STEPS_SELECT } from "../util/prismaSelects"; const PROPOSAL_SELECT = { id: true, proposedTitle: true, - proposedContent: true, + proposedServings: true, + proposedPrepTime: true, + proposedCookTime: true, + proposedRestTime: true, status: true, createdAt: true, decidedAt: true, @@ -16,21 +20,30 @@ const PROPOSAL_SELECT = { username: true, }, }, + proposedSteps: PROPOSAL_STEPS_SELECT, + proposedIngredients: PROPOSAL_INGREDIENTS_SELECT, }; interface ProposalWithRecipe { id: string; proposedTitle: string; - proposedContent: string; + proposedServings: number | null; + proposedPrepTime: number | null; + proposedCookTime: number | null; + proposedRestTime: number | null; status: string; createdAt: Date; recipeId: string; proposerId: string; + proposedSteps: { order: number; instruction: string }[]; recipe: { id: string; title: string; - content: string; - imageUrl: string | null; + servings: number; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + imageKey: string | null; communityId: string | null; creatorId: string; originRecipeId: string | null; @@ -38,8 +51,63 @@ interface ProposalWithRecipe { }; } +type TxClient = Omit< + typeof prisma, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends" +>; + +/** + * Copie les ProposalIngredients vers les RecipeIngredients d'une recette cible. + * Supprime d'abord les RecipeIngredients existants. + */ +async function applyProposalIngredients( + tx: TxClient, + 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, + }, + }); + } +} + +/** + * Copie les ProposalSteps vers les RecipeSteps d'une recette cible. + * Supprime d'abord les RecipeSteps existants. + */ +async function applyProposalSteps( + tx: TxClient, + recipeId: string, + proposalSteps: Array<{ order: number; instruction: string }> +) { + await tx.recipeStep.deleteMany({ where: { recipeId } }); + for (const ps of proposalSteps) { + await tx.recipeStep.create({ + data: { + recipeId, + order: ps.order, + instruction: ps.instruction, + }, + }); + } +} + /** * 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,16 +117,46 @@ export async function acceptProposal( return prisma.$transaction(async (tx) => { const now = new Date(); + // 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; + const hasProposedSteps = proposal.proposedSteps.length > 0; + + // Build scalar update data + const recipeUpdateData: Record = { + title: proposal.proposedTitle, + updatedAt: now, + }; + if (proposal.proposedServings !== null) recipeUpdateData.servings = proposal.proposedServings; + if (proposal.proposedPrepTime !== null) recipeUpdateData.prepTime = proposal.proposedPrepTime; + if (proposal.proposedCookTime !== null) recipeUpdateData.cookTime = proposal.proposedCookTime; + if (proposal.proposedRestTime !== null) recipeUpdateData.restTime = proposal.proposedRestTime; + // 1. Mettre a jour la recette communautaire await tx.recipe.update({ where: { id: proposal.recipe.id }, - data: { - title: proposal.proposedTitle, - content: proposal.proposedContent, - updatedAt: now, - }, + data: recipeUpdateData, }); + if (hasProposedSteps) { + await applyProposalSteps(tx, proposal.recipe.id, proposal.proposedSteps); + } + + // 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({ @@ -73,13 +171,17 @@ export async function acceptProposal( // Mettre a jour la recette personnelle await tx.recipe.update({ where: { id: originRecipe.id }, - data: { - title: proposal.proposedTitle, - content: proposal.proposedContent, - updatedAt: now, - }, + data: recipeUpdateData, }); + if (hasProposedSteps) { + await applyProposalSteps(tx, originRecipe.id, proposal.proposedSteps); + } + + if (hasProposedIngredients) { + await applyProposalIngredients(tx, originRecipe.id, proposalIngredients); + } + // 3. Propager aux autres copies communautaires const otherCommunityRecipes = await tx.recipe.findMany({ where: { @@ -93,13 +195,21 @@ export async function acceptProposal( if (otherCommunityRecipes.length > 0) { await tx.recipe.updateMany({ where: { id: { in: otherCommunityRecipes.map((r) => r.id) } }, - data: { - title: proposal.proposedTitle, - content: proposal.proposedContent, - updatedAt: now, - }, + data: recipeUpdateData, }); + if (hasProposedSteps) { + for (const linked of otherCommunityRecipes) { + await applyProposalSteps(tx, linked.id, proposal.proposedSteps); + } + } + + 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) @@ -143,31 +253,55 @@ export async function acceptProposal( interface ProposalForReject { id: string; proposedTitle: string; - proposedContent: string; + proposedServings: number | null; + proposedPrepTime: number | null; + proposedCookTime: number | null; + proposedRestTime: number | null; proposerId: string; + proposedSteps: { order: number; instruction: string }[]; recipe: { id: string; - imageUrl: string | null; + servings: number; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + imageKey: string | null; communityId: string | null; }; } /** * 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, - proposal: ProposalForReject -) { +export async function rejectProposal(proposalId: string, proposal: ProposalForReject) { 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: { title: proposal.proposedTitle, - content: proposal.proposedContent, - imageUrl: proposal.recipe.imageUrl, + servings: proposal.proposedServings ?? proposal.recipe.servings, + prepTime: + proposal.proposedPrepTime !== null ? proposal.proposedPrepTime : proposal.recipe.prepTime, + cookTime: + proposal.proposedCookTime !== null ? proposal.proposedCookTime : proposal.recipe.cookTime, + restTime: + proposal.proposedRestTime !== null ? proposal.proposedRestTime : proposal.recipe.restTime, + imageKey: proposal.recipe.imageKey, isVariant: true, creatorId: proposal.proposerId, communityId: proposal.recipe.communityId, @@ -176,8 +310,11 @@ export async function rejectProposal( select: { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, isVariant: true, creatorId: true, communityId: true, @@ -186,6 +323,40 @@ export async function rejectProposal( }, }); + // Copier les steps proposes dans la variante + if (proposal.proposedSteps.length > 0) { + await applyProposalSteps(tx, variant.id, proposal.proposedSteps); + } + + // 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, + }, + }); + } + } + + // Copier les tags de la recette originale vers la variante + const originalTags = await tx.recipeTag.findMany({ + where: { recipeId: proposal.recipe.id }, + select: { tagId: true }, + }); + if (originalTags.length > 0) { + await tx.recipeTag.createMany({ + data: originalTags.map((rt) => ({ + recipeId: variant.id, + tagId: rt.tagId, + })), + }); + } + // 2. Mettre a jour la proposition const updatedProposal = await tx.recipeUpdateProposal.update({ where: { id: proposalId }, diff --git a/backend/src/services/recipeImportService.ts b/backend/src/services/recipeImportService.ts new file mode 100644 index 00000000..8ad85bb7 --- /dev/null +++ b/backend/src/services/recipeImportService.ts @@ -0,0 +1,500 @@ +import * as cheerio from "cheerio"; +import createHttpError from "http-errors"; +import { IMPORT_001, IMPORT_002, IMPORT_003 } from "../constants/errorCodes"; + +// --- Types --- + +export interface ParsedIngredient { + raw: string; + quantity: number | null; + unitAbbreviation: string | null; + name: string | null; +} + +export interface ParsedRecipe { + title: string | null; + servings: number | null; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + ingredients: ParsedIngredient[]; + steps: string[]; +} + +// --- Unit mapping --- + +const UNIT_ALIAS_MAP: Record = { + // Poids + g: "g", + gr: "g", + gramme: "g", + grammes: "g", + kg: "kg", + kilo: "kg", + kilos: "kg", + kilogramme: "kg", + kilogrammes: "kg", + // Volumes + ml: "ml", + millilitre: "ml", + millilitres: "ml", + cl: "cl", + centilitre: "cl", + centilitres: "cl", + dl: "dl", + decilitre: "dl", + decilitres: "dl", + l: "l", + litre: "l", + litres: "l", + // Cuilleres (abbreviation DB = "cac" et "cas") + cs: "cas", + cas: "cas", + "c.a.s": "cas", + "cuillere a soupe": "cas", + "cuilleres a soupe": "cas", + cc: "cac", + cac: "cac", + "c.a.c": "cac", + "cuillere a cafe": "cac", + "cuilleres a cafe": "cac", + // Unites discretes + pincee: "pincee", + pincees: "pincee", + gousse: "gousse", + gousses: "gousse", + tranche: "tranche", + tranches: "tranche", + feuille: "feuille", + feuilles: "feuille", + brin: "brin", + brins: "brin", + botte: "botte", + bottes: "botte", + piece: "piece", + pieces: "piece", + sachet: "sachet", + sachets: "sachet", + // Variantes avec parentheses (HelloFresh: "2 piece(s) Aubergine") + "piece(s)": "piece", + "pincee(s)": "pincee", + "gousse(s)": "gousse", + "tranche(s)": "tranche", + "feuille(s)": "feuille", + "brin(s)": "brin", + "botte(s)": "botte", + "sachet(s)": "sachet", +}; + +// Tous les patterns d'unites reconnus (tries par longueur decroissante pour le regex) +const UNIT_PATTERNS = Object.keys(UNIT_ALIAS_MAP) + .sort((a, b) => b.length - a.length) + .map((k) => k.replace(/[()]/g, "\\$&")) + .join("|"); + +// --- SSRF protection --- + +const PRIVATE_IP_PATTERNS = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[01])\./, + /^192\.168\./, + /^0\.0\.0\.0$/, + /^::1$/, + /^\[::1\]$/, + /^localhost$/i, +]; + +function isPrivateHost(hostname: string): boolean { + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname)); +} + +// --- ISO 8601 duration parsing --- + +export function parseIsoDuration(duration: string | null | undefined): number | null { + if (!duration || typeof duration !== "string") return null; + const match = duration.match(/^PT(?:(\d+)H)?(?:(\d+)M)?$/i); + if (!match) return null; + const hours = parseInt(match[1] || "0", 10); + const minutes = parseInt(match[2] || "0", 10); + const total = hours * 60 + minutes; + return total > 0 ? total : null; +} + +// --- Unicode fraction normalization --- + +const UNICODE_FRACTIONS: Record = { + "\u00BD": "1/2", + "\u2153": "1/3", + "\u2154": "2/3", + "\u00BC": "1/4", + "\u00BE": "3/4", + "\u2155": "1/5", + "\u2156": "2/5", + "\u2157": "3/5", + "\u2158": "4/5", + "\u2159": "1/6", + "\u215A": "5/6", + "\u215B": "1/8", + "\u215C": "3/8", + "\u215D": "5/8", + "\u215E": "7/8", +}; + +function normalizeUnicodeFractions(text: string): string { + return text.replace(/[\u00BC\u00BD\u00BE\u2153-\u215E]/g, (ch) => UNICODE_FRACTIONS[ch] ?? ch); +} + +// Supprime les accents pour la normalisation des unites +function stripAccents(text: string): string { + return text.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); +} + +// --- Ingredient parsing --- + +export function parseIngredientLine(line: string): ParsedIngredient { + const cleaned = normalizeUnicodeFractions(line.replace(/^[-*\u2022\u2013\u2014]\s*/, "")).trim(); + // Version sans accents pour le matching des unites (piece vs piece) + const cleanedNorm = stripAccents(cleaned); + + if (!cleaned) { + return { raw: line, quantity: null, unitAbbreviation: null, name: null }; + } + + // Variante "selon le gout" en debut de ligne (HelloFresh: "selon le gout Poivre et sel") + const tastePrefix = cleanedNorm.match( + /^(?:selon\s+(?:le\s+)?(?:besoin|envie|gout)|a\s*gout)\s+(.+)$/i + ); + if (tastePrefix) { + // Extraire le nom depuis la chaine originale (avec accents) + const prefixLen = cleaned.length - tastePrefix[1].length; + return { + raw: cleaned, + quantity: null, + unitAbbreviation: null, + name: cleaned.substring(prefixLen).trim(), + }; + } + + // Variante "a gout" / sans quantite en fin de ligne + const tasteMatch = cleanedNorm.match( + /^(.+?)[\s,]*(?:a\s*gout|selon\s*(?:le\s+)?(?:besoin|envie|gout))$/i + ); + if (tasteMatch) { + return { + raw: cleaned, + quantity: null, + unitAbbreviation: null, + name: cleaned.substring(0, tasteMatch[1].length).trim(), + }; + } + + // Variante fractions en premier (1/2, 3/4) pour eviter que "1" de "1/2" matche le pattern principal + const fractionPattern = new RegExp( + `^(\\d+/\\d+)\\s*(${UNIT_PATTERNS})?\\s*(?:de\\s+|d')?(.+)$`, + "i" + ); + const fractionMatch = cleanedNorm.match(fractionPattern); + if (fractionMatch) { + const [num, den] = fractionMatch[1].split("/"); + const quantity = parseInt(num, 10) / parseInt(den, 10); + const unitRaw = fractionMatch[2]?.toLowerCase() || null; + const unitAbbreviation = unitRaw ? UNIT_ALIAS_MAP[unitRaw] || null : null; + // Extraire le nom depuis la chaine originale + const nameStart = cleaned.length - fractionMatch[3].length; + return { + raw: cleaned, + quantity: isNaN(quantity) ? null : quantity, + unitAbbreviation, + name: cleaned.substring(nameStart).trim(), + }; + } + + // Pattern principal : nombre (entier/decimal) + unite optionnelle + nom + const mainPattern = new RegExp( + `^(\\d+[.,]?\\d*)\\s*(${UNIT_PATTERNS})?\\s*(?:de\\s+|d')?(.+)$`, + "i" + ); + const mainMatch = cleanedNorm.match(mainPattern); + if (mainMatch) { + const quantity = parseFloat(mainMatch[1].replace(",", ".")); + const unitRaw = mainMatch[2]?.toLowerCase() || null; + const unitAbbreviation = unitRaw ? UNIT_ALIAS_MAP[unitRaw] || null : null; + // Extraire le nom depuis la chaine originale + const nameStart = cleaned.length - mainMatch[3].length; + return { + raw: cleaned, + quantity: isNaN(quantity) ? null : quantity, + unitAbbreviation, + name: cleaned.substring(nameStart).trim(), + }; + } + + // Fallback : pas de match + return { raw: cleaned, quantity: null, unitAbbreviation: null, name: cleaned }; +} + +// --- HTML stripping --- + +function stripHtml(text: string): string[] { + // Si pas de balises HTML, retourner tel quel + if (!/<[^>]+>/.test(text)) return [text]; + + const $ = cheerio.load(text, { xml: false }); + + // Extraire chaque
  • comme un step separe + const items = $("li"); + if (items.length > 0) { + const results: string[] = []; + items.each((_, el) => { + const t = $(el).text().trim(); + if (t) results.push(t); + }); + // Aussi extraire le texte hors des listes (

    , texte libre) + $("ul, ol").remove(); + const remaining = $.text().trim(); + if (remaining) results.push(remaining); + return results; + } + + // Pas de

  • , juste extraire le texte + const plain = $.text().trim(); + return plain ? [plain] : []; +} + +// --- recipeInstructions parsing --- + +function parseInstructions(instructions: unknown): string[] { + if (!instructions) return []; + + // String unique + if (typeof instructions === "string") { + return instructions + .split(/\n/) + .map((s) => s.replace(/^\d+[.)]\s*/, "").trim()) + .filter(Boolean); + } + + // Array + if (Array.isArray(instructions)) { + const steps: string[] = []; + + for (const item of instructions) { + if (typeof item === "string") { + const trimmed = item.replace(/^\d+[.)]\s*/, "").trim(); + if (trimmed) steps.push(...stripHtml(trimmed)); + } else if (item && typeof item === "object") { + // HowToStep + if (item["@type"] === "HowToStep" || item["@type"] === "schema:HowToStep") { + const text = (item.text || item.name || "").toString().trim(); + if (text) steps.push(...stripHtml(text)); + } + // HowToSection + else if (item["@type"] === "HowToSection" || item["@type"] === "schema:HowToSection") { + const sectionItems = item.itemListElement; + if (Array.isArray(sectionItems)) { + for (const subItem of sectionItems) { + if (typeof subItem === "string") { + const trimmed = subItem.trim(); + if (trimmed) steps.push(trimmed); + } else if (subItem && typeof subItem === "object") { + const text = (subItem.text || subItem.name || "").toString().trim(); + if (text) steps.push(...stripHtml(text)); + } + } + } + } + // Objet generique avec .text + else if ("text" in item) { + const text = (item.text || "").toString().trim(); + if (text) steps.push(...stripHtml(text)); + } + } + } + + return steps; + } + + return []; +} + +// --- JSON-LD Recipe extraction --- + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function findRecipeInJsonLd(data: any): any | null { + if (!data || typeof data !== "object") return null; + + // Verifier si l'objet lui-meme est une Recipe + if (isRecipeType(data)) return data; + + // Chercher dans @graph + if (Array.isArray(data["@graph"])) { + for (const item of data["@graph"]) { + if (isRecipeType(item)) return item; + } + } + + // Chercher dans un tableau direct + if (Array.isArray(data)) { + for (const item of data) { + const found = findRecipeInJsonLd(item); + if (found) return found; + } + } + + return null; +} + +function isRecipeType(obj: any): boolean { + if (!obj || typeof obj !== "object") return false; + const type = obj["@type"]; + if (!type) return false; + + if (typeof type === "string") { + return type === "Recipe" || type === "schema:Recipe"; + } + if (Array.isArray(type)) { + return type.some((t: string) => t === "Recipe" || t === "schema:Recipe"); + } + return false; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +function mapJsonLdToRecipe(recipe: Record): ParsedRecipe { + const title = typeof recipe.name === "string" ? recipe.name : null; + + // Servings : extraire le premier nombre + let servings: number | null = null; + const yieldValue = recipe.recipeYield; + if (yieldValue) { + const yieldStr = Array.isArray(yieldValue) ? String(yieldValue[0]) : String(yieldValue); + const servingsMatch = yieldStr.match(/(\d+)/); + if (servingsMatch) { + servings = parseInt(servingsMatch[1], 10); + } + } + + // Temps + const prepTime = parseIsoDuration(recipe.prepTime as string); + const cookTime = parseIsoDuration(recipe.cookTime as string); + const totalTime = parseIsoDuration(recipe.totalTime as string); + + // restTime n'est pas standard en JSON-LD, on ne l'extrait pas + const restTime: number | null = null; + + // Fallback : si aucun prepTime/cookTime, utiliser totalTime comme prepTime + const finalPrepTime = prepTime ?? (cookTime ? null : totalTime); + + // Ingredients + const rawIngredients = Array.isArray(recipe.recipeIngredient) ? recipe.recipeIngredient : []; + const ingredients: ParsedIngredient[] = rawIngredients + .filter((item): item is string => typeof item === "string") + .map((line) => parseIngredientLine(line)); + + // Steps + const steps = parseInstructions(recipe.recipeInstructions); + + return { + title, + servings, + prepTime: finalPrepTime, + cookTime, + restTime, + ingredients, + steps, + }; +} + +// --- Main service function --- + +export async function importFromUrl(url: string): Promise { + // Validation URL + if (!url || typeof url !== "string" || url.length > 2000) { + throw createHttpError(400, IMPORT_001); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw createHttpError(400, IMPORT_001); + } + + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw createHttpError(400, IMPORT_001); + } + + // SSRF protection + if (isPrivateHost(parsedUrl.hostname)) { + throw createHttpError(400, IMPORT_001); + } + + // Fetch la page + let html: string; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Mozilla/5.0 (compatible; ForestManager/1.0)", + Accept: "text/html,application/xhtml+xml", + }, + redirect: "follow", + }); + + clearTimeout(timeout); + + // Verifier la taille du contenu + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > 5 * 1024 * 1024) { + throw createHttpError(422, IMPORT_002); + } + + if (!response.ok) { + throw createHttpError(422, IMPORT_002); + } + + html = await response.text(); + + // Verifier la taille apres telechargement aussi + if (html.length > 5 * 1024 * 1024) { + throw createHttpError(422, IMPORT_002); + } + } catch (error) { + if (error instanceof Error && "statusCode" in error) { + throw error; // Re-throw createHttpError + } + throw createHttpError(422, IMPORT_002); + } + + // Extraire les JSON-LD + const $ = cheerio.load(html); + const jsonLdScripts = $('script[type="application/ld+json"]'); + + let recipeData: Record | null = null; + + jsonLdScripts.each((_, element) => { + if (recipeData) return; // Deja trouve + + try { + const content = $(element).html(); + if (!content) return; + + const parsed = JSON.parse(content); + const found = findRecipeInJsonLd(parsed); + if (found) { + recipeData = found; + } + } catch { + // JSON invalide, on continue + } + }); + + if (!recipeData) { + throw createHttpError(422, IMPORT_003); + } + + return mapJsonLdToRecipe(recipeData); +} diff --git a/backend/src/services/recipeService.ts b/backend/src/services/recipeService.ts index a436c85a..90c7b45c 100644 --- a/backend/src/services/recipeService.ts +++ b/backend/src/services/recipeService.ts @@ -1,7 +1,12 @@ 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 { + RECIPE_TAGS_SELECT, + RECIPE_INGREDIENTS_SELECT, + RECIPE_STEPS_SELECT, +} from "../util/prismaSelects"; +import { resolveTagsForRecipe } from "./tagService"; +import { StepInput } from "../util/validation"; type TransactionClient = Omit< PrismaClient, @@ -10,23 +15,42 @@ type TransactionClient = Omit< export interface IngredientInput { name: string; - quantity?: string; + quantity?: number | null; + unitId?: string; } // --- Helpers partages pour tags/ingredients --- -export async function upsertTags(tx: TransactionClient, recipeId: string, tags: string[]) { - const normalizedTags = normalizeNames(tags); +export async function upsertTags( + tx: TransactionClient, + recipeId: string, + tags: string[], + userId: string, + communityId: string | null +): Promise { + const { tagIds, pendingTagIds } = await resolveTagsForRecipe(tx, tags, userId, communityId); - for (const tagName of normalizedTags) { - const tag = await tx.tag.upsert({ - where: { name: tagName }, - create: { name: tagName }, - update: {}, + for (const tagId of tagIds) { + await tx.recipeTag.create({ + data: { recipeId, tagId }, }); + } + return pendingTagIds; +} + +/** + * Lie des tagIds deja resolus a une recette (sans re-resoudre les noms). + * Utilise pour la copie perso d'une recette communautaire. + */ +export async function linkTagsToRecipe( + tx: TransactionClient, + recipeId: string, + tagIds: string[] +): Promise { + for (const tagId of tagIds) { await tx.recipeTag.create({ - data: { recipeId, tagId: tag.id }, + data: { recipeId, tagId }, }); } } @@ -34,50 +58,137 @@ export async function upsertTags(tx: TransactionClient, recipeId: string, tags: 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?.trim() || null, + quantity: ing.quantity ?? null, + unitId: ing.unitId ?? null, order: i, }, }); } } +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, + }, + }); + } +} + +// --- Helpers pour steps --- + +export async function upsertSteps(tx: TransactionClient, recipeId: string, steps: StepInput[]) { + for (let i = 0; i < steps.length; i++) { + await tx.recipeStep.create({ + data: { + recipeId, + order: i, + instruction: steps[i].instruction.trim(), + }, + }); + } +} + +export async function upsertProposalSteps( + tx: TransactionClient, + proposalId: string, + steps: StepInput[] +) { + for (let i = 0; i < steps.length; i++) { + await tx.proposalStep.create({ + data: { + proposalId, + order: i, + instruction: steps[i].instruction.trim(), + }, + }); + } +} + // --- Select pour re-fetch apres create/update --- const RECIPE_RESULT_SELECT = { id: true, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, createdAt: true, updatedAt: true, creatorId: true, tags: RECIPE_TAGS_SELECT, ingredients: RECIPE_INGREDIENTS_SELECT, + steps: RECIPE_STEPS_SELECT, }; // --- Service functions --- interface CreateRecipeData { title: string; - content: string; - imageUrl?: string | null; + servings: number; + prepTime?: number | null; + cookTime?: number | null; + restTime?: number | null; + steps: StepInput[]; + imageKey?: string | null; tags: string[]; ingredients: IngredientInput[]; } @@ -87,18 +198,23 @@ export async function createRecipe(userId: string, data: CreateRecipeData) { const recipe = await tx.recipe.create({ data: { title: data.title.trim(), - content: data.content.trim(), - imageUrl: data.imageUrl?.trim() || null, + servings: data.servings, + prepTime: data.prepTime ?? null, + cookTime: data.cookTime ?? null, + restTime: data.restTime ?? null, + imageKey: data.imageKey?.trim() || null, creatorId: userId, }, }); + await upsertSteps(tx, recipe.id, data.steps); + 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) { - await upsertIngredients(tx, recipe.id, data.ingredients); + await upsertIngredients(tx, recipe.id, data.ingredients, userId); } return tx.recipe.findUnique({ @@ -110,8 +226,12 @@ export async function createRecipe(userId: string, data: CreateRecipeData) { interface UpdateRecipeData { title?: string; - content?: string; - imageUrl?: string; + servings?: number; + prepTime?: number | null; + cookTime?: number | null; + restTime?: number | null; + steps?: StepInput[]; + imageKey?: string; tags?: string[]; ingredients?: IngredientInput[]; } @@ -126,43 +246,57 @@ interface RecipeForSync { export async function updateRecipe( recipeId: string, data: UpdateRecipeData, - recipe: RecipeForSync + 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 }, data: { ...(data.title !== undefined && { title: data.title.trim() }), - ...(data.content !== undefined && { content: data.content.trim() }), - ...(data.imageUrl !== undefined && { imageUrl: data.imageUrl?.trim() || null }), + ...(data.servings !== undefined && { servings: data.servings }), + ...(data.prepTime !== undefined && { prepTime: data.prepTime }), + ...(data.cookTime !== undefined && { cookTime: data.cookTime }), + ...(data.restTime !== undefined && { restTime: data.restTime }), + ...(data.imageKey !== undefined && { imageKey: data.imageKey?.trim() || null }), }, }); + // Remplacer steps si fournis + if (data.steps !== undefined) { + await tx.recipeStep.deleteMany({ where: { recipeId } }); + await upsertSteps(tx, recipeId, data.steps); + } + // Remplacer tags si fournis if (data.tags !== undefined) { await tx.recipeTag.deleteMany({ where: { recipeId } }); - await upsertTags(tx, recipeId, data.tags); + pendingTagIds = await upsertTags(tx, recipeId, data.tags, userId, recipe.communityId); } // 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 }, select: RECIPE_RESULT_SELECT, }); }); + + return { result, pendingTagIds }; } /** - * Synchronise titre, contenu, imageUrl et ingredients vers les recettes liees. + * Synchronise titre, contenu, imageKey et ingredients vers les recettes liees. * Tags sont LOCAUX (pas synchronises). * Forks (sharedFromCommunityId != null) et variantes (isVariant = true) sont exclus. */ @@ -170,14 +304,19 @@ 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(); - if (data.content !== undefined) syncData.content = data.content.trim(); - if (data.imageUrl !== undefined) syncData.imageUrl = data.imageUrl?.trim() || null; - - const hasSyncableFields = Object.keys(syncData).length > 0 || data.ingredients !== undefined; + if (data.servings !== undefined) syncData.servings = data.servings; + if (data.prepTime !== undefined) syncData.prepTime = data.prepTime; + if (data.cookTime !== undefined) syncData.cookTime = data.cookTime; + if (data.restTime !== undefined) syncData.restTime = data.restTime; + if (data.imageKey !== undefined) syncData.imageKey = data.imageKey?.trim() || null; + + const hasSyncableFields = + Object.keys(syncData).length > 0 || data.ingredients !== undefined || data.steps !== undefined; if (!hasSyncableFields) return; // Trouver les recettes liees a synchroniser @@ -215,7 +354,7 @@ async function syncLinkedRecipes( if (linkedRecipeIds.length === 0) return; - // Mettre a jour titre, contenu, imageUrl + // Mettre a jour titre, contenu, imageKey if (Object.keys(syncData).length > 0) { await tx.recipe.updateMany({ where: { id: { in: linkedRecipeIds } }, @@ -223,11 +362,19 @@ async function syncLinkedRecipes( }); } + // Synchroniser les steps + if (data.steps !== undefined) { + for (const linkedId of linkedRecipeIds) { + await tx.recipeStep.deleteMany({ where: { recipeId: linkedId } }); + await upsertSteps(tx, linkedId, data.steps); + } + } + // Synchroniser les ingredients 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/backend/src/services/shareService.ts b/backend/src/services/shareService.ts index 1682ee61..b4c68fd2 100644 --- a/backend/src/services/shareService.ts +++ b/backend/src/services/shareService.ts @@ -1,14 +1,37 @@ +import { PrismaClient } from "@prisma/client"; import prisma from "../util/db"; -import { RECIPE_TAGS_SELECT, RECIPE_INGREDIENTS_SELECT } from "../util/prismaSelects"; +import { + RECIPE_TAGS_SELECT, + RECIPE_INGREDIENTS_SELECT, + RECIPE_STEPS_SELECT, +} from "../util/prismaSelects"; +import { resolveTagsForFork } from "./tagService"; + +type TransactionClient = Omit< + PrismaClient, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends" +>; interface SourceRecipeForShare { id: string; title: string; - content: string; - imageUrl: string | null; + servings: number; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + imageKey: string | null; communityId: string; - tags: { tagId: string }[]; - ingredients: { ingredientId: string; quantity: string | null; order: number }[]; + tags: { + tagId: string; + tag: { id: string; name: string; scope: string; communityId: string | null }; + }[]; + ingredients: { + ingredientId: string; + quantity: number | null; + unitId: string | null; + order: number; + }[]; + steps: { order: number; instruction: string }[]; } /** @@ -26,8 +49,11 @@ export async function forkRecipe( const forkedRecipe = await tx.recipe.create({ data: { title: sourceRecipe.title, - content: sourceRecipe.content, - imageUrl: sourceRecipe.imageUrl, + servings: sourceRecipe.servings, + prepTime: sourceRecipe.prepTime, + cookTime: sourceRecipe.cookTime, + restTime: sourceRecipe.restTime, + imageKey: sourceRecipe.imageKey, creatorId: userId, communityId: targetCommunityId, originRecipeId: sourceRecipe.id, @@ -36,16 +62,43 @@ export async function forkRecipe( }, }); - // Copier les tags - if (sourceRecipe.tags.length > 0) { - await tx.recipeTag.createMany({ - data: sourceRecipe.tags.map((rt) => ({ + // Copier les steps + if (sourceRecipe.steps.length > 0) { + await tx.recipeStep.createMany({ + data: sourceRecipe.steps.map((s) => ({ recipeId: forkedRecipe.id, - tagId: rt.tagId, + order: s.order, + instruction: s.instruction, })), }); } + // Copier les tags (scope-aware) + let forkPendingTagIds: string[] = []; + if (sourceRecipe.tags.length > 0) { + const sourceTags = sourceRecipe.tags.map((rt) => ({ + id: rt.tag.id, + name: rt.tag.name, + scope: rt.tag.scope, + communityId: rt.tag.communityId, + })); + const { tagIds, pendingTagIds } = await resolveTagsForFork( + tx, + sourceTags, + targetCommunityId, + userId + ); + forkPendingTagIds = pendingTagIds; + if (tagIds.length > 0) { + await tx.recipeTag.createMany({ + data: tagIds.map((tagId) => ({ + recipeId: forkedRecipe.id, + tagId, + })), + }); + } + } + // Copier les ingredients if (sourceRecipe.ingredients.length > 0) { await tx.recipeIngredient.createMany({ @@ -53,6 +106,7 @@ export async function forkRecipe( recipeId: forkedRecipe.id, ingredientId: ri.ingredientId, quantity: ri.quantity, + unitId: ri.unitId, order: ri.order, })), }); @@ -91,13 +145,16 @@ 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, title: true, - content: true, - imageUrl: true, + servings: true, + prepTime: true, + cookTime: true, + restTime: true, + imageKey: true, createdAt: true, updatedAt: true, creatorId: true, @@ -108,18 +165,33 @@ export async function forkRecipe( community: { select: { id: true, name: true } }, tags: RECIPE_TAGS_SELECT, ingredients: RECIPE_INGREDIENTS_SELECT, + steps: RECIPE_STEPS_SELECT, }, }); + + return { recipe: forkedResult, pendingTagIds: forkPendingTagIds }; }); } interface SourceRecipeForPublish { id: string; title: string; - content: string; - imageUrl: string | null; - tags: { tagId: string }[]; - ingredients: { ingredientId: string; quantity: string | null; order: number }[]; + servings: number; + prepTime: number | null; + cookTime: number | null; + restTime: number | null; + imageKey: string | null; + tags: { + tagId: string; + tag: { id: string; name: string; scope: string; communityId: string | null }; + }[]; + ingredients: { + ingredientId: string; + quantity: number | null; + unitId: string | null; + order: number; + }[]; + steps: { order: number; instruction: string }[]; } /** @@ -132,34 +204,63 @@ export async function publishRecipe( ) { return prisma.$transaction(async (tx) => { const results = []; + const allPendingTagIds: string[] = []; for (const communityId of communityIds) { const communityRecipe = await tx.recipe.create({ data: { title: sourceRecipe.title, - content: sourceRecipe.content, - imageUrl: sourceRecipe.imageUrl, + servings: sourceRecipe.servings, + prepTime: sourceRecipe.prepTime, + cookTime: sourceRecipe.cookTime, + restTime: sourceRecipe.restTime, + imageKey: sourceRecipe.imageKey, creatorId: userId, communityId, originRecipeId: sourceRecipe.id, }, }); - if (sourceRecipe.tags.length > 0) { - await tx.recipeTag.createMany({ - data: sourceRecipe.tags.map((rt) => ({ + // Copier les steps + if (sourceRecipe.steps.length > 0) { + await tx.recipeStep.createMany({ + data: sourceRecipe.steps.map((s) => ({ recipeId: communityRecipe.id, - tagId: rt.tagId, + order: s.order, + instruction: s.instruction, })), }); } + // Copier les tags (scope-aware via resolveTagsForFork) + if (sourceRecipe.tags.length > 0) { + const sourceTags = sourceRecipe.tags.map((rt) => ({ + id: rt.tag.id, + name: rt.tag.name, + scope: rt.tag.scope, + communityId: rt.tag.communityId, + })); + const { tagIds, pendingTagIds } = await resolveTagsForFork( + tx, + sourceTags, + communityId, + userId + ); + allPendingTagIds.push(...pendingTagIds); + if (tagIds.length > 0) { + await tx.recipeTag.createMany({ + data: tagIds.map((tagId) => ({ recipeId: communityRecipe.id, tagId })), + }); + } + } + if (sourceRecipe.ingredients.length > 0) { await tx.recipeIngredient.createMany({ data: sourceRecipe.ingredients.map((ri) => ({ recipeId: communityRecipe.id, ingredientId: ri.ingredientId, quantity: ri.quantity, + unitId: ri.unitId, order: ri.order, })), }); @@ -177,7 +278,7 @@ export async function publishRecipe( results.push(communityRecipe); } - return Promise.all( + const recipes = await Promise.all( results.map((r) => tx.recipe.findUnique({ where: { id: r.id }, @@ -191,6 +292,8 @@ export async function publishRecipe( }) ) ); + + return { recipes, pendingTagIds: allPendingTagIds }; }); } @@ -255,17 +358,15 @@ export async function getRecipeFamilyCommunities(recipeId: string) { // --- Helper interne --- -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function updateAncestorAnalytics(tx: any, sourceRecipeId: string) { +async function updateAncestorAnalytics(tx: TransactionClient, sourceRecipeId: string) { const recipesToUpdate: string[] = [sourceRecipeId]; let currentRecipeId: string | null = sourceRecipeId; while (currentRecipeId) { - const parentRecipe: { originRecipeId: string | null } | null = - await tx.recipe.findFirst({ - where: { id: currentRecipeId }, - select: { originRecipeId: true }, - }); + const parentRecipe: { originRecipeId: string | null } | null = await tx.recipe.findFirst({ + where: { id: currentRecipeId }, + select: { originRecipeId: true }, + }); if (parentRecipe?.originRecipeId) { recipesToUpdate.push(parentRecipe.originRecipeId); diff --git a/backend/src/services/socketServer.ts b/backend/src/services/socketServer.ts index c5294810..be0e52b0 100644 --- a/backend/src/services/socketServer.ts +++ b/backend/src/services/socketServer.ts @@ -5,6 +5,15 @@ import prisma from "../util/db"; import env from "../util/validateEnv"; import appEvents, { AppEvent } from "./eventEmitter"; import logger from "../util/logger"; +import { NotificationCategory } from "@prisma/client"; +import { + getCategoryForType, + createNotification, + createBroadcastNotifications, + resolveTemplateVars, +} from "./notificationService"; + +const ALL_CATEGORIES = Object.values(NotificationCategory); let io: Server | null = null; @@ -12,16 +21,47 @@ export function getIO(): Server | null { return io; } -export function initSocketServer( - httpServer: HttpServer, - userSessionMiddleware: RequestHandler -) { +/** + * Recupere le compteur de notifications non-lues pour un user (total + par categorie). + */ +async function getUnreadCount(userId: string) { + const [total, ...categoryCounts] = await Promise.all([ + prisma.notification.count({ where: { userId, readAt: null } }), + ...ALL_CATEGORIES.map((cat) => + prisma.notification.count({ + where: { userId, readAt: null, category: cat }, + }) + ), + ]); + + const byCategory: Record = {}; + ALL_CATEGORIES.forEach((cat, i) => { + byCategory[cat] = categoryCounts[i]; + }); + + return { count: total, byCategory }; +} + +/** + * Emet notification:count a un user connecte. + */ +async function emitUnreadCount(ioServer: Server, userId: string) { + try { + const countData = await getUnreadCount(userId); + ioServer.to(`user:${userId}`).emit("notification:count", countData); + } catch (err) { + logger.debug({ err, userId }, "Failed to emit notification:count"); + } +} + +// Types de broadcast communautaire (notifient tous les membres sauf l'acteur) +const BROADCAST_TYPES = new Set(["RECIPE_CREATED", "RECIPE_SHARED", "USER_JOINED", "USER_LEFT"]); + +export function initSocketServer(httpServer: HttpServer, userSessionMiddleware: RequestHandler) { const corsOrigin = env.CORS_ORIGIN || false; io = new Server(httpServer, { - cors: corsOrigin - ? { origin: corsOrigin, credentials: true } - : undefined, + cors: corsOrigin ? { origin: corsOrigin, credentials: true } : undefined, transports: ["websocket", "polling"], }); @@ -31,8 +71,7 @@ export function initSocketServer( const res = {} as Parameters[1]; userSessionMiddleware(req, res, (err?: unknown) => { if (err) return next(new Error("Session error")); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userId = (req as any).session?.userId; + const userId = req.session?.userId; if (!userId) { return next(new Error("Authentication required")); } @@ -60,6 +99,9 @@ export function initSocketServer( logger.debug({ err, userId }, "Failed to load community memberships for socket"); } + // Emit initial notification:count on connection + emitUnreadCount(io!, userId); + // Dynamic room management socket.on("join:community", (communityId: string) => { socket.join(`community:${communityId}`); @@ -74,7 +116,7 @@ export function initSocketServer( appEvents.on("activity", (event: AppEvent) => { if (!io) return; - // Broadcast to community room + // Broadcast activity to community room (inchange) if (event.communityId) { io.to(`community:${event.communityId}`).emit("activity", { type: event.type, @@ -85,19 +127,75 @@ export function initSocketServer( }); } - // Send personal notifications to target users - if (event.targetUserIds) { - for (const targetUserId of event.targetUserIds) { - io.to(`user:${targetUserId}`).emit("notification", { - type: event.type, - userId: event.userId, - communityId: event.communityId, - recipeId: event.recipeId, - metadata: event.metadata, - }); - } + // Persister les notifications et emettre notification:new + notification:count + const category = getCategoryForType(event.type); + if (category) { + handleNotificationCreation(io!, event).catch((err) => { + logger.error({ err, event: event.type }, "Failed to handle notification creation"); + }); } }); return io; } + +/** + * Gere la creation des notifications en DB et l'emission WebSocket. + * Separe en broadcast (tous les membres sauf acteur) et personal (targetUserIds). + */ +async function handleNotificationCreation(ioServer: Server, event: AppEvent) { + const templateVars = await resolveTemplateVars(event); + + if (BROADCAST_TYPES.has(event.type) && event.communityId) { + // Notifications broadcast : tous les membres sauf l'acteur + const notifications = await createBroadcastNotifications({ + type: event.type, + actorId: event.userId, + communityId: event.communityId, + recipeId: event.recipeId, + metadata: event.metadata, + templateVars, + }); + + // Emettre notification:new a chaque destinataire connecte + const notifiedUserIds = new Set(); + for (const notif of notifications) { + ioServer.to(`user:${notif.userId}`).emit("notification:new", { + notification: notif, + }); + notifiedUserIds.add(notif.userId); + } + + // Emettre notification:count a chaque destinataire + for (const userId of notifiedUserIds) { + emitUnreadCount(ioServer, userId); + } + } else if (event.targetUserIds && event.targetUserIds.length > 0) { + // Notifications personnelles (invitations, proposals, etc.) + const notifiedUserIds: string[] = []; + + for (const targetUserId of event.targetUserIds) { + const notification = await createNotification({ + userId: targetUserId, + type: event.type, + actorId: event.userId, + communityId: event.communityId, + recipeId: event.recipeId, + metadata: event.metadata, + templateVars, + }); + + if (notification) { + ioServer.to(`user:${targetUserId}`).emit("notification:new", { + notification, + }); + notifiedUserIds.push(targetUserId); + } + } + + // Emettre notification:count a chaque destinataire + for (const userId of notifiedUserIds) { + emitUnreadCount(ioServer, userId); + } + } +} diff --git a/backend/src/services/storageService.ts b/backend/src/services/storageService.ts new file mode 100644 index 00000000..f4b9f60f --- /dev/null +++ b/backend/src/services/storageService.ts @@ -0,0 +1,114 @@ +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { storageConfig } from "../config/storage"; +import logger from "../util/logger"; + +// Client interne pour les operations serveur (head, delete, etc.) +const s3Client = new S3Client({ + endpoint: `${storageConfig.useSSL ? "https" : "http"}://${storageConfig.endpoint}:${storageConfig.port}`, + region: "us-east-1", // obligatoire pour le SDK, mais ignore par MinIO + credentials: { + accessKeyId: storageConfig.accessKey, + secretAccessKey: storageConfig.secretKey, + }, + forcePathStyle: true, // obligatoire pour MinIO (pas de virtual-hosted style) +}); + +// Client avec endpoint public pour generer les presigned URLs destinees au frontend +const publicS3Client = new S3Client({ + endpoint: storageConfig.publicUrl, + region: "us-east-1", + credentials: { + accessKeyId: storageConfig.accessKey, + secretAccessKey: storageConfig.secretKey, + }, + forcePathStyle: true, +}); + +/** + * Genere une presigned PUT URL pour upload direct depuis le frontend. + */ +export async function generatePresignedUploadUrl(key: string): Promise { + const command = new PutObjectCommand({ + Bucket: storageConfig.bucket, + Key: key, + ContentType: "image/webp", + }); + + // Utilise le client public pour que l'URL soit accessible depuis le navigateur + const url = await getSignedUrl(publicS3Client, command, { + expiresIn: storageConfig.presignedUrlTTL, + }); + + logger.debug({ key }, "Presigned upload URL generated"); + return url; +} + +/** + * Recupere les metadata d'un objet (content-type, taille). + * Retourne null si l'objet n'existe pas. + */ +export async function headObject( + key: string +): Promise<{ contentType: string; contentLength: number } | null> { + try { + const response = await s3Client.send( + new HeadObjectCommand({ + Bucket: storageConfig.bucket, + Key: key, + }) + ); + + return { + contentType: response.ContentType ?? "unknown", + contentLength: response.ContentLength ?? 0, + }; + } catch (err: unknown) { + if (err instanceof Error && err.name === "NotFound") { + return null; + } + throw err; + } +} + +/** + * Supprime un objet du bucket. + * Ne leve pas d'erreur si l'objet n'existe pas (idempotent). + */ +export async function deleteObject(key: string): Promise { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: storageConfig.bucket, + Key: key, + }) + ); + + logger.debug({ key }, "Object deleted from storage"); +} + +/** + * Valide qu'un fichier uploade sur MinIO respecte les contraintes. + * Retourne un message d'erreur ou null si valide. + */ +export async function validateUploadedFile(key: string): Promise { + const metadata = await headObject(key); + + if (!metadata) { + return "File not found on storage"; + } + + if (!storageConfig.allowedMimeTypes.includes(metadata.contentType)) { + return `Invalid file type: ${metadata.contentType}. Allowed: ${storageConfig.allowedMimeTypes.join(", ")}`; + } + + if (metadata.contentLength > storageConfig.maxFileSize) { + return `File too large: ${metadata.contentLength} bytes. Max: ${storageConfig.maxFileSize} bytes`; + } + + return null; +} diff --git a/backend/src/services/tagService.ts b/backend/src/services/tagService.ts new file mode 100644 index 00000000..f30d7e45 --- /dev/null +++ b/backend/src/services/tagService.ts @@ -0,0 +1,302 @@ +import { PrismaClient } from "@prisma/client"; +import createHttpError from "http-errors"; +import prisma from "../util/db"; +import { normalizeNames } from "../util/validation"; +import { TAG_003 } from "../constants/errorCodes"; + +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); + } + + 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); + } + + 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<{ tagIds: string[]; pendingTagIds: string[] }> { + const tagIds: string[] = []; + const pendingTagIds: 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); + pendingTagIds.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); + pendingTagIds.push(newTag.id); + } + + return { tagIds, pendingTagIds }; +} 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/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index f159ed1c..78161eee 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -4,10 +4,7 @@ declare global { namespace Express { interface Request { userCommunity?: UserCommunity & { - community: Pick< - Community, - "id" | "name" | "description" | "visibility" | "deletedAt" - >; + community: Pick; }; } } diff --git a/backend/src/types/session.d.ts b/backend/src/types/session.d.ts index 7d864e1b..a0c36650 100644 --- a/backend/src/types/session.d.ts +++ b/backend/src/types/session.d.ts @@ -4,7 +4,7 @@ declare module "express-session" { interface SessionData { // User session userId?: string; - + // Admin session (utilise sur routes /api/admin/*) adminId?: string; totpVerified?: boolean; diff --git a/backend/src/util/assertIsDefine.ts b/backend/src/util/assertIsDefine.ts index df2bf40e..17214176 100644 --- a/backend/src/util/assertIsDefine.ts +++ b/backend/src/util/assertIsDefine.ts @@ -2,4 +2,4 @@ export function assertIsDefine(val: T): asserts val is NonNullable { if (!val) { throw Error(`Expected 'val' to be defined, but received ${val}`); } -} \ No newline at end of file +} diff --git a/backend/src/util/db.ts b/backend/src/util/db.ts index 553b19ad..79f03d96 100644 --- a/backend/src/util/db.ts +++ b/backend/src/util/db.ts @@ -1,17 +1,44 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from "@prisma/client"; +import logger from "./logger"; + +const env = process.env.NODE_ENV || "development"; const prismaClientSingleton = () => { - return new PrismaClient() -} + const client = new PrismaClient({ + log: + env === "development" + ? [ + { emit: "event", level: "query" }, + { emit: "stdout", level: "warn" }, + { emit: "stdout", level: "error" }, + ] + : env === "test" + ? [] + : [ + { emit: "stdout", level: "warn" }, + { emit: "stdout", level: "error" }, + ], + }); + + if (env === "development") { + client.$on("query", (e) => { + if (e.duration > 100) { + logger.warn({ duration: e.duration, query: e.query }, "Slow query detected"); + } + }); + } + + return client; +}; -type PrismaClientSingleton = ReturnType +type PrismaClientSingleton = ReturnType; const globalForPrisma = globalThis as unknown as { - prisma: PrismaClientSingleton | undefined -} + prisma: PrismaClientSingleton | undefined; +}; -const prisma = globalForPrisma.prisma ?? prismaClientSingleton() +const prisma = globalForPrisma.prisma ?? prismaClientSingleton(); -export default prisma +export default prisma; -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma \ No newline at end of file +if (env !== "production") globalForPrisma.prisma = prisma; diff --git a/backend/src/util/pagination.ts b/backend/src/util/pagination.ts index e86100ab..a5c643d3 100644 --- a/backend/src/util/pagination.ts +++ b/backend/src/util/pagination.ts @@ -23,10 +23,7 @@ export function parsePagination( defaultLimit = 20, maxLimit = 100 ): PaginationParams { - const limit = Math.min( - Math.max(parseInt(query.limit || String(defaultLimit), 10), 1), - maxLimit - ); + const limit = Math.min(Math.max(parseInt(query.limit || String(defaultLimit), 10), 1), maxLimit); const offset = Math.max(parseInt(query.offset || "0", 10), 0); return { limit, offset }; } diff --git a/backend/src/util/prismaSelects.ts b/backend/src/util/prismaSelects.ts index ee707760..e3f38665 100644 --- a/backend/src/util/prismaSelects.ts +++ b/backend/src/util/prismaSelects.ts @@ -3,18 +3,47 @@ * 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, }, }, }, } 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, + }, + }, + unit: { + select: { + id: true, + abbreviation: true, + }, + }, + }, + orderBy: { + order: "asc" as const, + }, +}; + /** Select pour les ingredients d'une recette, tries par ordre */ export const RECIPE_INGREDIENTS_SELECT = { select: { @@ -27,16 +56,47 @@ export const RECIPE_INGREDIENTS_SELECT = { name: true, }, }, + unit: { + select: { + id: true, + abbreviation: true, + }, + }, + }, + orderBy: { + order: "asc" as const, + }, +}; + +/** Select pour les etapes d'une recette, triees par ordre */ +export const RECIPE_STEPS_SELECT = { + select: { + id: true, + order: true, + instruction: true, + }, + orderBy: { + order: "asc" as const, + }, +}; + +/** Select pour les etapes proposees dans une proposal, triees par ordre */ +export const PROPOSAL_STEPS_SELECT = { + select: { + id: true, + order: true, + instruction: true, }, orderBy: { order: "asc" as const, }, }; -/** Select complet pour le detail d'une recette (tags + ingredients + creator) */ +/** Select complet pour le detail d'une recette (tags + ingredients + steps + creator) */ export const RECIPE_DETAIL_INCLUDE = { tags: RECIPE_TAGS_SELECT, ingredients: RECIPE_INGREDIENTS_SELECT, + steps: RECIPE_STEPS_SELECT, creator: { select: { id: true, diff --git a/backend/src/util/responseFormatters.ts b/backend/src/util/responseFormatters.ts index c2bcf4a1..425b53e4 100644 --- a/backend/src/util/responseFormatters.ts +++ b/backend/src/util/responseFormatters.ts @@ -3,17 +3,26 @@ * 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; + quantity: number | null; order: number; ingredient: { id: string; name: string }; + unit?: { id: string; abbreviation: string } | null; }; /** 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 */ @@ -23,6 +32,19 @@ export function formatIngredients(ingredients: RawIngredient[]) { name: ri.ingredient.name, ingredientId: ri.ingredient.id, quantity: ri.quantity, + unitId: ri.unit?.id ?? null, + unit: ri.unit ?? null, order: ri.order, })); } + +type RawStep = { id: string; order: number; instruction: string }; + +/** Formate les etapes depuis le format Prisma */ +export function formatSteps(steps: RawStep[]) { + return steps.map((s) => ({ + id: s.id, + order: s.order, + instruction: s.instruction, + })); +} diff --git a/backend/src/util/validateEnv.ts b/backend/src/util/validateEnv.ts index 39d2d443..e512abfe 100644 --- a/backend/src/util/validateEnv.ts +++ b/backend/src/util/validateEnv.ts @@ -1,5 +1,5 @@ import { cleanEnv } from "envalid"; -import { port, str } from "envalid/dist/validators"; +import { bool, port, str } from "envalid/dist/validators"; export default cleanEnv(process.env, { DATABASE_URL: str(), @@ -8,4 +8,12 @@ export default cleanEnv(process.env, { ADMIN_SESSION_SECRET: str(), CORS_ORIGIN: str({ default: "" }), NODE_ENV: str({ choices: ["development", "production", "test"], default: "development" }), -}); \ No newline at end of file + // MinIO / S3 + MINIO_ENDPOINT: str({ default: "minio" }), + MINIO_PORT: port({ default: 9000 }), + MINIO_ACCESS_KEY: str({ default: "minioadmin" }), + MINIO_SECRET_KEY: str({ default: "minioadmin" }), + MINIO_BUCKET: str({ default: "forestmanager-images-dev" }), + MINIO_PUBLIC_URL: str({ default: "http://localhost:9000" }), + MINIO_USE_SSL: bool({ default: false }), +}); diff --git a/backend/src/util/validation.ts b/backend/src/util/validation.ts index a835bba5..6059bf14 100644 --- a/backend/src/util/validation.ts +++ b/backend/src/util/validation.ts @@ -1,24 +1,48 @@ // Validation constants et utilitaires partages +// Note: Les type guards (assertString, etc.) ont ete supprimes car Zod gere maintenant la validation export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const USERNAME_REGEX = /^[a-zA-Z0-9_]+$/; export const MIN_USERNAME_LENGTH = 3; export const MIN_PASSWORD_LENGTH = 8; +// Max length constants +export const MAX_USERNAME_LENGTH = 30; +export const MAX_PASSWORD_LENGTH = 128; +export const MAX_TITLE_LENGTH = 200; +export const MAX_NAME_LENGTH = 100; +export const MAX_REASON_LENGTH = 500; +export const MAX_URL_LENGTH = 2048; +export const MAX_FILTER_ITEMS = 20; +export const MAX_TAGS_PER_RECIPE = 10; +export const MAX_SEARCH_LENGTH = 200; + export const COMMUNITY_VALIDATION = { NAME_MIN: 3, NAME_MAX: 100, DESCRIPTION_MAX: 1000, }; +// --- ValidationError --- +// Garde pour compatibilite avec errorHandler.ts + +export class ValidationError extends Error { + public readonly statusCode = 400; + public readonly code: string; + + constructor(message: string, code = "VALIDATION_001") { + super(`${code}: ${message}`); + this.code = code; + this.name = "ValidationError"; + } +} + /** * Normalise une liste de noms (tags ou ingredients) : * trim, lowercase, deduplique, filtre les vides. */ export function normalizeNames(items: string[]): string[] { - return [ - ...new Set(items.map((item) => item.trim().toLowerCase()).filter(Boolean)), - ]; + return [...new Set(items.map((item) => item.trim().toLowerCase()).filter(Boolean))]; } /** @@ -34,3 +58,31 @@ export function isValidHttpUrl(url: string | null | undefined): boolean { return false; } } + +// --- Recipe validators (gardes pour les tests unitaires) --- + +export function validateServings(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100; +} + +export function validateTime(value: unknown): value is number | null { + if (value === null || value === undefined) return true; + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 10000; +} + +export interface StepInput { + instruction: string; +} + +export function validateSteps(steps: unknown): steps is StepInput[] { + if (!Array.isArray(steps) || steps.length === 0) return false; + return steps.every( + (s) => + s && + typeof s === "object" && + "instruction" in s && + typeof s.instruction === "string" && + s.instruction.trim().length > 0 && + s.instruction.trim().length <= 5000 + ); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 85bf8de4..8df6b3b8 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ @@ -25,7 +25,7 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "commonjs" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ @@ -34,7 +34,7 @@ "typeRoots": [ "node_modules/@types", "@types" - ], /* Specify multiple folders that act like './node_modules/@types'. */ + ] /* Specify multiple folders that act like './node_modules/@types'. */, // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ @@ -52,7 +52,7 @@ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ @@ -74,12 +74,12 @@ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ @@ -101,9 +101,9 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, "ts-node": { "files": true } -} \ No newline at end of file +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 95d57ead..e76ad2fb 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -1,32 +1,30 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: 'node', - setupFiles: ['./src/__tests__/setup/globalSetup.ts'], - include: ['src/__tests__/**/*.test.ts'], - exclude: ['node_modules', 'dist'], + environment: "node", + setupFiles: ["./src/__tests__/setup/globalSetup.ts"], + include: ["src/__tests__/**/*.test.ts"], + exclude: ["node_modules", "dist"], testTimeout: 30000, hookTimeout: 30000, env: { - NODE_ENV: 'test', + NODE_ENV: "test", }, coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/**', - 'dist/**', - 'src/__tests__/**', - 'prisma/**', - '**/*.d.ts', - ], + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/**", "dist/**", "src/__tests__/**", "prisma/**", "**/*.d.ts"], + thresholds: { + statements: 80, + branches: 70, + }, }, // singleFork requis: les tests partagent une seule DB et afterEach // fait deleteMany() sur toutes les tables. Des workers paralleles // se pollueraient mutuellement. - pool: 'forks', + pool: "forks", poolOptions: { forks: { singleFork: true, diff --git a/docker-compose.minio.yml b/docker-compose.minio.yml new file mode 100644 index 00000000..c8234177 --- /dev/null +++ b/docker-compose.minio.yml @@ -0,0 +1,51 @@ +x-logging: &default-logging + driver: json-file + options: + max-size: "50m" + max-file: "7" + +services: + minio: + image: minio/minio:latest + container_name: minio + hostname: minio + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: + - minio-data:/data + networks: + - minio-net + - proxy + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + labels: + # API S3 (upload/lecture des images) + - "traefik.enable=true" + - "traefik.http.routers.minio-api.rule=Host(`s3.matthias-bouloc.fr`)" + - "traefik.http.routers.minio-api.entrypoints=https" + - "traefik.http.routers.minio-api.tls.certresolver=cloudflare" + - "traefik.http.routers.minio-api.service=minio-api" + - "traefik.http.services.minio-api.loadbalancer.server.port=9000" + # Console admin + - "traefik.http.routers.minio-console.rule=Host(`minio.matthias-bouloc.fr`)" + - "traefik.http.routers.minio-console.entrypoints=https" + - "traefik.http.routers.minio-console.tls.certresolver=cloudflare" + - "traefik.http.routers.minio-console.service=minio-console" + - "traefik.http.services.minio-console.loadbalancer.server.port=9001" + logging: *default-logging + +volumes: + minio-data: + +networks: + minio-net: + name: minio-net + proxy: + external: true + name: proxy diff --git a/docker-compose.preprod.yml b/docker-compose.preprod.yml index c94b080f..20143944 100644 --- a/docker-compose.preprod.yml +++ b/docker-compose.preprod.yml @@ -46,12 +46,20 @@ services: - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - TRUST_PROXY=1 - NODE_OPTIONS=--max-old-space-size=1024 + - MINIO_ENDPOINT=minio + - MINIO_PORT=9000 + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=forestmanager-images-preprod + - MINIO_PUBLIC_URL=https://s3.matthias-bouloc.fr + - MINIO_USE_SSL=false volumes: - backend-logs-dev:/app/logs networks: internal-dev: aliases: - forestmanager-backend + minio-net: depends_on: forestmanager-dev-postgres: condition: service_healthy @@ -92,6 +100,9 @@ networks: proxy: external: true name: proxy + minio-net: + external: true + name: minio-net volumes: postgres_data_dev: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b8be0d7e..474b989f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -46,10 +46,19 @@ services: - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - TRUST_PROXY=1 - NODE_OPTIONS=--max-old-space-size=1024 + - SEED_MODE=prod + - MINIO_ENDPOINT=minio + - MINIO_PORT=9000 + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=forestmanager-images-prod + - MINIO_PUBLIC_URL=https://s3.matthias-bouloc.fr + - MINIO_USE_SSL=false volumes: - backend-logs:/app/logs networks: - internal + - minio-net depends_on: forestmanager-postgres: condition: service_healthy @@ -90,6 +99,9 @@ networks: proxy: external: true name: proxy + minio-net: + external: true + name: minio-net volumes: postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 3498c026..e488e864 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,12 +25,55 @@ services: networks: - internal healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-forestmanager} -d ${POSTGRES_DB:-forestmanager_db}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-forestmanager} -d ${POSTGRES_DB:-forestmanager_db}", + ] interval: 5s timeout: 5s retries: 5 logging: *default-logging + minio: + image: minio/minio:latest + container_name: forestmanager-minio + hostname: minio + restart: unless-stopped + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + volumes: + - minio_data:/data + networks: + - internal + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + logging: *default-logging + + minio-init: + image: minio/mc:latest + container_name: forestmanager-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin && + mc mb --ignore-existing local/forestmanager-images-dev && + mc anonymous set download local/forestmanager-images-dev && + echo 'MinIO init done' + " + networks: + - internal + backend: build: context: . @@ -48,6 +91,13 @@ services: - SESSION_SECRET=${SESSION_SECRET:-dev_session_secret_change_me} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-dev_admin_session_secret_change_me} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000} + - MINIO_ENDPOINT=minio + - MINIO_PORT=9000 + - MINIO_ACCESS_KEY=minioadmin + - MINIO_SECRET_KEY=minioadmin + - MINIO_BUCKET=forestmanager-images-dev + - MINIO_PUBLIC_URL=http://localhost:9000 + - MINIO_USE_SSL=false volumes: - ./backend:/app - /app/node_modules @@ -56,6 +106,8 @@ services: depends_on: postgres: condition: service_healthy + minio: + condition: service_healthy healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/health"] <<: *health_http @@ -101,3 +153,4 @@ networks: volumes: postgres_data: + minio_data: diff --git a/docs/0 - brainstorming futur.md b/docs/0 - brainstorming futur.md index b35da034..f642f85b 100644 --- a/docs/0 - brainstorming futur.md +++ b/docs/0 - brainstorming futur.md @@ -1,34 +1,25 @@ # Projet futur Nous allons travailler sur chacun de ces points les uns après les autres. + Le but est de bien définir chaque point avant de passer à l'implémentation. Tout doit être cohérent avec l'application et son fonctionnement actuel. Ce sont des évolutions, ou parfois des reworks complets de certaines parties du projet afin de les pousser à leur maximum. 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 ??? --> vu que les recettes sont associé à un user, comment faire de manière logique ? +# ~~Rework du système d'ingrédients~~ DONE (Phase 11) -# Rework du système d'ingrédients +Implemente : unites structurees, gouvernance PENDING/APPROVED, moderation admin, propositions avec ingredients, notifications WebSocket. -Problèmes similaires au système de tags : --> vu que les recettes sont associé à un user, comment faire de manière logique ? +# ~~Le système de notifications doit être amélioré~~ DONE (Phase 12) -# Rework des pages recettes (v2) +persistance, fonctionne y compris offline (je me connecte je dois voir les notifs reçues lorsque j'étais offline) -nombre de personne pour les ingrédients, -étapes claires et structurées -temps de préparation, temps de cuisson, de repos, temps total +# ~~Rework des pages recettes (v2)~~ DONE (Phase 13) -sélecteur du nombre de persones avec calcul automatique des quantités +Implemente : servings avec scaling dynamique des quantites, etapes structurees ordonnees, temps de prep/cuisson/repos avec total auto, propositions granulaires (servings/temps/steps), badges temps et servings sur les cartes. -# audit refactorisation complete back + front - -# Système d'upload de photos +# ~~Système d'upload de photos~~ DONE (Phase 15) usage : miniature user ? @@ -65,16 +56,72 @@ Base de données Ajouter imageUrl nullable sur Recipe et Community" Qu'en penses-tu ? est-ce que tu vois une meilleure option, mon projet étant (pour l'instant) petit, très indépendant et je voudrais éviter au maximum les investissements financiers ? +Dans la mesure du possible je ne veux pas fournir de carte bancaire pour être certain de ne pas dépenser pour cette feature. +Si je n'ai pas le choix comment faire pour m'assurer à 100% que je ne dépasserai pas ? +Est-ce qu'il existe une solution en local ? Je cherche tout à de même à avoir une solution la plus professionnelle et sécurisée possible. +# ~~système d'importation de recettes~~ DONE (Phase 16) + +Je ne sais pas si c'est vraiment nécessaire, mais il serait bien pratique de pouvoir importer des recettes depuis un copier coller. Le problème est que le format d'origine varie beaucoup. Un LLM pourrait aider à parser le format d'origine et à le convertir en format interne mais je ne veux pas de solution payante. Dans un monde idéal j'aimerais pouvoir coller un texte ou un lien, que le llm préremplit le formulaire de recette, l'utilisateur vérifie, ajuste et valide. +S'il existe une solution autre qu'un LLM c'est l'idéal mais pour le coup je ne maitrise pas du tout le sujet. Que peux-tu me proposer comme solutions ? Si c'est trop complexe, je suis ouvert à des solutions moins sophistiquées, et je suis également ouvert à ne pas réaliser cette fonctionnalité. + +# ~~audit refactorisation complete~~ DONE (Phase 17) + +back + front +Clean code +Dry +optimisation du code +zod ? est-ce nécessaire ? +abstraction +app.ts et app.tsx optimisés, clair et lisisbles +mvp parfait, la suite ne sera que des itérations +audit performances +Audit sécurité complet +npm audit +lint +tests + +# ~~Corrections des imports depuis Hello Fresh~~ DONE + +exemple : https://www.hellofresh.fr/recipes/aubergine-laquee-au-miso-and-oeuf-64fb2da9786cce2df0e01899 +(pas de nom d'ingrédients, des balises html dans les étapes etc ) + +# ~~Update des docs et petit ménage dans Progress.md ?~~ DONE + +docs/mvp/ supprime, roadmaps des features terminees supprimees, PROGRESS/CLAUDE.md simplifies. +Les specs fonctionnelles (SPEC\_\*.md) sont conservees dans docs/features/ comme reference. + +# ~~Version mobile inutilisable~~ DONE + +Spec complete : `docs/features/mobile-rework/SPEC_MOBILE_REWORK.md` +Bottom tab bar, bottom sheets, breakpoints md:, touch targets 44px, safe areas, layout adaptatif par composant. + +# ~~codes erreur doublons : string literal~~ DONE + +Des codes erreurs utilisent des string literal au lieu de constantes car le code erreur est le même. Peut être qu'il faudrait créer de nouveaux code erreur pour ces cas spécifiques et les centraliser pour éviter les string literal et les doublons. + +## système + page de changelog automatique + +(Bouton pour y accéder à ajouter dans le footer du menu sidebar lorsqu'un user est connecté) +Le changelog représente des blocs de texte type "blog" du plus récent au plus ancien. Lorsqu'une merge à Master est faite et validée, lors de la phase de "deploy_prod", un résumé de tout les commits de manière organisé (Nouvelle feature, updates, bug) doit être généré et stocké en base. +L'idée est d'avoir un véritable changelog automatisé. Pas besoin de retranscrire tout ce qui touche au tests, déploiement, update de docs etc. Il faut retranscrire uniquement ce qui impacte une correction ou une évolution de l'expérience utilisateur. +Dans l'interface admin, il faut pouvoir modifier et supprimer ces message (toujours avec une confirmation de validation) + +- Mise en place d'un système de version propre pour suivre les patch du changelog ? + ## Gestionnaire de planning de repas dans une communauté automatique + drag and drop (à a Trello ? ) -possibilité de créer des règles d'automatisation selon des tags, cooldown avant qu'une recette ne revienne, repas du midi, repas du soir, pouvoir mettre des poids sur les recettes afin de favoriser ou non leur récurence etc pouvoir faire plusieurs template de génération (par saison par exemple)etc. +Sur chaques cartes, il doit y avoir un bouton (qui demande confirmation au clic) pour remplacer le repas proposé un autre tout en conservant les critères de génération. +possibilité de créer des règles d'automatisation selon des tags (possibilité avancée de mettre des poids sur les tags), cooldown avant qu'une recette ne revienne, repas du midi, repas du soir, pouvoir mettre des poids sur les recettes afin de favoriser ou non leur récurence etc pouvoir faire plusieurs template de génération (par saison par exemple)etc. Il faut pouvoir modifier le planning à tout moment, soit en drag and drop des menus d'un repas sur l'autre afin de réorganiser le planning à tout moment dans la semaine, soit en modifiant directement le menu d'un repas. -### Brique liste de courses +Pouvoir ajouter une liste de recettes (le nom pourrait être suffisant) / d'idée et de pouvoir faire en sorte que le générateur de planning puisse les utiliser. +De fait cela permet d'ajouter de nouvelles idées qui peuvent devenir des recettes au fur et à mesure du temps afin de générer de nouvelles recettes. + +## Brique liste de courses Création d'une liste de courses associée au planning. Possibilité d'ajouter un lien web à un ingrédient, @@ -84,6 +131,38 @@ Archiver une liste. avoir des sous listes suivant les "type de shop favoris" (type de shop = supermarché, boucherie, poissonnerie, etc). poivoir associer les ingrédients et articles à un shop favori. +## upload de photo suite + +faire ne sorte que les photos de communauté, s'il y en a une, soient utilisée pour la miniature de la communauté dans la sidebar + +## Mobile v2 (post rework mobile v1) + +- **Swipe gestures** : swipe depuis le bord gauche pour ouvrir le drawer sidebar, swipe down pour fermer les bottom sheets, swipe-back pour naviguer en arriere. Necessite une gestion fine des conflits avec le scroll horizontal/vertical. +- **Long-press sur RecipeCard** : ouvrir l'ActionSheet (Edit, Delete, Share) via long-press au lieu du bouton "...". Pattern standard iOS/Android. Conflit potentiel avec le scroll de la liste a gerer. +- **Pull-to-refresh** : sur les listes (recettes, communautes, notifications). Necessite un composant custom ou une lib. +- **Haptic feedback** : `navigator.vibrate()` sur les actions de confirmation (suppression, validation de proposition). Support navigateur variable. + +## Bouton pour exporter ses recettes ou celles d'une de nos communautés (backup local) + +## Version application ? + +comment faire une application mobile ? +Est-ce nécessaire ? + +## Recipe Rework v3 - Sous-sections de recettes + +Certaines recettes sont composees de plusieurs sous-recettes (ex: enchiladas = viande + sauce + assemblage), chacune avec ses propres ingredients et etapes. Aujourd'hui tout est aplati en une seule liste d'ingredients et une seule liste d'etapes. Penser également à séparer les ingrédients en sous catégories + +L'idee serait d'introduire un concept de `RecipeSection` (ou groupe) : + +- Une recette peut avoir N sections ordonnees (ex: "Sauce", "Viande", "Assemblage") +- Chaque section a ses propres ingredients et ses propres etapes +- Les recettes simples (1 seule section) gardent le meme rendu qu'aujourd'hui +- L'import de recettes detecterait automatiquement les sous-sections +- Le formulaire de creation permettrait d'ajouter/supprimer des sections +- Impact DB : nouveau modele `RecipeSection` entre `Recipe` et `RecipeIngredient`/`RecipeStep` +- Impact front : formulaire multi-sections, affichage avec separateurs visuels, scaling par section + ## Brique on joue à quoi ? (utiliser api publique pour trouver des jeux et faire une liste) @@ -117,3 +196,23 @@ Barre de recherche unique (navbar) qui cherche simultanement dans les recettes, Comptage vues (RecipeView, RecipeAnalytics) Affichage statistiques sur recettes Dashboard analytics utilisateur + +--- + +Hello ! + +J'ai un petit projet perso à vous partager ! Il se trouve que je suis développeur et que de temps en temps ça m'arrive de réfléchir (oui oui je vous jure!) et même de créer des petits projets. + +Mon projet actuel est un projet de gestion de communauté privées. Que ce soit votre famille, un groupe d'amis, vos colloc etc, libre à vous de vous regrouper comme vous le souhaitez ! + +Pour commencer je m'intéresse à un sujet : la cuisine et plus particulièrement la gestion des recettes. +On a tous des recettes dans des carnets, des postits, des words, des mails, des liens, des photos etc etc et il n'est pas toujours facile de les retrouver. +L'idée ici est d'avoir votre propre espace pour stocker toutes vos recettes, puis les partager à vos proches dans vos communautés ! +Partager des recettes devient simple, les retrouver tout autant ! + +Mais ce projet n'a pas pour but de s'arreter là ! La prochaine étape sera de permettre de générer à partir de règles personnalisées au niveau de la communauté des propositions de menus pour la semaine à partir de vos recettes, et pourquoi pas la liste de courses associée ! + +Certain veulent partager les recettes du TOM, il suffit de créer une communauté TOM et que l'on y partage les recettes dedans et hop, tout le monde y a accès ! + +Des idées j'en ai plein, mais je suis curieux de vos avis, et de vos retours ! +(PS : le design n'est pas mon métier donc la version actuelle est un peu "brute") diff --git a/docs/API_SPECIFICATION.md b/docs/API_SPECIFICATION.md deleted file mode 100644 index 8e05cd27..00000000 --- a/docs/API_SPECIFICATION.md +++ /dev/null @@ -1,1532 +0,0 @@ -# API Specification - Forest Manager - -## Vue d'ensemble - -API REST pour Forest Manager. Base URL: `/api` - -Toutes les routes (sauf auth) necessitent une session valide. - ---- - -## Authentication - -### POST /api/auth/signup -Inscription d'un nouvel utilisateur. - -**Body:** -```json -{ - "email": "user@example.com", - "username": "johndoe", - "password": "securePassword123" -} -``` - -**Response 201:** -```json -{ - "id": "uuid", - "email": "user@example.com", - "username": "johndoe", - "createdAt": "2024-01-15T10:00:00Z" -} -``` - -**Errors:** -- `400` - Validation error -- `409` - Email or username already exists - ---- - -### POST /api/auth/login -Authentification d'un utilisateur. - -**Body:** -```json -{ - "email": "user@example.com", - "password": "securePassword123" -} -``` - -**Response 200:** -```json -{ - "id": "uuid", - "email": "user@example.com", - "username": "johndoe" -} -``` - -**Errors:** -- `400` - Invalid credentials -- `401` - Unauthorized - ---- - -### POST /api/auth/logout -Deconnexion. - -**Response 200:** -```json -{ - "message": "Logged out successfully" -} -``` - ---- - -### GET /api/auth/me -Recupere l'utilisateur courant. - -**Response 200:** -```json -{ - "id": "uuid", - "email": "user@example.com", - "username": "johndoe", - "createdAt": "2024-01-15T10:00:00Z" -} -``` - -**Errors:** -- `401` - Not authenticated - ---- - -## Users - -### GET /api/users/:id -Recupere un profil utilisateur public. - -**Response 200:** -```json -{ - "id": "uuid", - "username": "johndoe", - "createdAt": "2024-01-15T10:00:00Z", - "recipesCount": 15, - "communitiesCount": 3 -} -``` - ---- - -### GET /api/users/:id/recipes -Liste des recettes personnelles d'un utilisateur (seulement les siennes si authentifie comme cet utilisateur). - -**Query params:** -- `page` (default: 1) -- `limit` (default: 20, max: 100) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "title": "Tarte aux pommes", - "createdAt": "2024-01-15T10:00:00Z", - "tags": ["dessert", "fruit"] - } - ], - "pagination": { - "page": 1, - "limit": 20, - "total": 45, - "pages": 3 - } -} -``` - ---- - -### GET /api/users/me/invites -Liste des invitations recues par l'utilisateur connecte. - -**Query params:** -- `status` (default: "PENDING") - PENDING, ACCEPTED, REJECTED, ou "all" - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "community": { - "id": "uuid", - "name": "Les Gourmands", - "description": "Communaute de passionnes" - }, - "inviter": { - "id": "uuid", - "username": "admin_user" - }, - "status": "PENDING", - "createdAt": "2024-01-15T10:00:00Z" - } - ] -} -``` - ---- - -### GET /api/users/me/activity -Feed d'activite personnel (propositions sur mes recettes, etc.). - -**Query params:** -- `page` (default: 1) -- `limit` (default: 20, max: 100) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "type": "VARIANT_PROPOSED", - "user": { - "id": "uuid", - "username": "contributor" - }, - "recipe": { - "id": "uuid", - "title": "Tarte aux pommes" - }, - "community": { - "id": "uuid", - "name": "Les Gourmands" - }, - "createdAt": "2024-01-15T10:00:00Z" - } - ], - "pagination": {...} -} -``` - ---- - -## Invites - -### POST /api/invites/:id/accept -Accepte une invitation recue. - -**Response 200:** -```json -{ - "message": "Invitation accepted", - "community": { - "id": "uuid", - "name": "Les Gourmands" - } -} -``` - -**Errors:** -- `404` - Invite not found -- `400` - Invite not pending (already processed) -- `403` - Not the invitee - ---- - -### POST /api/invites/:id/reject -Refuse une invitation recue. - -**Response 200:** -```json -{ - "message": "Invitation rejected" -} -``` - -**Errors:** -- `404` - Invite not found -- `400` - Invite not pending -- `403` - Not the invitee - ---- - -## Communities - -### GET /api/communities -Liste les communautes de l'utilisateur connecte. - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "Les Gourmands", - "description": "Communaute de passionnes de cuisine", - "role": "ADMIN", - "membersCount": 12, - "recipesCount": 45, - "joinedAt": "2024-01-15T10:00:00Z" - } - ] -} -``` - ---- - -### POST /api/communities -Cree une nouvelle communaute. - -**Body:** -```json -{ - "name": "Les Gourmands", - "description": "Communaute de passionnes de cuisine" -} -``` - -**Response 201:** -```json -{ - "id": "uuid", - "name": "Les Gourmands", - "description": "Communaute de passionnes de cuisine", - "visibility": "INVITE_ONLY", - "createdAt": "2024-01-15T10:00:00Z" -} -``` - ---- - -### GET /api/communities/:id -Details d'une communaute. - -**Response 200:** -```json -{ - "id": "uuid", - "name": "Les Gourmands", - "description": "Communaute de passionnes de cuisine", - "visibility": "INVITE_ONLY", - "createdAt": "2024-01-15T10:00:00Z", - "membersCount": 12, - "recipesCount": 45, - "currentUserRole": "ADMIN" -} -``` - -**Errors:** -- `403` - Not a member -- `404` - Community not found - ---- - -### PATCH /api/communities/:id -Modifie une communaute (admin only). - -**Body:** -```json -{ - "name": "Nouveau nom", - "description": "Nouvelle description" -} -``` - -**Response 200:** Communaute mise a jour - -**Errors:** -- `403` - Not admin - ---- - -### GET /api/communities/:id/members -Liste les membres d'une communaute. - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "username": "johndoe", - "role": "ADMIN", - "joinedAt": "2024-01-15T10:00:00Z" - } - ] -} -``` - ---- - -### DELETE /api/communities/:id/members/:userId -Quitte la communaute (self) ou retire un membre (admin kick). - -**Response 200:** -```json -{ - "message": "Left community successfully" -} -``` -ou -```json -{ - "message": "Member removed successfully" -} -``` - -**Errors:** -- `403` - Last admin must promote another first -- `403` - Cannot kick an admin -- `410` - Community deleted (was last member) - ---- - -### PATCH /api/communities/:id/members/:userId -Modifie le role d'un membre (promotion uniquement, admin only). - -**Body:** -```json -{ - "role": "ADMIN" -} -``` - -**Response 200:** -```json -{ - "message": "User promoted to ADMIN" -} -``` - -**Errors:** -- `403` - Not admin or trying to demote - ---- - -### GET /api/communities/:id/invites -Liste les invitations d'une communaute (admin only). - -**Query params:** -- `status` (default: "PENDING") - PENDING, ACCEPTED, REJECTED, CANCELLED, ou "all" - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "invitee": { - "id": "uuid", - "username": "newuser", - "email": "new@example.com" - }, - "inviter": { - "id": "uuid", - "username": "admin_user" - }, - "status": "PENDING", - "createdAt": "2024-01-15T10:00:00Z" - } - ] -} -``` - -**Errors:** -- `403` - Not admin - ---- - -### POST /api/communities/:id/invites -Envoie une invitation (admin only). - -**Body:** -```json -{ - "userId": "uuid" -} -``` -ou -```json -{ - "email": "user@example.com" -} -``` -ou -```json -{ - "username": "johndoe" -} -``` - -**Response 201:** -```json -{ - "id": "uuid", - "invitee": { - "id": "uuid", - "username": "johndoe", - "email": "john@example.com" - }, - "status": "PENDING", - "createdAt": "2024-01-15T10:00:00Z" -} -``` - -**Errors:** -- `403` - Not admin -- `404` - User not found -- `409` - Already a member -- `409` - Invite already pending - ---- - -### DELETE /api/communities/:id/invites/:inviteId -Annule une invitation en attente (admin only). - -**Response 200:** -```json -{ - "message": "Invitation cancelled" -} -``` - -**Errors:** -- `403` - Not admin -- `404` - Invite not found -- `400` - Invite not pending - ---- - -### GET /api/communities/:id/activity -Feed d'activite de la communaute. - -**Query params:** -- `page` (default: 1) -- `limit` (default: 20, max: 100) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "type": "RECIPE_CREATED", - "user": { - "id": "uuid", - "username": "johndoe" - }, - "recipe": { - "id": "uuid", - "title": "Tarte aux pommes" - }, - "createdAt": "2024-01-15T10:00:00Z" - } - ], - "pagination": {...} -} -``` - ---- - -## Recipes - -### GET /api/recipes -Liste des recettes (catalogue personnel de l'utilisateur connecte). - -**Query params:** -- `page` (default: 1) -- `limit` (default: 20) -- `tags` - Filtre par tags (comma-separated) -- `search` - Recherche par titre - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "title": "Tarte aux pommes", - "imageUrl": "https://...", - "tags": ["dessert", "fruit"], - "createdAt": "2024-01-15T10:00:00Z" - } - ], - "pagination": {...} -} -``` - ---- - -### GET /api/communities/:communityId/recipes -Liste des recettes d'une communaute. - -**Query params:** -- `page`, `limit`, `tags`, `search` - -**Response 200:** Meme format que GET /api/recipes - ---- - -### POST /api/recipes -Cree une recette dans le catalogue personnel. - -**Body:** -```json -{ - "title": "Tarte aux pommes", - "content": "## Ingredients\n- 4 pommes...", - "imageUrl": "https://...", - "tags": ["dessert", "fruit"], - "ingredients": [ - { "name": "Pommes", "quantity": "4" }, - { "name": "Sucre", "quantity": "100g" } - ] -} -``` - -**Response 201:** Recette creee - ---- - -### POST /api/communities/:communityId/recipes -Cree une recette dans une communaute (+ copie dans catalogue personnel). - -**Body:** Meme format que POST /api/recipes - -**Response 201:** -```json -{ - "personal": { ... }, - "community": { ... } -} -``` - ---- - -### GET /api/recipes/:id -Details d'une recette. - -**Response 200:** -```json -{ - "id": "uuid", - "title": "Tarte aux pommes", - "content": "...", - "imageUrl": "https://...", - "creator": { - "id": "uuid", - "username": "johndoe" - }, - "community": { - "id": "uuid", - "name": "Les Gourmands" - }, - "tags": ["dessert", "fruit"], - "ingredients": [ - { "name": "Pommes", "quantity": "4" }, - { "name": "Sucre", "quantity": "100g" } - ], - "isVariant": false, - "originRecipe": null, - "sharedFrom": null, - "variantsCount": 3, - "createdAt": "2024-01-15T10:00:00Z", - "updatedAt": "2024-01-15T10:00:00Z" -} -``` - ---- - -### GET /api/recipes/:id/variants -Liste les variantes d'une recette. - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "title": "Tarte aux pommes - Version sans gluten", - "creator": { - "id": "uuid", - "username": "baker42" - }, - "createdAt": "2024-01-20T10:00:00Z" - } - ] -} -``` - ---- - -### PATCH /api/recipes/:id -Modifie une recette (createur only). - -**Body:** -```json -{ - "title": "Nouveau titre", - "content": "Nouveau contenu", - "tags": ["updated", "tags"], - "ingredients": [...] -} -``` - -**Response 200:** Recette mise a jour - ---- - -### DELETE /api/recipes/:id -Supprime une recette (soft delete, createur only). - -**Response 200:** -```json -{ - "message": "Recipe deleted" -} -``` - ---- - -### POST /api/recipes/:id/share -Fork une recette vers une autre communaute. - -**Body:** -```json -{ - "targetCommunityId": "uuid" -} -``` - -**Response 201:** -```json -{ - "message": "Recipe shared successfully", - "forkedRecipe": { ... } -} -``` - -**Errors:** -- `403` - Not member of both communities or insufficient permissions - ---- - -## Proposals - -### GET /api/recipes/:id/proposals -Liste des propositions sur une recette. - -**Query params:** -- `status` - Filtre: PENDING, ACCEPTED, REJECTED (default: all) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "proposer": { - "id": "uuid", - "username": "contributor" - }, - "proposedTitle": "Tarte aux pommes - amelioree", - "status": "PENDING", - "createdAt": "2024-01-15T10:00:00Z" - } - ] -} -``` - ---- - -### POST /api/recipes/:id/proposals -Cree une proposition de mise a jour. - -**Body:** -```json -{ - "proposedTitle": "Tarte aux pommes - amelioree", - "proposedContent": "## Nouvelle recette..." -} -``` - -**Response 201:** Proposition creee - -**Errors:** -- `403` - Not a community member or proposing on own recipe - ---- - -### GET /api/proposals/:id -Details d'une proposition. - -**Response 200:** -```json -{ - "id": "uuid", - "recipe": { - "id": "uuid", - "title": "Tarte aux pommes" - }, - "proposer": { - "id": "uuid", - "username": "contributor" - }, - "proposedTitle": "Tarte aux pommes - amelioree", - "proposedContent": "...", - "status": "PENDING", - "createdAt": "2024-01-15T10:00:00Z", - "decidedAt": null -} -``` - ---- - -### POST /api/proposals/:id/accept -Accepte une proposition (createur de la recette only). - -**Response 200:** -```json -{ - "message": "Proposal accepted", - "updatedRecipe": { ... } -} -``` - ---- - -### POST /api/proposals/:id/reject -Refuse une proposition et cree une variante. - -**Response 200:** -```json -{ - "message": "Proposal rejected, variant created", - "variant": { ... } -} -``` - ---- - -## Tags - -### GET /api/tags -Liste tous les tags (avec nombre d'utilisations). - -**Query params:** -- `search` - Recherche par nom - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "dessert", - "recipesCount": 42 - } - ] -} -``` - ---- - -### GET /api/communities/:id/tags -Tags utilises dans une communaute. - -**Response 200:** Meme format - ---- - -## Ingredients - -### GET /api/ingredients -Liste tous les ingredients (avec nombre d'utilisations). - -**Query params:** -- `search` - Recherche par nom - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "Pommes", - "recipesCount": 15 - } - ] -} -``` - ---- - -## Users Search (pour invitations) - -### GET /api/users/search -Recherche d'utilisateurs par username ou email (pour invitations). - -**Query params:** -- `q` - Terme de recherche (min 3 caracteres) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "username": "johndoe", - "email": "john@example.com" - } - ] -} -``` - -**Note:** Limite a 10 resultats. N'affiche pas les utilisateurs deja membres de la communaute cible si `communityId` est fourni. - ---- - -## Pagination - -Toutes les routes paginees utilisent le format: - -```json -{ - "data": [...], - "pagination": { - "page": 1, - "limit": 20, - "total": 100, - "pages": 5, - "hasNext": true, - "hasPrev": false - } -} -``` - ---- - -## Error Response Format - -```json -{ - "error": { - "code": "AUTH_001", - "message": "Not authenticated", - "details": {} - } -} -``` - ---- - -## Error Codes - -| Code | HTTP Status | Message | -|------|-------------|---------| -| `AUTH_001` | 401 | Non authentifie | -| `AUTH_002` | 401 | Session expiree | -| `COMMUNITY_001` | 403 | Non membre | -| `COMMUNITY_002` | 403 | Permission insuffisante | -| `COMMUNITY_003` | 400 | Dernier admin | -| `COMMUNITY_004` | 409 | Utilisateur deja membre | -| `COMMUNITY_005` | 409 | Invitation deja envoyee | -| `COMMUNITY_006` | 403 | Impossible de retirer un admin | -| `RECIPE_001` | 404 | Recette non trouvee | -| `RECIPE_002` | 403 | Non proprietaire | -| `PROPOSAL_001` | 403 | Proposition invalide | -| `PROPOSAL_002` | 400 | Deja decidee | -| `SHARE_001` | 403 | Non membre source | -| `SHARE_002` | 403 | Non membre cible | -| `SHARE_003` | 403 | Permission partage | -| `INVITE_001` | 404 | Invitation non trouvee | -| `INVITE_002` | 400 | Invitation deja traitee (status non PENDING) | -| `INVITE_003` | 404 | Utilisateur non trouve | - ---- - -## Rate Limiting (futur) - -- 100 requests/minute pour les routes authentifiees -- 20 requests/minute pour les routes non authentifiees -- Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` - ---- - -# Admin API - -API d'administration plateforme. Completement isolee de l'API utilisateur. - -**Base URL:** `/api/admin` - -**Authentification:** Session admin separee + 2FA TOTP obligatoire. - ---- - -## Admin Authentication - -### POST /api/admin/auth/login -Authentification SuperAdmin. - -**Premiere connexion (2FA non configure):** - -**Body:** -```json -{ - "username": "admin", - "password": "securePassword123" -} -``` - -**Response 200 (setup required):** -```json -{ - "requireTotpSetup": true, - "totpSecret": "JBSWY3DPEHPK3PXP", - "qrCode": "data:image/png;base64,..." -} -``` - -**Connexions suivantes:** - -**Body:** -```json -{ - "username": "admin", - "password": "securePassword123", - "totpToken": "123456" -} -``` - -**Response 200:** -```json -{ - "id": "uuid", - "username": "admin", - "email": "admin@example.com", - "lastLoginAt": "2024-01-15T10:00:00Z" -} -``` - -**Errors:** -- `401` - Invalid credentials -- `401` - Invalid TOTP token -- `403` - 2FA not configured - ---- - -### POST /api/admin/auth/totp/verify -Verifie le token TOTP lors du setup initial. - -**Body:** -```json -{ - "token": "123456" -} -``` - -**Response 200:** -```json -{ - "message": "2FA configured successfully", - "admin": { - "id": "uuid", - "username": "admin" - } -} -``` - -**Errors:** -- `400` - Invalid token -- `400` - 2FA already configured - ---- - -### POST /api/admin/auth/logout -Deconnexion admin. - -**Response 200:** -```json -{ - "message": "Logged out successfully" -} -``` - ---- - -### GET /api/admin/auth/me -Recupere l'admin courant. - -**Response 200:** -```json -{ - "id": "uuid", - "username": "admin", - "email": "admin@example.com", - "lastLoginAt": "2024-01-15T10:00:00Z" -} -``` - ---- - -## Admin Tags Management - -### GET /api/admin/tags -Liste tous les tags avec statistiques. - -**Query params:** -- `search` - Recherche par nom -- `page` (default: 1) -- `limit` (default: 50) -- `sortBy` - name, recipesCount (default: name) - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "dessert", - "recipesCount": 42, - "communitiesCount": 5 - } - ], - "pagination": {...} -} -``` - ---- - -### POST /api/admin/tags -Cree un nouveau tag. - -**Body:** -```json -{ - "name": "nouveau-tag" -} -``` - -**Response 201:** -```json -{ - "id": "uuid", - "name": "nouveau-tag" -} -``` - ---- - -### PATCH /api/admin/tags/:id -Renomme un tag. - -**Body:** -```json -{ - "name": "nouveau-nom" -} -``` - -**Response 200:** Tag mis a jour - ---- - -### DELETE /api/admin/tags/:id -Supprime un tag (hard delete). - -**Response 200:** -```json -{ - "message": "Tag deleted", - "recipesAffected": 15 -} -``` - ---- - -### POST /api/admin/tags/:id/merge -Fusionne un tag dans un autre. - -**Body:** -```json -{ - "targetTagId": "uuid" -} -``` - -**Response 200:** -```json -{ - "message": "Tag merged successfully", - "recipesUpdated": 15, - "targetTag": { - "id": "uuid", - "name": "target-tag", - "recipesCount": 57 - } -} -``` - ---- - -## Admin Ingredients Management - -### GET /api/admin/ingredients -Liste tous les ingredients avec statistiques. - -**Query params:** Memes que tags - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "Pommes", - "recipesCount": 28 - } - ], - "pagination": {...} -} -``` - ---- - -### POST /api/admin/ingredients -Cree un nouvel ingredient. - -**Body:** -```json -{ - "name": "Nouvel ingredient" -} -``` - ---- - -### PATCH /api/admin/ingredients/:id -Renomme un ingredient. - ---- - -### DELETE /api/admin/ingredients/:id -Supprime un ingredient (hard delete). - ---- - -### POST /api/admin/ingredients/:id/merge -Fusionne un ingredient dans un autre. - -**Body:** -```json -{ - "targetIngredientId": "uuid" -} -``` - ---- - -## Admin Communities Management - -### GET /api/admin/communities -Liste toutes les communautes avec statistiques. - -**Query params:** -- `search` - Recherche par nom -- `page`, `limit` -- `sortBy` - name, membersCount, recipesCount, createdAt - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "name": "Les Gourmands", - "description": "...", - "membersCount": 12, - "recipesCount": 45, - "adminsCount": 2, - "features": ["MVP"], - "createdAt": "2024-01-15T10:00:00Z" - } - ], - "pagination": {...}, - "stats": { - "totalCommunities": 150, - "totalMembers": 1200, - "totalRecipes": 5000 - } -} -``` - ---- - -### GET /api/admin/communities/:id -Details complets d'une communaute. - -**Response 200:** -```json -{ - "id": "uuid", - "name": "Les Gourmands", - "description": "...", - "visibility": "INVITE_ONLY", - "members": [ - { - "id": "uuid", - "username": "johndoe", - "role": "ADMIN", - "joinedAt": "2024-01-15T10:00:00Z" - } - ], - "features": [ - { - "code": "MVP", - "name": "Fonctionnalites de base", - "grantedAt": "2024-01-15T10:00:00Z" - } - ], - "stats": { - "recipesCount": 45, - "activeMembers30d": 8, - "proposalsCount": 12 - }, - "createdAt": "2024-01-15T10:00:00Z" -} -``` - ---- - -### PATCH /api/admin/communities/:id -Modifie une communaute. - -**Body:** -```json -{ - "name": "Nouveau nom", - "description": "Nouvelle description" -} -``` - ---- - -### DELETE /api/admin/communities/:id -Soft delete une communaute (et tous ses membres). - -**Response 200:** -```json -{ - "message": "Community deleted", - "membersAffected": 12, - "recipesAffected": 45 -} -``` - ---- - -## Admin Features Management - -### GET /api/admin/features -Liste toutes les features disponibles. - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "code": "MVP", - "name": "Fonctionnalites de base", - "description": "Catalogue de recettes, communautes, partage", - "isDefault": true, - "communitiesCount": 150 - }, - { - "id": "uuid", - "code": "MEAL_PLANNER", - "name": "Planificateur de repas", - "description": "Planification hebdomadaire des repas", - "isDefault": false, - "communitiesCount": 12 - } - ] -} -``` - ---- - -### POST /api/admin/features -Cree une nouvelle feature. - -**Body:** -```json -{ - "code": "NEW_FEATURE", - "name": "Nouvelle fonctionnalite", - "description": "Description de la feature", - "isDefault": false -} -``` - ---- - -### PATCH /api/admin/features/:id -Modifie une feature. - -**Body:** -```json -{ - "name": "Nouveau nom", - "description": "Nouvelle description" -} -``` - -**Note:** Le `code` et `isDefault` ne peuvent pas etre modifies apres creation. - ---- - -### GET /api/admin/communities/:id/features -Liste les features d'une communaute. - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "code": "MVP", - "name": "Fonctionnalites de base", - "grantedAt": "2024-01-15T10:00:00Z", - "grantedBy": null - } - ], - "available": [ - { - "id": "uuid", - "code": "MEAL_PLANNER", - "name": "Planificateur de repas" - } - ] -} -``` - ---- - -### POST /api/admin/communities/:id/features/:featureId -Attribue une feature a une communaute. - -**Response 201:** -```json -{ - "message": "Feature granted", - "feature": { - "code": "MEAL_PLANNER", - "name": "Planificateur de repas", - "grantedAt": "2024-01-15T10:00:00Z" - } -} -``` - -**Errors:** -- `409` - Feature already granted - ---- - -### DELETE /api/admin/communities/:id/features/:featureId -Revoque une feature d'une communaute. - -**Response 200:** -```json -{ - "message": "Feature revoked" -} -``` - -**Errors:** -- `400` - Cannot revoke default feature (MVP) - ---- - -## Admin Dashboard - -### GET /api/admin/dashboard/stats -Statistiques globales de la plateforme. - -**Response 200:** -```json -{ - "users": { - "total": 1500, - "active30d": 800, - "new7d": 50 - }, - "communities": { - "total": 150, - "active30d": 120 - }, - "recipes": { - "total": 5000, - "new7d": 200, - "proposals": { - "pending": 45, - "accepted7d": 30, - "rejected7d": 15 - } - }, - "features": { - "MVP": 150, - "MEAL_PLANNER": 12 - } -} -``` - ---- - -### GET /api/admin/activity -Journal d'activite admin. - -**Query params:** -- `page`, `limit` -- `type` - Filtre par type d'action -- `adminId` - Filtre par admin - -**Response 200:** -```json -{ - "data": [ - { - "id": "uuid", - "type": "TAG_MERGED", - "admin": { - "id": "uuid", - "username": "admin" - }, - "targetType": "Tag", - "targetId": "uuid", - "metadata": { - "sourceTag": "desserts", - "targetTag": "dessert", - "recipesUpdated": 15 - }, - "createdAt": "2024-01-15T10:00:00Z" - } - ], - "pagination": {...} -} -``` - ---- - -## Admin Error Codes - -| Code | HTTP Status | Message | -|------|-------------|---------| -| `ADMIN_001` | 401 | Non authentifie (admin) | -| `ADMIN_002` | 401 | 2FA requis | -| `ADMIN_003` | 401 | Token TOTP invalide | -| `ADMIN_004` | 400 | 2FA deja configure | -| `ADMIN_005` | 404 | Tag non trouve | -| `ADMIN_006` | 404 | Ingredient non trouve | -| `ADMIN_007` | 404 | Community non trouvee | -| `ADMIN_008` | 404 | Feature non trouvee | -| `ADMIN_009` | 409 | Feature deja attribuee | -| `ADMIN_010` | 400 | Impossible de revoquer feature par defaut | -| `ADMIN_011` | 409 | Tag/Ingredient existe deja | -| `ADMIN_012` | 400 | Fusion sur soi-meme interdite | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 6618edd5..00000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,730 +0,0 @@ -# Architecture Technique - Forest Manager - -## Vue d'ensemble - -Forest Manager suit une architecture client-serveur classique avec separation frontend/backend, containerisee avec Docker. - -``` -+---------------------------------------------------------------------+ -| Client (Browser) | -+---------------------------------------------------------------------+ - | - v -+---------------------------------------------------------------------+ -| Frontend (React + Vite) | -| localhost:3000 | -| +-------------+ +-------------+ +---------------------------+ | -| | Pages | | Components | | State Management | | -| +-------------+ +-------------+ +---------------------------+ | -| | | -| +------+------+ | -| | API Client | | -| +-------------+ | -+---------------------------------------------------------------------+ - | - HTTP/REST - | -+---------------------------------------------------------------------+ -| Backend (Express + Node.js) | -| localhost:3001 | -| +-------------+ +-------------+ +---------------------------+ | -| | Routes |->| Controllers |->| Services | | -| +-------------+ +-------------+ +---------------------------+ | -| | | | -| v v | -| +-------------+ +---------------------+ | -| | Middleware | | Prisma Client | | -| | (Auth, etc) | +---------------------+ | -| +-------------+ | | -+---------------------------------------------------------------------+ - | - v -+---------------------------------------------------------------------+ -| PostgreSQL Database | -| localhost:5432 | -+---------------------------------------------------------------------+ -``` - ---- - -## Stack Technique - -### Frontend - -| Technologie | Version | Role | -|-------------|---------|------| -| React | 18.x | UI Framework | -| TypeScript | 5.x | Type safety | -| Vite | 5.x | Build tool & dev server | -| TailwindCSS | 3.x | Styling | -| daisyUI | 4.x | Component library | -| Axios | 1.x | HTTP client | -| React Router | 6.x | Routing | - -### Backend - -| Technologie | Version | Role | -|-------------|---------|------| -| Node.js | 20.x | Runtime | -| Express | 4.x | Web framework | -| TypeScript | 5.x | Type safety | -| Prisma | 6.x | ORM | -| bcrypt | 5.x | Password hashing | -| express-session | 1.x | Session management | -| **@quixo3/prisma-session-store** | 3.x | **Session store (PostgreSQL via Prisma)** | -| **otplib** | 12.x | **2FA TOTP (SuperAdmin)** | -| **qrcode** | 1.x | **Generation QR code 2FA** | - -### Infrastructure - -| Technologie | Role | -|-------------|------| -| PostgreSQL 16 | Base de donnees | -| Docker | Containerisation | -| Docker Compose | Orchestration locale | -| GitHub Actions | CI/CD | -| Portainer | Deploiement | - ---- - -## Structure du projet - -``` -ForestManager/ -├── backend/ -│ ├── @types/ # Types TypeScript personnalises -│ │ └── session.d.ts # Extension session Express -│ ├── prisma/ -│ │ ├── schema.prisma # Schema de base de donnees -│ │ ├── migrations/ # Migrations Prisma -│ │ └── seed.js # Donnees de seed -│ ├── src/ -│ │ ├── controllers/ # Logique metier par domaine -│ │ │ ├── auth.ts -│ │ │ ├── users.ts -│ │ │ ├── communities.ts -│ │ │ ├── invites.ts # NOUVEAU: Invitations -│ │ │ ├── members.ts # NOUVEAU: Gestion membres -│ │ │ ├── recipes.ts -│ │ │ └── proposals.ts -│ │ ├── middleware/ # Middleware Express -│ │ │ ├── auth.ts # Verification session -│ │ │ ├── memberOf.ts # Verification membership -│ │ │ ├── adminOf.ts # Verification role ADMIN -│ │ │ └── errorHandler.ts # Gestion erreurs globale -│ │ ├── routes/ # Definition des routes -│ │ │ ├── auth.ts -│ │ │ ├── users.ts -│ │ │ ├── communities.ts -│ │ │ ├── invites.ts # NOUVEAU -│ │ │ ├── recipes.ts -│ │ │ └── proposals.ts -│ │ ├── services/ # Services metier -│ │ │ ├── recipeService.ts -│ │ │ ├── proposalService.ts -│ │ │ ├── inviteService.ts # NOUVEAU -│ │ │ ├── memberService.ts # NOUVEAU -│ │ │ └── activityService.ts -│ │ ├── admin/ # MODULE SUPERADMIN (isole) -│ │ │ ├── controllers/ -│ │ │ │ ├── auth.ts # Login/logout 2FA -│ │ │ │ ├── tags.ts # CRUD tags global -│ │ │ │ ├── ingredients.ts -│ │ │ │ ├── communities.ts -│ │ │ │ └── features.ts # Attribution briques -│ │ │ ├── routes/ -│ │ │ │ ├── index.ts # Router /api/admin -│ │ │ │ ├── auth.ts -│ │ │ │ ├── tags.ts -│ │ │ │ ├── ingredients.ts -│ │ │ │ ├── communities.ts -│ │ │ │ └── features.ts -│ │ │ ├── middleware/ -│ │ │ │ └── requireSuperAdmin.ts -│ │ │ └── services/ -│ │ │ └── totpService.ts -│ │ ├── scripts/ # CLI scripts -│ │ │ └── createAdmin.ts # npm run admin:create -│ │ ├── util/ -│ │ │ ├── db.ts # Instance Prisma -│ │ │ ├── validateEnv.ts # Validation variables env -│ │ │ └── assertIsDefined.ts -│ │ ├── app.ts # Configuration Express -│ │ └── server.ts # Point d'entree -│ ├── Dockerfile -│ ├── package.json -│ └── tsconfig.json -│ -├── frontend/ -│ ├── src/ -│ │ ├── components/ # Composants reutilisables -│ │ │ ├── common/ # Boutons, inputs, etc. -│ │ │ ├── layout/ # Header, Sidebar, Footer -│ │ │ ├── recipe/ # Composants recette -│ │ │ ├── community/ # Composants communaute -│ │ │ ├── invite/ # NOUVEAU: Composants invitation -│ │ │ └── activity/ # NOUVEAU: Composants feed -│ │ ├── pages/ # Pages/routes -│ │ │ ├── auth/ -│ │ │ ├── dashboard/ -│ │ │ ├── recipes/ -│ │ │ ├── communities/ -│ │ │ └── invites/ # NOUVEAU -│ │ ├── hooks/ # Custom hooks -│ │ │ ├── useAuth.ts -│ │ │ ├── useRecipes.ts -│ │ │ ├── useCommunities.ts -│ │ │ ├── useInvites.ts # NOUVEAU -│ │ │ └── useActivity.ts # NOUVEAU -│ │ ├── context/ # React Context -│ │ │ ├── AuthContext.tsx -│ │ │ └── NotificationContext.tsx # NOUVEAU -│ │ ├── network/ -│ │ │ └── api.ts # Client API Axios -│ │ ├── models/ # Types TypeScript -│ │ ├── utils/ # Utilitaires -│ │ ├── App.tsx -│ │ └── main.tsx -│ ├── Dockerfile -│ ├── package.json -│ └── vite.config.ts -│ -├── docs/ # Documentation technique -├── docker-compose.yml # Config developpement -├── docker-compose.prod.yml # Config production -└── .github/workflows/ # CI/CD -``` - ---- - -## Patterns et conventions - -### Backend - -#### Session Store avec Prisma - -Configuration de express-session avec @quixo3/prisma-session-store : - -```typescript -// app.ts -import session from 'express-session'; -import { PrismaSessionStore } from '@quixo3/prisma-session-store'; -import { prisma } from './util/db'; - -app.use( - session({ - cookie: { - maxAge: 60 * 60 * 1000, // 1 heure - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - }, - secret: process.env.SESSION_SECRET!, - resave: false, - saveUninitialized: false, - store: new PrismaSessionStore(prisma, { - checkPeriod: 2 * 60 * 1000, // Nettoyage sessions expirees toutes les 2min - dbRecordIdIsSessionId: true, - dbRecordIdFunction: undefined, - }), - }) -); -``` - -#### Controllers -Les controllers recoivent la requete, valident les donnees, appellent les services et retournent la reponse. - -```typescript -// controllers/recipes.ts -export const createRecipe = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.session.userId; - const data = validateRecipeInput(req.body); - const recipe = await recipeService.create(userId, data); - res.status(201).json(recipe); - } catch (error) { - next(error); - } -}; -``` - -#### Services -Les services contiennent la logique metier complexe. - -```typescript -// services/recipeService.ts -export const create = async (userId: string, data: RecipeInput) => { - return await prisma.$transaction(async (tx) => { - // 1. Creer recette personnelle - const personalRecipe = await tx.recipe.create({...}); - - // 2. Si communityId, creer copie communautaire - if (data.communityId) { - const communityRecipe = await tx.recipe.create({...}); - await activityService.log(tx, 'RECIPE_CREATED', {...}); - } - - return personalRecipe; - }); -}; -``` - -#### Middleware - -```typescript -// middleware/memberOf.ts -export const memberOf = (paramName: string = 'communityId') => { - return async (req: Request, res: Response, next: NextFunction) => { - const communityId = req.params[paramName]; - const userId = req.session.userId; - - const membership = await prisma.userCommunity.findFirst({ - where: { userId, communityId, deletedAt: null } - }); - - if (!membership) { - throw createHttpError(403, 'Not a member of this community'); - } - - req.membership = membership; - next(); - }; -}; - -// middleware/adminOf.ts -export const adminOf = (paramName: string = 'communityId') => { - return async (req: Request, res: Response, next: NextFunction) => { - const communityId = req.params[paramName]; - const userId = req.session.userId; - - const membership = await prisma.userCommunity.findFirst({ - where: { userId, communityId, role: 'ADMIN', deletedAt: null } - }); - - if (!membership) { - throw createHttpError(403, 'Admin access required'); - } - - req.membership = membership; - next(); - }; -}; -``` - -#### Soft Delete Pattern - -Toutes les queries doivent filtrer les entites soft-deleted : - -```typescript -// Middleware Prisma recommande pour filtrage automatique -// util/db.ts -prisma.$use(async (params, next) => { - const softDeleteModels = ['User', 'Community', 'UserCommunity', 'Recipe', 'RecipeUpdateProposal', 'CommunityInvite']; - - if (softDeleteModels.includes(params.model || '')) { - if (params.action === 'findMany' || params.action === 'findFirst') { - if (!params.args) params.args = {}; - if (!params.args.where) params.args.where = {}; - if (params.args.where.deletedAt === undefined) { - params.args.where.deletedAt = null; - } - } - } - return next(params); -}); -``` - -### Frontend - -#### API Client -Utilisation d'Axios avec intercepteurs pour la gestion des erreurs. - -```typescript -// network/api.ts -const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - withCredentials: true, -}); - -api.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - // Redirect to login - } - return Promise.reject(error); - } -); -``` - -#### Custom Hooks - -```typescript -// hooks/useRecipes.ts -export const useRecipes = (communityId?: string) => { - const [recipes, setRecipes] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetch = async () => { - const data = await api.getRecipes(communityId); - setRecipes(data); - setLoading(false); - }; - fetch(); - }, [communityId]); - - return { recipes, loading }; -}; - -// hooks/useInvites.ts (NOUVEAU) -export const useInvites = () => { - const [invites, setInvites] = useState([]); - const [loading, setLoading] = useState(true); - - const fetchInvites = async () => { - const data = await api.getMyInvites(); - setInvites(data.filter(i => i.status === 'PENDING')); - setLoading(false); - }; - - const acceptInvite = async (inviteId: string) => { - await api.acceptInvite(inviteId); - fetchInvites(); - }; - - const rejectInvite = async (inviteId: string) => { - await api.rejectInvite(inviteId); - fetchInvites(); - }; - - useEffect(() => { fetchInvites(); }, []); - - return { invites, loading, acceptInvite, rejectInvite, pendingCount: invites.length }; -}; -``` - ---- - -## Gestion des erreurs - -### Backend - -```typescript -// Types d'erreurs personnalises -class AppError extends Error { - constructor( - public statusCode: number, - public code: string, - message: string, - public details?: Record - ) { - super(message); - } -} - -// Middleware global -app.use((err: Error, req: Request, res: Response, next: NextFunction) => { - if (err instanceof AppError) { - return res.status(err.statusCode).json({ - error: { - code: err.code, - message: err.message, - details: err.details - } - }); - } - - console.error(err); - res.status(500).json({ - error: { - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred' - } - }); -}); -``` - -### Frontend - -```typescript -// Composant ErrorBoundary -class ErrorBoundary extends Component { - state = { hasError: false, error: null }; - - static getDerivedStateFromError(error: Error) { - return { hasError: true, error }; - } - - render() { - if (this.state.hasError) { - return ; - } - return this.props.children; - } -} -``` - ---- - -## Securite - -### Authentification Utilisateurs -- Sessions stockees en base de donnees (PostgreSQL via @quixo3/prisma-session-store) -- Cookie httpOnly, secure en production -- CSRF protection via SameSite=Strict -- Duree de session: 1 heure (rolling) - -### Authentification SuperAdmin (Securite Renforcee) - -**Isolation complete du systeme utilisateur:** - -| Aspect | Users | SuperAdmin | -|--------|-------|------------| -| Model | `User` | `AdminUser` | -| Session | `Session` | `AdminSession` | -| Cookie | `connect.sid` | `admin.sid` | -| Duree | 1 heure | 30 minutes | -| Secret | `SESSION_SECRET` | `ADMIN_SESSION_SECRET` | -| 2FA | Non | **TOTP obligatoire** | - -**Flux d'authentification 2FA:** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PREMIERE CONNEXION │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. POST /api/admin/auth/login { username, password } │ -│ 2. Verification credentials │ -│ 3. totpEnabled = false → Retourne QR code + secret │ -│ 4. Admin scanne avec Google Authenticator │ -│ 5. POST /api/admin/auth/totp/verify { token: "123456" } │ -│ 6. totpEnabled = true, cree session │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ CONNEXIONS SUIVANTES │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. POST /api/admin/auth/login { username, password, totpToken }│ -│ 2. Verification credentials + TOTP │ -│ 3. Cree AdminSession │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Configuration sessions separees:** - -```typescript -// app.ts - Sessions isolees via Routers separes - -import { Router } from 'express'; - -// Router API utilisateurs (exclu /api/admin) -const userRouter = Router(); -userRouter.use(session({ - name: 'connect.sid', - store: new PrismaSessionStore(prisma, { - checkPeriod: 2 * 60 * 1000, - dbRecordIdIsSessionId: true, - }), - secret: env.SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 60 * 60 * 1000, // 1h - httpOnly: true, - secure: env.NODE_ENV === 'production', - sameSite: 'strict', - }, -})); - -// Router API admin (completement isole) -const adminRouter = Router(); -adminRouter.use(session({ - name: 'admin.sid', - store: new PrismaSessionStore(prisma, { - checkPeriod: 2 * 60 * 1000, - dbRecordIdIsSessionId: true, - sessionModelName: 'AdminSession', - }), - secret: env.ADMIN_SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 30 * 60 * 1000, // 30min - httpOnly: true, - secure: env.NODE_ENV === 'production', - sameSite: 'strict', - }, -})); - -// Montage des routers (ordre important: admin AVANT api) -app.use('/api/admin', adminRouter); -app.use('/api', userRouter); -``` - -**Note:** L'ordre de montage est critique. `/api/admin` doit etre monte AVANT `/api` pour eviter que le middleware user session ne s'applique aux routes admin. - -**Middleware SuperAdmin:** - -```typescript -// admin/middleware/requireSuperAdmin.ts -export const requireSuperAdmin: RequestHandler = (req, res, next) => { - if (!req.session.adminId) { - return next(createHttpError(401, 'Admin authentication required')); - } - if (!req.session.totpVerified) { - return next(createHttpError(401, '2FA verification required')); - } - next(); -}; -``` - -**CLI creation admin (server-side only):** - -```bash -# Execution cote serveur uniquement -npm run admin:create -# Prompt: username, email, password -# Genere totpSecret, admin cree avec totpEnabled=false -# A la premiere connexion: scan QR → activation 2FA -``` - -### Autorisation -- Verification de session sur toutes les routes protegees -- Verification de membership pour les ressources communautaires -- Verification de role pour les actions admin communaute -- Middleware chainable: requireAuth -> memberOf -> adminOf -- SuperAdmin: requireSuperAdmin (isole) - -### Validation -- Validation des inputs cote serveur (format, longueur) -- Sanitization des donnees avant stockage -- Echappement HTML dans les reponses - -### Mots de passe -- Hashage bcrypt avec salt (cost factor: 10) -- Pas de stockage en clair -- Pas de transmission en clair (HTTPS en prod) - ---- - -## Performance - -### Base de donnees -- Index sur les colonnes frequemment requetees -- Pagination sur toutes les listes -- Soft delete avec index partiel (WHERE deletedAt IS NULL) - -### Caching (futur) -- Cache des tags populaires -- Cache des metadata communautes - -### Frontend -- Code splitting par route -- Lazy loading des images -- Optimistic updates pour UX - ---- - -## Deploiement - -### Developpement -```bash -npm run docker:up:build -``` - -### Production -- Build des images via GitHub Actions -- Push vers registry prive -- Deploiement via Portainer API -- Migration automatique au demarrage - -### Variables d'environnement - -| Variable | Description | -|----------|-------------| -| `DATABASE_URL` | URL PostgreSQL | -| `SESSION_SECRET` | Secret pour les sessions utilisateurs (min 32 chars) | -| `ADMIN_SESSION_SECRET` | Secret pour les sessions admin (min 32 chars, different de SESSION_SECRET) | -| `CORS_ORIGIN` | Origine autorisee CORS (ex: http://localhost:3000) | -| `NODE_ENV` | development / production | - -### Configuration CORS - -```typescript -// app.ts - CORS avec credentials pour cookies -import cors from 'cors'; - -app.use(cors({ - origin: env.CORS_ORIGIN, - credentials: true, // Requis pour cookies connect.sid et admin.sid - methods: ['GET', 'POST', 'PATCH', 'DELETE'], - allowedHeaders: ['Content-Type'], -})); -``` - -**Note:** `credentials: true` est obligatoire pour que les cookies de session soient envoyes cross-origin. Le frontend doit utiliser `withCredentials: true` dans Axios. - ---- - -## Systeme de Briques (Features) - -### Concept - -L'application est structuree en "briques" (modules/features) attribuables aux communautes : - -| Feature | Code | Default | Description | -|---------|------|---------|-------------| -| MVP | `MVP` | Oui | Catalogue recettes, communautes, partage | -| Planificateur | `MEAL_PLANNER` | Non | Planification repas hebdomadaire | -| (Futur) | `...` | Non | Autres fonctionnalites | - -### Attribution - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ CREATION COMMUNAUTE │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. User cree une communaute │ -│ 2. Query: SELECT * FROM Feature WHERE isDefault = true │ -│ 3. Insert: CommunityFeature (communityId, featureId, null) │ -│ 4. Communaute a acces au MVP automatiquement │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ ATTRIBUTION MANUELLE │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. SuperAdmin POST /api/admin/communities/:id/features/:fId │ -│ 2. Insert: CommunityFeature (communityId, featureId, adminId) │ -│ 3. Log: AdminActivityLog (FEATURE_GRANTED) │ -│ 4. Communaute a acces a la nouvelle brique │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Verification d'acces (futur) - -```typescript -// middleware/hasFeature.ts -export const hasFeature = (featureCode: string) => { - return async (req: Request, res: Response, next: NextFunction) => { - const communityId = req.params.communityId; - - const access = await prisma.communityFeature.findFirst({ - where: { - communityId, - feature: { code: featureCode }, - revokedAt: null, - } - }); - - if (!access) { - throw createHttpError(403, `Feature ${featureCode} not available`); - } - - next(); - }; -}; - -// Utilisation -router.get('/meal-plan', requireAuth, memberOf(), hasFeature('MEAL_PLANNER'), getMealPlan); -``` diff --git a/docs/BUSINESS_RULES.md b/docs/BUSINESS_RULES.md deleted file mode 100644 index 14475a3e..00000000 --- a/docs/BUSINESS_RULES.md +++ /dev/null @@ -1,727 +0,0 @@ -# Business Rules - Forest Manager - -## Vue d'ensemble - -Ce document decrit toutes les regles metier et la logique applicative de Forest Manager. Ces regles doivent etre implementees dans la couche controleur/service du backend. - ---- - -## 1. Gestion des utilisateurs - -### 1.1 Inscription -- L'email doit etre unique -- Le username doit etre unique -- Le mot de passe est hashe avec bcrypt (salt rounds: 10) -- Un utilisateur nouvellement inscrit n'appartient a aucune communaute - -### 1.2 Authentification -- Authentification par session (express-session) -- Sessions persistees en base de donnees via `@quixo3/prisma-session-store` -- Duree de session: 1 heure - -### 1.3 Catalogue personnel -- Chaque utilisateur possede un catalogue personnel de recettes -- Les recettes personnelles ont `communityId = null` -- Les recettes personnelles ne sont visibles que par leur createur - ---- - -## 2. Gestion des communautes - -### 2.1 Creation de communaute -- Tout utilisateur authentifie peut creer une communaute -- Le createur devient automatiquement **ADMIN** de la communaute -- Visibilite: toujours **INVITE_ONLY** (MVP) - -### 2.2 Roles - -| Role | Permissions | -|------|-------------| -| **MEMBER** | Voir les recettes, creer des recettes, proposer des mises a jour, quitter | -| **ADMIN** | Tout ce que MEMBER + inviter, retirer un membre, promouvoir, modifier la description, annuler des invitations | - -### 2.3 Systeme d'invitation (NOUVEAU) - -**Principe:** Les invitations necessitent une acceptation explicite de l'invite. - -#### Qui peut inviter ? -- **Seuls les ADMIN** peuvent envoyer des invitations -- L'invite doit etre un utilisateur **deja inscrit** sur la plateforme -- Recherche de l'invite par username ou email - -#### Workflow d'invitation -``` -1. Admin envoie une invitation (POST /api/communities/:id/invites) - → CommunityInvite cree avec status: PENDING - → ActivityLog: INVITE_SENT - -2. L'invite voit l'invitation dans son espace - → GET /api/users/me/invites (liste des invitations recues) - -3. L'invite repond: - A) Accepter (POST /api/invites/:id/accept) - → CommunityInvite.status = ACCEPTED - → Creation UserCommunity (role: MEMBER) - → ActivityLog: INVITE_ACCEPTED, USER_JOINED - - B) Refuser (POST /api/invites/:id/reject) - → CommunityInvite.status = REJECTED - → ActivityLog: INVITE_REJECTED - -4. L'admin peut annuler une invitation en attente - → DELETE /api/invites/:id - → CommunityInvite.status = CANCELLED - → ActivityLog: INVITE_CANCELLED -``` - -#### Contraintes -- Une seule invitation PENDING par couple (communityId, inviteeId) -- Si l'utilisateur est deja membre → erreur -- Si une invitation PENDING existe deja → erreur - -### 2.4 Promotion en administrateur -- **Seuls les ADMIN** peuvent promouvoir un MEMBER en ADMIN -- Un ADMIN **ne peut pas retrograder** un autre ADMIN -- Le role ADMIN est permanent (sauf si l'utilisateur quitte) - -### 2.5 Retirer un membre (Kick) (NOUVEAU) -``` -CONDITIONS: -- L'utilisateur qui kick DOIT etre ADMIN -- L'utilisateur cible DOIT etre MEMBER (pas ADMIN) - -ACTIONS: -1. Marquer UserCommunity.deletedAt = now() -2. Creer ActivityLog (type: USER_KICKED) -3. L'utilisateur perd l'acces immediatement - -NOTE: Un ADMIN ne peut PAS retirer un autre ADMIN. -``` - -### 2.6 Quitter une communaute - -``` -SI l'utilisateur veut quitter: - SI c'est un ADMIN: - SI c'est le dernier ADMIN: - SI il y a d'autres MEMBER: - → BLOQUER: doit d'abord promouvoir un autre utilisateur - SINON (dernier utilisateur): - → Soft delete de la communaute (cascade applicative) - SINON: - → Marquer UserCommunity.deletedAt - → L'utilisateur perd l'acces - SINON (MEMBER): - → Marquer UserCommunity.deletedAt - → L'utilisateur perd l'acces - → Creer ActivityLog (type: USER_LEFT) -``` - -### 2.7 Suppression de communaute -- Declenchee automatiquement quand le dernier utilisateur quitte -- **Cascade applicative (soft delete):** - - Toutes les recettes de la communaute → `deletedAt = now()` - - Toutes les variantes de ces recettes → `deletedAt = now()` - - Toutes les propositions sur ces recettes → `deletedAt = now()` - - Tous les UserCommunity → `deletedAt = now()` - - Toutes les invitations PENDING → `status = CANCELLED` - - La communaute → `deletedAt = now()` -- Les ActivityLog sont **conserves** pour historique -- **Note:** Les recettes perso des anciens membres NE SONT PAS affectees - ---- - -## 3. Gestion des recettes - -### 3.1 Creation de recette - -**Dans le catalogue personnel:** -- La recette est creee avec `communityId = null` -- Seul le createur peut la voir - -**Dans une communaute:** -``` -CONDITION: l'utilisateur DOIT etre membre de la communaute - -1. Creer la recette dans le catalogue personnel (communityId = null) -2. Creer une COPIE dans la communaute (communityId = community.id) -3. La copie communautaire a originRecipeId = recette_personnelle.id -4. Creer un ActivityLog (type: RECIPE_CREATED) -``` - -**Lien recette personnelle ↔ communautaire:** -- La recette communautaire pointe vers la recette personnelle via `originRecipeId` -- Pour retrouver la recette perso: `originRecipe.communityId == null` - -### 3.2 Modification de recette -- **Catalogue personnel:** Le createur peut modifier librement -- **Communaute:** Seul le createur de la recette communautaire peut modifier directement -- Les autres membres doivent passer par le systeme de propositions - -### 3.2.1 Synchronisation des copies (NOUVEAU) - -**Principe:** Une recette personnelle et ses copies communautaires restent synchronisees. - -``` -MODIFICATION PAR LE CREATEUR: -- Synchronisation BIDIRECTIONNELLE -- Modifier la recette perso → met a jour toutes les copies communautaires liees -- Modifier une copie communautaire → met a jour la recette perso + les N autres copies - -MULTI-COMMUNAUTES: -- Si la meme recette est partagee dans N communautes -- Une modification depuis n'importe quelle copie propage a toutes les autres - -SCOPE DE LA SYNCHRONISATION: -- Titre, contenu, ingredients: SYNCHRONISES -- Tags: LOCAUX a chaque communaute (non synchronises) - → Chaque communaute peut avoir ses propres tags sur une recette - → Les MODERATORS peuvent modifier les tags pour leur communaute - → N'impacte pas les tags de la version privee du createur - → Toutes les variantes d'une meme recette partagent les memes tags -``` - -**Note:** Les forks (section 5) sont independants et ne sont PAS synchronises. -La synchronisation passe TOUJOURS par la version privee de l'utilisateur. - -### 3.3 Suppression de recette (soft delete) -- Le createur peut supprimer sa recette -- La suppression est un soft delete (`deletedAt = now()`) -- Supprimer une recette personnelle **ne supprime pas** les copies communautaires -- Supprimer une recette communautaire **ne supprime pas** la recette personnelle -- Les tables pivot (RecipeTag, RecipeIngredient) sont hard delete via Cascade - -### 3.3.1 Recettes orphelines (NOUVEAU) - -**Declencheur:** La recette personnelle d'origine est supprimee OU le createur quitte/est expulse de la communaute OU le createur supprime son compte. - -``` -COMPORTEMENT D'UNE RECETTE ORPHELINE: -1. La recette reste visible dans la communaute -2. Le "proprietaire" affiche = la communaute elle-meme (pas d'utilisateur) -3. Plus de synchronisation possible (pas de source de verite) -4. L'ORPHELINAT EST PERMANENT - meme si le createur re-rejoint, la cesure est definitive -5. Une recette orpheline NE PEUT PAS etre modifiee directement -6. Les propositions sur une recette orpheline: - - Ne peuvent plus etre acceptees (pas de createur pour decider) - - Sont automatiquement refusees → variante creee pour le proposeur - - Les propositions PENDING existantes sont auto-refusees au moment de l'orphelinat - -DEPART/EXPULSION D'UN MEMBRE: -- Toutes ses recettes communautaires deviennent orphelines -- Toutes les propositions PENDING sur ces recettes sont immediatement auto-refusees -- Pour chaque proposition PENDING: une variante est creee pour le proposeur - -SUPPRESSION COMPTE UTILISATEUR: -- Toutes ses recettes (perso + communautaires) deviennent orphelines -- Meme comportement que ci-dessus -``` - -### 3.4 Tags -- Une recette peut avoir **plusieurs tags** -- Les tags sont globaux (partages entre communautes) -- Creation de tag a la volee si inexistant - -### 3.5 Ingredients -- Une recette peut avoir **plusieurs ingredients** avec quantites -- Les ingredients sont globaux (comme les tags) -- Creation a la volee si inexistant - ---- - -## 4. Systeme de propositions - -### 4.1 Creer une proposition -``` -CONDITIONS: -- L'utilisateur DOIT etre membre de la communaute -- La recette DOIT appartenir a cette communaute -- L'utilisateur NE PEUT PAS proposer sur sa propre recette - -ACTIONS: -1. Creer RecipeUpdateProposal (status: PENDING) -2. Creer ActivityLog (type: VARIANT_PROPOSED) -``` - -### 4.2 Accepter une proposition -``` -CONDITIONS: -- L'utilisateur DOIT etre le createur de la recette cible -- La proposition DOIT etre en status PENDING -- La recette NE DOIT PAS avoir change depuis la creation de la proposition (NOUVEAU) - → Comparer recipe.updatedAt avec proposal.createdAt - → Si recipe.updatedAt > proposal.createdAt → BLOQUER (erreur PROPOSAL_003) - → Le createur doit d'abord refuser ou le proposeur doit resoumettre - -ACTIONS: -1. Mettre a jour la recette communautaire avec le contenu propose -2. Retrouver et mettre a jour la recette personnelle liee: - → Chercher recipe.originRecipeId - → Verifier que originRecipe.communityId == null - → Mettre a jour originRecipe -3. CASCADE: Mettre a jour toutes les autres copies communautaires (NOUVEAU) - → Chercher toutes les recettes ou originRecipeId = originRecipe.id - → Mettre a jour chacune avec le contenu accepte -4. Mettre a jour RecipeUpdateProposal (status: ACCEPTED, decidedAt: now) -5. Creer ActivityLog (type: PROPOSAL_ACCEPTED) -``` - -### 4.3 Refuser une proposition -``` -CONDITIONS: -- L'utilisateur DOIT etre le createur de la recette cible -- La proposition DOIT etre en status PENDING - -ACTIONS: -1. Creer une NOUVELLE recette (variante): - - Contenu = contenu propose - - creatorId = proposer.id - - communityId = meme communaute - - originRecipeId = recette_cible.id - - isVariant = true -2. Mettre a jour RecipeUpdateProposal (status: REJECTED, decidedAt: now) -3. Creer ActivityLog (type: VARIANT_CREATED) -``` - -### 4.4 Visualisation des variantes -- Sur la page d'une recette, afficher toutes les recettes ou `originRecipeId = recette.id` ET `isVariant = true` -- **Visibilite:** Seulement les variantes de CETTE communaute (pas cross-communaute) -- Presentation en liste deroulante (dropdown) PLATE (pas d'arbre) -- **Version par defaut:** La plus recente (MAX de createdAt, updatedAt) s'affiche par defaut -- **Tri:** par date la plus recente (createdAt ET updatedAt confondus), plus recent en premier - -**Coexistence des variantes (NOUVEAU):** -``` -- Les variantes COEXISTENT dans la communaute (pas de remplacement) -- Chaque variante a son propre createur -- L'utilisateur navigue entre elles via le dropdown -- Une recette peut avoir plusieurs variantes dans la meme communaute: - → Propositions refusees (devient variante du proposeur) - → Modifications du createur (nouvelle version) - -VARIANTES DE VARIANTES: -- Une variante peut avoir ses propres variantes (chaine illimitee) -- Le dropdown reste PLAT (affiche toutes les variantes a plat) -- originRecipeId pointe vers le parent IMMEDIAT (pas la racine) -``` - ---- - -## 5. Partage inter-communautes (Fork) - -### 5.1 Conditions de partage -``` -L'utilisateur DOIT: -- Etre membre de la communaute SOURCE -- Etre membre de la communaute CIBLE -- Etre ADMIN dans au moins une des deux communautes OU etre le createur de la recette -``` - -### 5.2 Processus de fork -``` -1. Creer une NOUVELLE recette dans la communaute cible: - - Copier title, content, tags, ingredients - - creatorId = utilisateur qui partage - - communityId = communaute cible - - originRecipeId = recette COMMUNAUTAIRE source (pas la perso) - - sharedFromCommunityId = communaute source - - isVariant = false - -2. Incrementer RecipeAnalytics.shares de la recette source -3. Incrementer RecipeAnalytics.forks de la recette source -4. Creer ActivityLog (type: RECIPE_SHARED) dans les deux communautes -``` - -### 5.3 Independance des forks -- La recette forkee est **totalement independante** -- Elle peut recevoir ses propres propositions et variantes -- Les modifications sur l'originale n'affectent pas les forks -- Les modifications sur le fork n'affectent pas l'originale -- **Pas de synchronisation** - la synchro passe par la version privee, le fork n'en a pas - -### 5.4 Chaines de forks (NOUVEAU) -``` -FORK DE FORK: -- On peut forker un fork (A → B → C) -- originRecipeId pointe vers le PARENT IMMEDIAT (B, pas A) -- sharedFromCommunityId = communaute du parent - -ANALYTICS EN CHAINE: -- Quand C fork B: B.shares++ ET B.forks++ -- MAIS AUSSI: A.shares++ ET A.forks++ (remontee dans la chaine) -- Permet de tracer l'impact total d'une recette originale -``` - ---- - -## 6. Activity Feed - -### 6.1 Evenements trackes - -| Type | Declencheur | Donnees | -|------|-------------|---------| -| `RECIPE_CREATED` | Creation de recette dans communaute | recipeId, communityId | -| `RECIPE_UPDATED` | Modification de recette | recipeId | -| `RECIPE_DELETED` | Suppression de recette | recipeId | -| `RECIPE_SHARED` | Fork d'une recette | recipeId, communityId, metadata.fromCommunityId | -| `VARIANT_PROPOSED` | Nouvelle proposition | recipeId, proposalId | -| `VARIANT_CREATED` | Proposition refusee | recipeId (variante), originRecipeId | -| `PROPOSAL_ACCEPTED` | Proposition acceptee | recipeId, proposalId | -| `PROPOSAL_REJECTED` | Proposition refusee | recipeId, proposalId | -| `USER_JOINED` | Invitation acceptee | communityId | -| `USER_LEFT` | Membre quitte | communityId | -| `USER_KICKED` | Membre retire par admin | communityId, metadata.kickedUserId | -| `USER_PROMOTED` | Promotion en admin | communityId, metadata.promotedUserId | -| `INVITE_SENT` | Invitation envoyee | communityId, metadata.inviteeId | -| `INVITE_ACCEPTED` | Invitation acceptee | communityId | -| `INVITE_REJECTED` | Invitation refusee | communityId | -| `INVITE_CANCELLED` | Invitation annulee | communityId, metadata.inviteeId | - -### 6.2 Requetes feed -- **Feed par communaute:** `WHERE communityId = X ORDER BY createdAt DESC` -- **Feed personnel:** `WHERE userId = X OR recipeId IN (user's recipes) ORDER BY createdAt DESC` - -### 6.3 Cascade et Activity Log (NOUVEAU) - -``` -ACCEPTATION PROPOSITION AVEC CASCADE: -- Quand une proposition est acceptee et cascade vers N communautes: -- Creation de 1 ActivityLog par communaute impactee -- Chaque communaute voit RECIPE_UPDATED dans son feed -- L'entry dans la communaute d'origine a type: PROPOSAL_ACCEPTED -- Les entries dans les autres communautes ont type: RECIPE_UPDATED - -ORPHELINAT AUTO-REFUS: -- Quand une recette devient orpheline avec propositions PENDING: -- Creation de 1 ActivityLog VARIANT_CREATED par proposition auto-refusee -``` - ---- - -## 7. Analytics (prepare mais desactive MVP) - -### 7.1 Compteurs -- `views`: Incremente a chaque consultation de la page recette -- `shares`: Incremente lors d'un fork -- `forks`: Incremente lors d'un fork - -### 7.2 Tracking detaille -- RecipeView stocke chaque vue individuelle -- Permet l'analyse par utilisateur et par periode -- Desactive par defaut pour le MVP - ---- - -## 8. Regles de validation - -### 8.1 Recette -```typescript -{ - title: string, min: 3, max: 200 - content: string, min: 10, max: 50000 - imageUrl?: string, format: URL - tags?: string[], max: 10 tags - ingredients?: { name: string, quantity?: string }[], max: 100 ingredients -} -``` - -### 8.2 Communaute -```typescript -{ - name: string, min: 3, max: 100 - description?: string, max: 1000 -} -``` - -### 8.3 Proposition -```typescript -{ - proposedTitle: string, min: 3, max: 200 // Requis - proposedContent: string, min: 10, max: 50000 // Requis -} -// Note: Les deux champs sont obligatoires. -// Une proposition est une version complete suggeree, pas une modification partielle. -// Si acceptee, titre ET contenu sont remplaces. -``` - -### 8.4 Invitation -```typescript -{ - inviteeId?: string, format: UUID - email?: string, format: email - username?: string, min: 3 -} -// Au moins un des trois champs requis -``` - ---- - -## 9. Codes d'erreur - -| Code | Message | Contexte | -|------|---------|----------| -| `AUTH_001` | Non authentifie | Acces route protegee | -| `AUTH_002` | Session expiree | Session invalide | -| `COMMUNITY_001` | Non membre | Acces communaute sans membership | -| `COMMUNITY_002` | Permission insuffisante | Action admin sans etre admin | -| `COMMUNITY_003` | Dernier admin | Tentative de quitter en tant que dernier admin | -| `COMMUNITY_004` | Utilisateur deja membre | Invitation d'un membre existant | -| `COMMUNITY_005` | Invitation deja envoyee | Invitation PENDING existe deja | -| `COMMUNITY_006` | Impossible de retirer un admin | Tentative de kick un admin | -| `RECIPE_001` | Recette non trouvee | ID invalide ou soft deleted | -| `RECIPE_002` | Non proprietaire | Modification sans etre createur | -| `PROPOSAL_001` | Proposition invalide | Auto-proposition interdite | -| `PROPOSAL_002` | Deja decidee | Proposition non-pending | -| `PROPOSAL_003` | Recette modifiee depuis | Conflit: recipe.updatedAt > proposal.createdAt | -| `SHARE_001` | Non membre source | Pas dans la communaute source | -| `SHARE_002` | Non membre cible | Pas dans la communaute cible | -| `SHARE_003` | Permission partage | Ni admin ni createur | -| `INVITE_001` | Invitation non trouvee | ID invalide | -| `INVITE_002` | Invitation deja traitee | Status non PENDING | -| `INVITE_003` | Utilisateur non trouve | Email/username inexistant | - ---- - -## 10. Soft Delete - Implementation - -### 10.1 Principe -- Toutes les entites principales ont un champ `deletedAt` -- Une entite avec `deletedAt != null` est consideree supprimee -- Toutes les requetes doivent filtrer `WHERE deletedAt IS NULL` - -### 10.2 Entites concernees -- User -- Community -- UserCommunity -- Recipe -- RecipeUpdateProposal -- CommunityInvite - -### 10.3 Tables pivot (hard delete) -- RecipeTag → Cascade quand Recipe supprime -- RecipeIngredient → Cascade quand Recipe supprime -- RecipeAnalytics → Cascade quand Recipe supprime -- RecipeView → Cascade quand Recipe supprime - -### 10.4 Middleware Prisma recommande -```typescript -// Filtrer automatiquement les entites soft-deleted -prisma.$use(async (params, next) => { - if (params.action === 'findMany' || params.action === 'findFirst') { - if (!params.args.where?.deletedAt) { - params.args.where = { ...params.args.where, deletedAt: null }; - } - } - return next(params); -}); -``` - ---- - -## 11. SuperAdmin - Gestion Plateforme - -### 11.1 Concept - -Le SuperAdmin est un compte d'administration plateforme **completement isole** du systeme utilisateur classique. - -**Isolation:** -| Aspect | Users | SuperAdmin | -|--------|-------|------------| -| Model | `User` | `AdminUser` | -| Session | `Session` | `AdminSession` | -| Authentification | Session simple | Session + 2FA TOTP | -| Creation | Inscription publique | CLI serveur uniquement | - -### 11.2 Capacites SuperAdmin - -| Domaine | Actions | -|---------|---------| -| **Tags** | Lister, creer, renommer, supprimer, fusionner | -| **Ingredients** | Lister, creer, renommer, supprimer, fusionner | -| **Communautes** | Lister toutes, voir details, renommer, supprimer | -| **Features** | Lister, creer, modifier, attribuer, revoquer | -| **Dashboard** | Stats globales, logs d'activite admin | - -### 11.3 Authentification 2FA TOTP - -``` -PREMIERE CONNEXION: -1. Admin saisit username + password -2. Serveur verifie credentials -3. Si totpEnabled = false: - → Retourne QR code + totpSecret - → Admin scanne avec Google Authenticator -4. POST /api/admin/auth/totp/verify { token: "123456" } -5. Si valide: - → totpEnabled = true - → Cree AdminSession - → session.adminId = admin.id - → session.totpVerified = true - -CONNEXIONS SUIVANTES: -1. Admin saisit username + password + totpToken -2. Serveur verifie credentials + TOTP -3. Si valide: - → Cree AdminSession - → session.adminId = admin.id - → session.totpVerified = true -``` - -### 11.4 Creation de compte SuperAdmin - -**Methode:** CLI serveur uniquement (`npm run admin:create`) - -``` -1. Execution sur le serveur (pas d'API publique) -2. Prompt: username, email, password -3. Hash password avec bcrypt -4. Genere totpSecret avec otplib -5. Cree AdminUser (totpEnabled: false) -6. A la premiere connexion: scan QR → activation 2FA -``` - -**Securite:** Aucun endpoint public ne permet de creer un SuperAdmin. - -### 11.5 Gestion des Tags/Ingredients - -**Fusion (merge):** -``` -CONDITIONS: -- Le tag/ingredient source existe -- Le tag/ingredient cible existe -- Source != Cible - -ACTIONS: -1. Mettre a jour toutes les RecipeTag/RecipeIngredient: - → Remplacer sourceId par targetId -2. Supprimer le tag/ingredient source (hard delete) -3. Creer AdminActivityLog (TAG_MERGED / INGREDIENT_MERGED) -4. Retourner le nombre de recettes mises a jour -``` - -### 11.6 Gestion des Communautes (Admin) - -**Suppression par SuperAdmin:** -``` -ACTIONS: -1. Soft delete la communaute (deletedAt = now) -2. Soft delete tous les UserCommunity -3. Soft delete toutes les recettes communautaires -4. Annuler toutes les invitations PENDING -5. Creer AdminActivityLog (COMMUNITY_DELETED) -``` - -**Note:** Le SuperAdmin peut supprimer n'importe quelle communaute, meme avec des membres actifs. - -### 11.7 Audit Admin - -Toutes les actions SuperAdmin sont enregistrees dans `AdminActivityLog`: - -| Type | Declencheur | Donnees | -|------|-------------|---------| -| `TAG_CREATED` | Nouveau tag | targetType: "Tag", targetId | -| `TAG_UPDATED` | Renommage tag | targetId, metadata.oldName, metadata.newName | -| `TAG_DELETED` | Suppression tag | targetId, metadata.recipesAffected | -| `TAG_MERGED` | Fusion tags | targetId, metadata.sourceId, metadata.recipesUpdated | -| `INGREDIENT_*` | (idem que tags) | ... | -| `COMMUNITY_RENAMED` | Renommage communaute | targetId, metadata | -| `COMMUNITY_DELETED` | Suppression communaute | targetId, metadata.membersAffected | -| `FEATURE_GRANTED` | Attribution feature | metadata.communityId, metadata.featureCode | -| `FEATURE_REVOKED` | Revocation feature | metadata.communityId, metadata.featureCode | -| `ADMIN_LOGIN` | Connexion reussie | adminId | -| `ADMIN_LOGOUT` | Deconnexion | adminId | -| `ADMIN_TOTP_SETUP` | Configuration 2FA | adminId | - ---- - -## 12. Systeme de Briques (Features) - -### 12.1 Concept - -L'application est structuree en "briques" (modules/features) que les communautes peuvent ou non avoir: - -| Feature | Code | Description | Default | -|---------|------|-------------|---------| -| MVP | `MVP` | Catalogue recettes, communautes, partage | Oui | -| Planificateur | `MEAL_PLANNER` | Planification repas hebdomadaire | Non | -| (Futur) | `...` | Autres fonctionnalites | Non | - -### 12.2 Attribution automatique - -**A la creation d'une communaute:** -``` -1. Requete: SELECT * FROM Feature WHERE isDefault = true -2. Pour chaque feature default: - → INSERT CommunityFeature (communityId, featureId, grantedById: null) -3. La communaute a acces aux features par defaut -``` - -**Ajout d'une nouvelle feature par defaut (NOUVEAU):** -``` -Quand une nouvelle feature est creee avec isDefault = true: -1. Toutes les communautes EXISTANTES la recoivent automatiquement -2. Attribution: grantedById = null (auto-attribue) -3. Pas besoin d'action manuelle du SuperAdmin -``` - -### 12.3 Attribution manuelle - -**Par SuperAdmin:** -``` -POST /api/admin/communities/:id/features/:featureId - -CONDITIONS: -- La communaute existe -- La feature existe -- La feature n'est pas deja attribuee - -ACTIONS: -1. INSERT CommunityFeature (communityId, featureId, grantedById: adminId) -2. Creer AdminActivityLog (FEATURE_GRANTED) -``` - -### 12.4 Revocation - -**Par SuperAdmin:** -``` -DELETE /api/admin/communities/:id/features/:featureId - -CONDITIONS: -- La feature est attribuee a cette communaute -- La feature N'EST PAS une feature par defaut (isDefault = false) - → Exception: MVP ne peut pas etre revoque - -ACTIONS: -1. UPDATE CommunityFeature SET revokedAt = now() -2. Creer AdminActivityLog (FEATURE_REVOKED) -``` - -### 12.5 Verification d'acces (futur) - -Middleware pour verifier qu'une communaute a acces a une feature: - -```typescript -// Usage: -router.get('/meal-plan', requireAuth, memberOf(), hasFeature('MEAL_PLANNER'), getMealPlan); -``` - -**Note:** Pour le MVP, seule la feature "MVP" existe. Le middleware `hasFeature` sera implemente quand de nouvelles briques seront ajoutees. - ---- - -## 13. Codes d'erreur Admin - -| Code | Message | Contexte | -|------|---------|----------| -| `ADMIN_001` | Non authentifie (admin) | Acces route admin sans session | -| `ADMIN_002` | 2FA requis | Session admin sans totpVerified | -| `ADMIN_003` | Token TOTP invalide | Code 2FA incorrect | -| `ADMIN_004` | 2FA deja configure | Setup TOTP sur admin avec totpEnabled=true | -| `ADMIN_005` | Tag non trouve | ID tag invalide | -| `ADMIN_006` | Ingredient non trouve | ID ingredient invalide | -| `ADMIN_007` | Communaute non trouvee | ID communaute invalide | -| `ADMIN_008` | Feature non trouvee | ID feature invalide | -| `ADMIN_009` | Feature deja attribuee | Attribution en double | -| `ADMIN_010` | Impossible de revoquer feature par defaut | Tentative revocation MVP | -| `ADMIN_011` | Tag/Ingredient existe deja | Creation avec nom existant | -| `ADMIN_012` | Fusion sur soi-meme interdite | sourceId == targetId | diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md deleted file mode 100644 index deaa73b6..00000000 --- a/docs/DEVELOPMENT_ROADMAP.md +++ /dev/null @@ -1,915 +0,0 @@ -# Development Roadmap - Forest Manager - -## Vue d'ensemble - -Ce document decrit les phases de developpement du MVP de Forest Manager, avec les taches techniques associees. - ---- - -## Phase 0: Setup & Infrastructure - -### 0.1 Configuration initiale - -- [x] Setup boilerplate (React + Express + PostgreSQL + Docker) -- [x] Configuration Docker Compose -- [x] CI/CD GitHub Actions -- [x] Mise a jour du schema Prisma selon PRISMA_SCHEMA.prisma -- [x] Configuration des variables d'environnement (SESSION_SECRET, ADMIN_SESSION_SECRET) -- [x] Installation @quixo3/prisma-session-store -- [x] Migration initiale creee et appliquee - -### 0.2 Structure du code - -- [x] Creation de l'arborescence backend (services/, middleware/, admin/) -- [x] Creation de l'arborescence frontend (pages/, components/, hooks/, contexts/) -- [x] Configuration ESLint/Prettier uniformes -- [x] Setup des types TypeScript partages (frontend/src/models/) - -### Livrables - -- Projet demarrable en local via `docker-compose up` -- Schema BDD complet et migre -- Structure de code prete pour le developpement - ---- - -## Phase 0.5: SuperAdmin & Briques (NOUVEAU) - -### 0.5.1 Backend Admin - -- [x] Installation dependances (otplib, qrcode) -- [x] Creation module admin/ isole - - admin/controllers/ - - admin/routes/ - - admin/middleware/ -- [x] Model AdminUser (Prisma schema) -- [x] Model AdminSession (Prisma session separee) -- [x] Model Feature (briques) -- [x] Model CommunityFeature (attribution) -- [x] Model AdminActivityLog (audit) -- [x] Script CLI `npm run admin:create` - - Prompt username, email, password - - Hash password bcrypt - - Genere totpSecret - -### 0.5.2 Backend Auth Admin - -- [x] Configuration double session store - - Session users: `connect.sid` → model Session - - Session admin: `admin.sid` → model AdminSession - - Cookie flags: httpOnly, secure (prod), sameSite=strict -- [x] Middleware requireSuperAdmin - - Verifie session.adminId - - Verifie session.totpVerified -- [x] Route POST /api/admin/auth/login - - Premiere connexion: retourne QR code (base64) - - Connexions suivantes: demande code TOTP - - **Securite**: Rate limiting (5 tentatives/15min) -- [x] Route POST /api/admin/auth/totp/verify - - Verifie code TOTP (fenetre 1 step = 30s) - - **Securite**: Invalide apres 3 echecs consecutifs (reset session) - - Log ADMIN_LOGIN et ADMIN_TOTP_SETUP (AdminActivityLog) -- [x] Route POST /api/admin/auth/logout - - Destruction complete session admin - - Log ADMIN_LOGOUT (AdminActivityLog) -- [x] Route GET /api/admin/auth/me - - Retourne infos admin (sans totpSecret) - -### 0.5.3 Backend Admin API - -- [x] Routes Tags CRUD (/api/admin/tags) - - GET, POST, PATCH, DELETE, POST merge -- [x] Routes Ingredients CRUD (/api/admin/ingredients) - - GET, POST, PATCH, DELETE, POST merge -- [x] Routes Communities (/api/admin/communities) - - GET list, GET detail, PATCH, DELETE -- [x] Routes Features (/api/admin/features) - - GET, POST, PATCH - - POST /communities/:id/features/:featureId (grant) - - DELETE /communities/:id/features/:featureId (revoke) -- [x] Route Dashboard (/api/admin/dashboard/stats) -- [x] Route Activity (/api/admin/activity) -- [x] AdminActivityLog sur toutes les actions - -### 0.5.4 Seed Feature MVP - -- [x] Ajouter feature MVP dans seed - - code: "MVP", isDefault: true -- [x] Modifier creation communaute - - Attribution auto features par defaut - -### 0.5.5 Frontend Admin (NOUVEAU) - -- [x] Route React /admin/login - - Etape 1: Formulaire email + password - - Etape 2: Affichage QR code si !totpEnabled (setup initial) - - Etape 3: Champ code TOTP (6 chiffres) - - **Securite**: Pas de stockage token/secret cote client - - **Securite**: Redirect si deja authentifie -- [x] Route React /admin/dashboard (protegee) - - Layout admin separe du frontend user - - Affichage stats basiques -- [x] Composant AdminProtectedRoute - - Verifie session admin via /api/admin/auth/me - - Redirect vers /admin/login si non authentifie -- [x] Context AdminAuthProvider (isole de AuthProvider user) - -### 0.5.6 Securite Admin (transversal) - -- [x] Rate limiting sur /api/admin/auth/\* (express-rate-limit, 5/15min) -- [x] Rate limiting global sur /api/admin/\* (30 req/min) -- [x] Headers securite (helmet) - - CSP strict pour pages admin - - X-Frame-Options: DENY - - X-Content-Type-Options: nosniff -- [x] HTTPS obligatoire en production (middleware requireHttps + HSTS) -- [x] Audit log actions auth admin (ADMIN_LOGIN, ADMIN_LOGOUT, ADMIN_TOTP_SETUP) -- [x] Session admin courte (30min, non renouvelable) - -### Livrables - -- SuperAdmin fonctionnel avec 2FA -- Interface admin minimale et securisee (login + dashboard) -- API admin complete (tags, ingredients, features, communities) -- Systeme de briques operationnel -- Feature MVP attribuee auto - -### Tests Phase 0.5 - -**Backend** (~60 tests): - -- [x] `adminAuth.test.ts` - Auth 2FA admin (14 tests) -- [x] `adminTags.test.ts` - CRUD tags admin (12 tests) -- [x] `adminIngredients.test.ts` - CRUD ingredients admin (12 tests) -- [x] `adminFeatures.test.ts` - CRUD features + grant/revoke (10 tests) -- [x] `adminCommunities.test.ts` - Gestion communautes admin (8 tests) -- [x] `adminDashboard.test.ts` - Stats dashboard (4 tests) -- [x] `adminActivity.test.ts` - Logs activite (4 tests) - -**Frontend** (~20 tests): - -- [x] `AdminAuthContext.test.tsx` - Context admin 2FA (7 tests) -- [x] `AdminProtectedRoute.test.tsx` - Guard admin (4 tests) -- [x] `AdminLoginPage.test.tsx` - Page login 2FA (8 tests) -- [x] `AdminDashboardPage.test.tsx` - Page dashboard (6 tests) -- [x] `AdminLayout.test.tsx` - Layout admin (3 tests) - ---- - -## Phase 1: Authentification & Base - -### 1.1 Backend Auth - -- [x] Route POST /api/auth/signup - - Validation email, username, password - - Hash password (bcrypt) - - Creation session -- [x] Route POST /api/auth/login - - Verification credentials - - Creation session -- [x] Route POST /api/auth/logout - - Destruction session -- [x] Route GET /api/auth/me - - Recuperation user courant -- [x] Middleware requireAuth - -### 1.2 Frontend Auth - -- [x] Page Login (Modal - pattern UX hybride) -- [x] Page Signup (Page dediee - meilleur onboarding) -- [x] Context AuthProvider -- [x] Hook useAuth -- [x] Protected routes (ProtectedRoute component) -- [x] Redirection automatique - -### 1.3 Layout de base - -- [x] Header avec navigation (NavBar sticky) -- [x] Sidebar (communautes) - placeholder, ready for Phase 3 -- [x] Layout responsive (DaisyUI drawer pattern) - -### Livrables - -- Utilisateurs peuvent s'inscrire, se connecter, se deconnecter -- Navigation de base fonctionnelle - -### Tests Phase 1 - -**Backend** (~16 tests): - -- [x] `auth.test.ts` - User signup/login/logout/me (16 tests) - -**Frontend** (~25 tests): - -- [x] `AuthContext.test.tsx` - Context auth user (6 tests) -- [x] `LoginModal.test.tsx` - Modal login (6 tests) -- [x] `Modal.test.tsx` - Composant modal (4 tests) -- [x] `SignUpPage.test.tsx` - Page inscription (6 tests) -- [x] `ProtectedRoute.test.tsx` - Guard user (5 tests) -- [x] `NavBar.test.tsx` - Navigation conditionnelle (4 tests) - ---- - -## Phase 2: Catalogue Personnel - -### 2.1 Backend Recipes (personnel) - -- [x] Route POST /api/recipes - - Creation recette personnelle - - Gestion tags (creation a la volee si inexistant) - - Gestion ingredients (creation a la volee si inexistant) -- [x] Route GET /api/recipes - - Liste paginee (limit, offset, hasMore) - - Filtre par tags (logique AND) - - Recherche par titre (case-insensitive) -- [x] Route GET /api/recipes/:id - - Detail recette avec tags et ingredients - - Verification acces (owner only pour recettes perso) -- [x] Route PATCH /api/recipes/:id - - Modification (owner only) - - Mise a jour tags/ingredients (remplacement complet) -- [x] Route DELETE /api/recipes/:id - - Soft delete (owner only) - -### 2.2 Backend Autocomplete (NOUVEAU) - -- [x] Route GET /api/tags - - Recherche tags avec recipeCount - - Limite configurable (max 100) -- [x] Route GET /api/ingredients - - Recherche ingredients avec recipeCount - - Limite configurable (max 100) - -### 2.3 Frontend Catalogue - -- [x] Page liste recettes personnelles - - Grille responsive (1-4 colonnes) - - Pagination "Load more" - - Filtres persistés dans URL (search, tags) -- [x] Page creation recette (/recipes/new) - - Form: titre, contenu (textarea), imageUrl (optionnel) - - Gestion tags (TagSelector avec creation a la volee) - - Gestion ingredients (IngredientList dynamique) -- [x] Page detail recette (/recipes/:id) - - Affichage complet avec ingredients et instructions - - Navigation vers filtres par tag -- [x] Page edition recette (/recipes/:id/edit) - - Meme formulaire que creation, pre-rempli -- [x] Composant RecipeCard - - Image, titre, tags (max 3), date, actions edit/delete -- [x] Composant TagSelector - - Multi-select avec debounce 300ms - - Creation on-the-fly - - Mode filtre (sans creation) -- [x] Composant IngredientList - - Liste dynamique avec autocomplete - - Champs: nom, quantite (optionnel) -- [x] Composant RecipeFilters - - Recherche titre + filtre tags - - Bouton reset - -### Livrables - -- [x] CRUD complet sur les recettes personnelles -- [x] Interface de creation/edition fonctionnelle -- [x] Pagination et filtres fonctionnels - -### Tests Phase 2 - -**Backend** (~40 tests): - -- [x] `recipes.test.ts` - CRUD complet recettes (31 tests) -- [x] `tags.test.ts` - GET /api/tags autocomplete (5 tests) -- [x] `ingredients.test.ts` - GET /api/ingredients autocomplete (5 tests) - -**Frontend** (~40 tests): - -- [x] `RecipeCard.test.tsx` - Carte recette (8 tests) -- [x] `RecipeFilters.test.tsx` - Filtres search/tags/ingredients (8 tests) -- [x] `TagSelector.test.tsx` - Selecteur tags (6 tests) -- [x] `IngredientList.test.tsx` - Liste ingredients (6 tests) -- [x] `RecipesPage.test.tsx` - Page liste recettes (3 tests) -- [x] `MainLayout.test.tsx` - Layout principal (6 tests) -- [x] `Sidebar.test.tsx` - Navigation sidebar (10 tests) -- [x] `HomePage.test.tsx` - Page accueil (6 tests) - ---- - -## Phase 3: Communautes & Invitations - -### 3.1 Backend Communities - -- [x] Route POST /api/communities - - Creation communaute - - Ajout createur comme MODERATOR (admin de communaute) -- [x] Route GET /api/communities - - Liste des communautes de l'utilisateur -- [x] Route GET /api/communities/:id - - Detail communaute - - Middleware memberOf -- [x] Route PATCH /api/communities/:id - - Modification (MODERATOR only) - -### 3.2 Backend Invitations (NOUVEAU) - -- [x] Route POST /api/communities/:id/invites - - Envoi invitation (MODERATOR only) - - Recherche user par email/username/userId - - Validation: pas deja membre, pas deja invite PENDING - - Creation CommunityInvite (status: PENDING) - - Log ActivityLog (INVITE_SENT) -- [x] Route GET /api/communities/:id/invites - - Liste invitations (MODERATOR only) - - Filtre par status (default: PENDING, ou ?status=all) -- [x] Route DELETE /api/communities/:id/invites/:inviteId - - Annulation invitation (MODERATOR only) - - Status → CANCELLED - - Log ActivityLog (INVITE_CANCELLED) -- [x] Route GET /api/users/me/invites - - Invitations recues par l'utilisateur - - Filtre par status -- [x] Route POST /api/invites/:id/accept - - Acceptation invitation (invitee only) - - Creation UserCommunity (role: MEMBER) - - Status → ACCEPTED - - Log ActivityLog (INVITE_ACCEPTED, USER_JOINED) -- [x] Route POST /api/invites/:id/reject - - Refus invitation (invitee only) - - Status → REJECTED - - Log ActivityLog (INVITE_REJECTED) - -### 3.3 Backend Members - -- [x] Route GET /api/communities/:id/members -- [x] Route PATCH /api/communities/:id/members/:userId - - Promotion (MODERATOR only, no demote) - - Log ActivityLog (USER_PROMOTED) -- [x] Route DELETE /api/communities/:id/members/:userId - - Quitter (self) ou Retirer (MODERATOR kick) - - Validation: MODERATOR ne peut pas kick un MODERATOR - - Logique dernier MODERATOR (bloquer si autres membres) - - Suppression communaute si dernier membre (cascade soft delete) - - Log ActivityLog (USER_LEFT ou USER_KICKED) - -### 3.4 Frontend Communities - -- [x] Page liste mes communautes (CommunitiesPage) -- [x] Page creation communaute (CommunityCreatePage) -- [x] Page detail communaute avec onglets Membres/Invitations/Recipes -- [x] Page edition communaute (inline SidePanel, CommunityEditPage en fallback route) -- [x] Composant MembersList (promotion, retrait, leave) -- [x] Dashboard page (communautes + recettes, page d'accueil authentifiee) -- [x] Sidebar Discord-style avec avatars communautes (initiales) -- [x] Correction bug leave community (gestion 410, redirect) - -### 3.5 Frontend Invitations - -- [x] Page invitations recues (InvitationsPage) -- [x] Badge notification (InvitationBadge) -- [x] Carte invitation avec Accept/Reject (InviteCard) -- [x] Modal recherche utilisateur avec autocomplete (InviteUserModal) -- [x] Liste invitations envoyees (SentInvitesList) -- [x] Dropdown notifications dans navbar (NotificationDropdown) -- [x] Redirect vers communaute apres acceptation invitation - -### 3.6 Frontend User Management - -- [x] User menu (icone profil + dropdown dans navbar) -- [x] Page profil (modification username, email, mot de passe) -- [x] Backend PATCH /api/users/me (mise a jour profil) -- [x] Backend GET /api/users/search (autocomplete usernames) - -### Livrables - -- Gestion complete des communautes -- Systeme d'invitation avec acceptation explicite -- Systeme de roles fonctionnel -- Kick de membres - -### Tests Phase 3 - -**Backend** (~50 tests): - -- [x] `communities.test.ts` - CRUD communautes (27 tests) -- [x] `invitations.test.ts` - Systeme d'invitations (35 tests) -- [x] `members.test.ts` - Gestion membres, kick, promotion (22 tests) - -**Frontend** (~31 tests): - -- [x] `CommunitiesPage.test.tsx` - Liste communautes (7 tests) -- [x] `CommunityDetailPage.test.tsx` - Detail communaute (11 tests) -- [x] `InviteCard.test.tsx` - Carte invitation (5 tests) -- [x] `MembersList.test.tsx` - Liste membres (6 tests) -- [x] `InviteUserModal.test.tsx` - Modal invitation (5 tests) - ---- - -## Phase 4: Recettes Communautaires - -**Note**: La synchronisation bidirectionnelle (modif perso ↔ communautaire) sera implementee en Phase 5. - -### 4.1 Backend - -- [x] Route POST /api/communities/:id/recipes - - Creation recette dans catalogue personnel (communityId: null) - - Creation copie dans communaute (communityId: X) - - Lien originRecipeId vers recette perso - - Log ActivityLog (RECIPE_CREATED) -- [x] Route GET /api/communities/:id/recipes - - Liste recettes communaute - - Pagination, filtre tags, recherche -- [x] Modification routes recipes/:id - - Gestion recettes communautaires (verification membership) - -### 4.2 Frontend - -- [x] Liste recettes dans page communaute -- [x] Creation recette depuis communaute -- [x] Distinction visuelle perso vs communaute -- [x] Lien vers communaute sur recette -- [x] Badge "Partage depuis X" si sharedFromCommunityId - -### Livrables - -- Creation et affichage de recettes dans les communautes -- Copie automatique dans catalogue personnel -- Lien recette perso ↔ communautaire fonctionnel - ---- - -## Phase 5: Propositions & Variantes - -**Decisions metier validees (voir BUSINESS_RULES.md sections 3.2.1, 3.3.1, 4.2, 4.4, 5.4, 6.3):** - -- Synchro bidirectionnelle: titre/contenu/ingredients synchronises, tags LOCAUX par communaute -- Conflit: bloquer acceptation si recipe.updatedAt > proposal.createdAt (PROPOSAL_003) -- Cascade: acceptation → 1 ActivityLog par communaute impactee -- Orphelins: permanent, pas de modification directe, propositions PENDING auto-refusees -- Variantes: coexistent, dropdown PLAT, tri par MAX(createdAt, updatedAt) -- Variantes de variantes OK, originRecipeId = parent immediat - -### 5.1 Backend Proposals - -- [x] Route GET /api/recipes/:id/proposals - - Liste propositions (filtre status) -- [x] Route POST /api/recipes/:id/proposals - - Creation proposition - - Validation: not own recipe, member, recipe not orphan - - Log ActivityLog (VARIANT_PROPOSED) -- [x] Route GET /api/proposals/:id - - Detail proposition -- [x] Route POST /api/proposals/:id/accept - - **Validation conflit**: bloquer si recipe.updatedAt > proposal.createdAt (PROPOSAL_003) - - Mise a jour recette communautaire (titre, contenu - PAS tags) - - Mise a jour recette personnelle liee (via originRecipeId) - - **CASCADE**: Mise a jour de toutes les autres copies communautaires - - **ActivityLog**: 1 entry PROPOSAL_ACCEPTED + 1 entry RECIPE_UPDATED par communaute - - Status ACCEPTED -- [x] Route POST /api/proposals/:id/reject - - Creation variante (isVariant: true, originRecipeId = parent, creatorId: proposer) - - Status REJECTED - - Log ActivityLog (VARIANT_CREATED) - -### 5.2 Backend Variants - -- [x] Route GET /api/recipes/:id/variants - - Liste variantes (where originRecipeId = X AND isVariant = true) - - **Scope**: seulement les variantes de CETTE communaute - - **Tri**: par MAX(createdAt, updatedAt) DESC - -### 5.3 Backend Orphan Handling - -- [x] Service handleOrphanedRecipes - - Declenche quand: membre quitte/kick - - Auto-refuse toutes les propositions PENDING → cree variantes - - Log ActivityLog (VARIANT_CREATED) avec reason: ORPHAN_AUTO_REJECT -- [x] Integration dans members controller (handleLeave, handleKick) -- [x] Tests integration (4 tests: leave avec proposals, multiple proposals, decided proposals, kick) - -### 5.4 Frontend - -- [x] Bouton "Proposer modification" sur recette (pas sur ses propres) -- [x] Page/Modal creation proposition (formulaire pre-rempli) -- [x] Section propositions pour proprietaire (dans RecipeDetailPage) -- [x] Boutons Accept/Reject avec expand details -- [x] Dropdown variantes sur page recette - -### Livrables - -- Workflow complet de propositions -- Creation automatique de variantes -- Visualisation des variantes - ---- - -## Phase 6: Activity Feed (communautaire + personnel) - -### 6.1 Backend Activity - -- [x] Controller `controllers/activity.ts` - - getCommunityActivity (feed communaute) - - getMyActivity (feed personnel) -- [x] Route GET /api/communities/:id/activity - - Feed pagine par communaute (memberOf) -- [x] Route GET /api/users/me/activity - - Feed personnel pagine - - Actions propres de l'utilisateur - - Propositions sur ses recettes -- [x] Tests `activity.test.ts` (15 tests) - -### 6.2 Frontend Activity - -- [x] Composant ActivityFeed avec icones par type d'evenement -- [x] Integration dans page communaute (onglet Activity) -- [x] Section feed personnel sur DashboardPage -- [x] Formatage des evenements (16 types supportes) -- [x] Liens vers recettes et communautes -- [x] Tests `ActivityFeed.test.tsx` (8 tests) - -### Livrables - -- Activity feed communautaire fonctionnel -- Activity feed personnel fonctionnel - ---- - -## Phase 7: Partage Inter-Communautes - - - -**Decisions metier validees (voir BUSINESS_RULES.md section 5):** - -- originRecipeId pointe vers recette COMMUNAUTAIRE source (pas perso) -- Fork = totalement independant, pas de synchronisation -- Chaines de forks OK: A→B→C, origin = parent immediat -- Analytics: remontee en chaine (+1 pour A quand C fork B) - -### 7.1 Backend - -- [x] Route POST /api/recipes/:id/share - - Validation membership deux communautes - - Validation permission (MODERATOR OU createur) - - Creation fork avec originRecipeId et sharedFromCommunityId - - Analytics chaine: increment shares/forks pour tous les ancetres - - ActivityLog RECIPE_SHARED dans les deux communautes -- [x] Tests `share.test.ts` (28 tests) - -### 7.2 Frontend - -- [x] Bouton "Partager dans une communaute" -- [x] Modal selection communaute cible (ShareRecipeModal) -- [x] Badge "Partage depuis X" sur recettes forkees (RecipeDetailPage, RecipeCard, RecipeListRow) -- [x] Tests ShareRecipeModal (7 tests) - -### 7.3 Corrections pre-Phase 8 (13 fixes) - -- [x] Fix bug ShareRecipeModal (response.data.recipe → response.data) -- [x] Prevention partage doublon (SHARE_006) -- [x] Badge "Shared by: username" (remplace "Shared from: community") -- [x] Publish recette perso vers communautes (POST /api/recipes/:id/publish) -- [x] Endpoint GET /api/recipes/:id/communities (liste communautes d'une recette) -- [x] SharePersonalRecipeModal (multi-select checkboxes) -- [x] Activity tab visible uniquement pour moderateurs -- [x] Synchro bidirectionnelle recettes (perso ↔ communaute, excl. forks/variants/tags) -- [x] Sidebar header sticky + scrollbar invisible -- [x] Default tab "recipes" (au lieu de "members") -- [x] Suppression bouton "Back to communities" -- [x] Side panel redimensionnable (remplace tabs members/activity/invitations) -- [x] Edit communaute inline dans le side panel (remplace page separee) -- [x] Sidebar refresh automatique apres edit communaute (custom event) -- [x] ShareRecipeModal refactored en checkboxes multi-select (meme UX que personal) -- [x] getRecipeCommunities trace toute la chaine de forks (prevention doublons) -- [x] Bouton Share sur RecipeDetailPage pour recettes personnelles -- [x] Redirect vers page detail apres creation recette communautaire -- [x] Navigation tags communautaires (filtre via query params) -- [x] Tests backend share.test.ts (28 tests total, +13 nouveaux) -- [x] Tests frontend CommunityDetailPage (11 tests, reecrit pour side panel) - -### Livrables - -- Fork de recettes entre communautes fonctionnel -- Tracabilite des origines -- Analytics avec remontee en chaine -- Publication recettes perso vers communautes -- Side panel redimensionnable pour membres/activity/invitations -- Synchro bidirectionnelle recettes perso ↔ communaute - ---- - -## Phase 8: Finitions MVP - -### 8.1 Qualite - -- [x] Refactoring du code pour ameliorer la lisibilite et la maintenabilite et decouper les fichiers trop longs - - recipes.ts (1438 -> 720 lignes) decoupe en recipeVariants.ts + recipeShare.ts -- [x] Gestion complete des erreurs frontend (toast/notification) -- [x] Messages de confirmation/feedback -- [x] Loading states (skeletons) -- [x] Empty states (messages explicatifs) -- [x] Gestion soft delete (filtrage automatique deletedAt IS NULL) - audit complet, tout OK - -### 8.2 Tests - -- [x] Corriger les tests frontend qui ne passent pas (ShareRecipeModal.test.tsx - 6 tests fixes, combobox -> checkboxes) -- [x] Verifier que le lint passe sans erreur (`npm run lint` frontend + backend) - 0 erreurs -- [ ] Tests manuels complets (parcours utilisateur) -- [x] Corrections bugs -- [x] Verification toutes les regles metier - -### 8.3 Documentation - -- [x] README utilisateur -- [x] Guide de deploiement - -### Livrables - -- Application MVP complete et testee -- Prete pour mise en production - ---- - -## Phase 9: (Post-MVP) Ameliorations - -### 9.2 Notifications temps reel + Dark mode - -- [x] Dark mode : themes DaisyUI forest (dark) + winter (light) - - ThemeContext avec localStorage + system preference - - Anti-flash script inline dans index.html - - Toggle soleil/lune dans NavBar (user only, admin reste dark) - - Cleanup global.css (suppression couleurs hardcodees) - - 7 tests ThemeContext + 2 tests NavBar toggle -- [x] WebSocket (Socket.IO) : events temps reel - - Backend : EventEmitter singleton + Socket.IO server avec auth session - - Middleware auth : wrappe session Express, rejette si pas de userId - - Rooms : user:{userId} + community:{communityId} - - Emissions depuis controllers : invites, members, proposals, recipes, share - - CSP : ajout ws:/wss: dans connectSrc - - Frontend : SocketContext (auto connect/disconnect), useSocketEvent, useCommunityRoom - - useNotificationToasts (INVITE_SENT, VARIANT_PROPOSED, PROPOSAL_ACCEPTED/REJECTED...) - - Remplacement polling 60s par socket events (NotificationDropdown, InvitationBadge) - - Auto-refresh ActivityFeed sur socket activity events - - 3 tests eventEmitter, 4 tests websocket integration, 2 tests SocketContext - -### 9.3 Technique - -- [x] Tests unitaires supplementaires (+113 tests, 550 -> 663) - - Backend: pagination, validation, responseFormatters, auth middleware, requireSuperAdmin, security - - Frontend hooks: useClickOutside, useDebouncedEffect, useConfirm, useSocketEvent, useCommunityRoom, useNotificationToasts, usePaginatedList - - Frontend utils: formatDate, communityEvents - - Frontend pages: DashboardPage, ProfilePage, NotFoundPage, RecipeFormPage -- [x] Logging structure (Pino) - - Remplacement morgan par pino-http - - Logger central (pino) : silent en test, pino-pretty en dev, JSON en prod - - Remplacement console.log/error dans app.ts, server.ts, socketServer.ts - -### 9.4 Frontend Admin - Pages de gestion - -- [x] Layout admin avec sidebar navigation - - Dashboard, Tags, Ingredients, Features, Communities, Activity -- [x] Page /admin/tags - - Liste paginee avec recherche - - Modal creation/edition tag - - Bouton suppression avec confirmation - - Fonctionnalite merge (selectionner 2+ tags) -- [x] Page /admin/ingredients - - Liste paginee avec recherche - - Modal creation/edition ingredient - - Bouton suppression avec confirmation - - Fonctionnalite merge (selectionner 2+ ingredients) -- [x] Page /admin/features - - Liste des features avec statut (actif/inactif) - - Modal creation/edition feature - - Toggle actif/inactif -- [x] Page /admin/communities - - Liste paginee avec recherche - - Detail communaute (membres, recettes, features) - - Attribution/revocation features - - Bouton suppression avec confirmation -- [x] Page /admin/activity - - Liste paginee des logs d'activite admin - - Filtres par type d'action, date - ---- - -## Estimation par phase - -| Phase | Description | Complexite | -| ------- | ------------------------- | ---------- | -| 0 | Setup | Faible | -| **0.5** | **SuperAdmin & Briques** | **Haute** | -| 1 | Auth | Moyenne | -| 2 | Catalogue personnel | Moyenne | -| 3 | Communautes & Invitations | **Haute** | -| 4 | Recettes communautaires | Moyenne | -| 5 | Propositions & Variantes | Haute | -| 6 | Activity Feed | Moyenne | -| 7 | Partage inter-communautes | Moyenne | -| 8 | Finitions | Moyenne | - ---- - -## Dependances entre phases - -``` -Phase 0 (Setup) - │ - ▼ -Phase 0.5 (SuperAdmin & Briques) ◄── Prerequis pour gestion plateforme - │ - ▼ -Phase 1 (Auth) - │ - ▼ -Phase 2 (Catalogue personnel) - │ - ▼ -Phase 3 (Communautes & Invitations) ◄── Attribution auto feature MVP - │ - ├───────────────┐ - ▼ ▼ -Phase 4 Phase 7 -(Recettes (Partage inter-communautes) -communautaires) │ - │ │ - ▼ │ -Phase 5 ◄──────────┘ -(Propositions & Variantes) - │ - ▼ -Phase 6 (Activity Feed) - │ - ▼ -Phase 8 (Finitions MVP) -``` - ---- - -## Checklist de validation MVP - -### SuperAdmin (Phase 0.5) - -- [x] SuperAdmin cree via CLI (`npm run admin:create`) -- [x] SuperAdmin peut se connecter avec 2FA TOTP (backend ready) -- [x] SuperAdmin peut gerer les tags (CRUD, merge) (backend ready) -- [x] SuperAdmin peut gerer les ingredients (CRUD, merge) (backend ready) -- [x] SuperAdmin peut voir toutes les communautes (backend ready) -- [x] SuperAdmin peut supprimer une communaute (backend ready) -- [x] SuperAdmin peut attribuer/revoquer des features (backend ready) -- [x] Feature MVP attribuee auto a la creation communaute -- [x] Toutes les actions admin sont loguees - -### Fonctionnel - -- [x] Un utilisateur peut s'inscrire et se connecter -- [x] Un utilisateur peut creer des recettes personnelles -- [x] Un utilisateur peut creer une communaute -- [x] Un MODERATOR peut inviter des utilisateurs (backend) -- [x] Un utilisateur voit ses invitations recues (backend) -- [x] Un utilisateur peut accepter/refuser une invitation (backend) -- [x] Un MODERATOR peut annuler une invitation (backend) -- [x] Un MODERATOR peut promouvoir un membre en MODERATOR (backend) -- [x] Un MODERATOR peut retirer un membre (mais pas un MODERATOR) (backend) -- [x] Un membre peut creer une recette dans une communaute -- [x] Une copie est creee dans son catalogue personnel -- [x] Un membre peut proposer une modification -- [x] Le createur peut accepter (mise a jour des deux recettes) ou refuser (variante) -- [x] Les variantes sont visibles dans un dropdown -- [x] Un utilisateur peut forker une recette vers une autre communaute -- [x] L'activity feed communautaire montre les evenements -- [x] L'activity feed personnel montre les propositions sur mes recettes - -### Technique - -- [ ] Application stable sans erreurs bloquantes -- [ ] Donnees persistees correctement -- [x] Sessions utilisateurs fonctionnelles (via @quixo3/prisma-session-store) -- [x] Sessions admin isolees (AdminSession, cookie admin.sid) -- [x] 2FA TOTP fonctionnel pour SuperAdmin (backend ready) -- [x] Soft delete filtre correctement (deletedAt IS NULL) -- [ ] Responsive design -- [ ] Performance acceptable (<3s chargement page) - ---- - -## Changements par rapport a la version precedente - -1. **Phase 0.5 ajoutee** - SuperAdmin & Briques - - Systeme SuperAdmin isole avec 2FA TOTP - - Gestion globale tags/ingredients/communautes - - Systeme de briques (Features) pour moduler les fonctionnalites - -2. **Phase 3 elargie** - Communautes & Invitations - - Ajout du systeme d'invitation avec acceptation explicite - - Ajout du kick de membres - - Attribution auto feature MVP a la creation - -3. **Phase 6** - Activity Feed (communautaire + personnel) - - Feed personnel inclus dans le MVP (upgrader de P3) - -4. **Session store** - Specifie @quixo3/prisma-session-store - - Session utilisateurs: connect.sid → Session - - Session admin: admin.sid → AdminSession (isole) - -5. **Checklist mise a jour** - Avec criteres SuperAdmin et 2FA - ---- - -## Maintenance technique (CI/CD) - -### Dependances a mettre a jour - -- [x] Corriger vulnerabilite axios frontend (`npm audit fix`) -- [x] Migrer otplib v12 -> v13 (nouvelle API: generateSecret, generateSync, verifySync, generateURI) -- [x] Mettre a jour ESLint v8 -> v9 (flat config `eslint.config.mjs`) -- [x] Migrer config Prisma vers `prisma.config.ts` -> non necessaire (setup standard suffisant, seed non supporte en ~6.19) -- [x] Remplacer `npm prune --production` par `--omit=dev` dans Dockerfile -- [x] Fix vulnerabilites npm: vitest 2->3 (fix 12 vulns esbuild/vite), bcrypt 5->6 (fix 3 vulns tar) -- [x] `npm audit` 0 vulnerabilites (backend + frontend) - ---- - -## 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** | diff --git a/docs/TESTS_IMPLEMENTATION_PLAN.md b/docs/TESTS_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 26af8c51..00000000 --- a/docs/TESTS_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,296 +0,0 @@ -# Plan d'Implementation des Tests - ForestManager - -## Objectif -Mettre en place un systeme de tests unitaires complet pour le backend et le frontend, integre au workflow CI/CD et au DEVELOPMENT_ROADMAP.md. - -## Decisions -- **Priorite**: Phase 0.5 (Admin) d'abord -- **Couverture**: Exhaustive (~244 tests total) - ---- - -## Etat Actuel (74 tests) - -### Backend (61 tests existants) -| Fichier | Tests | Couverture | -|---------|-------|------------| -| `auth.test.ts` | 16 | User signup/login/logout/me | -| `adminAuth.test.ts` | 14 | Admin 2FA login flow | -| `recipes.test.ts` | 31 | CRUD complet recettes | - -### Frontend (13 tests existants) -| Fichier | Tests | Couverture | -|---------|-------|------------| -| `AuthContext.test.tsx` | 6 | Context auth user | -| `AdminAuthContext.test.tsx` | 7 | Context admin 2FA | - -### Infrastructure existante -- **Backend**: Vitest + Supertest + Prisma test DB + testHelpers -- **Frontend**: Vitest + MSW + Testing Library + testUtils -- **CI/CD**: Jobs `test-backend` et `test-frontend` dans deploy.yml - ---- - -## Sprints d'Implementation - -### Sprint 1: Backend Admin (Phase 0.5) - ~50 tests -- [ ] Ajouter helpers dans `testHelpers.ts`: - - `createTestCommunity(creatorId, data?)` - - `createTestFeature(data?)` - - `loginAsAdmin(admin)` - Helper pour login complet 2FA -- [ ] `adminTags.test.ts` (12 tests) - - GET /api/admin/tags - liste paginee - - POST /api/admin/tags - creation - - PATCH /api/admin/tags/:id - modification - - DELETE /api/admin/tags/:id - suppression - - POST /api/admin/tags/:id/merge - fusion -- [ ] `adminIngredients.test.ts` (12 tests) - - GET /api/admin/ingredients - liste paginee - - POST /api/admin/ingredients - creation - - PATCH /api/admin/ingredients/:id - modification - - DELETE /api/admin/ingredients/:id - suppression - - POST /api/admin/ingredients/:id/merge - fusion -- [ ] `adminFeatures.test.ts` (10 tests) - - GET /api/admin/features - liste - - POST /api/admin/features - creation - - PATCH /api/admin/features/:id - modification - - POST /api/admin/communities/:cid/features/:fid - grant - - DELETE /api/admin/communities/:cid/features/:fid - revoke -- [ ] `adminCommunities.test.ts` (8 tests) - - GET /api/admin/communities - liste - - GET /api/admin/communities/:id - detail - - PATCH /api/admin/communities/:id - modification - - DELETE /api/admin/communities/:id - suppression -- [ ] `adminDashboard.test.ts` (4 tests) - - GET /api/admin/dashboard/stats - stats globales -- [ ] `adminActivity.test.ts` (4 tests) - - GET /api/admin/activity - logs activite - -### Sprint 2: Backend User Complet - ~10 tests -- [ ] `tags.test.ts` (5 tests) - - GET /api/tags - recherche - - Pagination, filtres, recipeCount -- [ ] `ingredients.test.ts` (5 tests) - - GET /api/ingredients - recherche - - Pagination, filtres, recipeCount - -### Sprint 3: Frontend Admin (Phase 0.5) - ~20 tests -- [ ] Etendre `mswHandlers.ts` avec mocks admin API: - - /api/admin/tags (CRUD) - - /api/admin/ingredients (CRUD) - - /api/admin/features (CRUD + grant/revoke) - - /api/admin/communities (CRUD) - - /api/admin/activity -- [ ] `AdminProtectedRoute.test.tsx` (4 tests) - - Redirect si non authentifie - - Redirect si TOTP non verifie - - Affiche enfants si authentifie - - Loading state -- [ ] `AdminLoginPage.test.tsx` (8 tests) - - Formulaire email/password - - Affichage QR code si nouvelle config - - Champ TOTP - - Validation erreurs - - Redirect apres succes -- [ ] `AdminDashboardPage.test.tsx` (5 tests) - - Affichage stats - - Loading state - - Error state -- [ ] `AdminLayout.test.tsx` (3 tests) - - Render avec sidebar - - Navigation - -### Sprint 4: Frontend Auth (Phase 1.2) - ~25 tests -- [ ] `LoginModal.test.tsx` (6 tests) - - Ouverture/fermeture modal - - Formulaire validation - - Login succes/erreur -- [ ] `Modal.test.tsx` (4 tests) - - Click outside ferme - - Escape ferme - - Render children -- [ ] `SignUpPage.test.tsx` (6 tests) - - Formulaire validation - - Signup succes/erreur - - Redirect si deja connecte -- [ ] `ProtectedRoute.test.tsx` (5 tests) - - Redirect si non authentifie - - Affiche enfants si authentifie - - Loading state -- [ ] `NavBar.test.tsx` (4 tests) - - Affichage connecte/deconnecte - - Navigation links - -### Sprint 5: Frontend Recipes (Phase 2.0) - ~40 tests -- [ ] `RecipeCard.test.tsx` (6 tests) - - Affichage image, titre, tags - - Click navigation - - Boutons edit/delete -- [ ] `RecipeListRow.test.tsx` (4 tests) - - Affichage row - - Actions -- [ ] `RecipeFilters.test.tsx` (8 tests) - - Recherche avec debounce - - Filtre tags - - Filtre ingredients - - Reset filtres - - URL sync -- [ ] `TagSelector.test.tsx` (6 tests) - - Autocomplete - - Selection/deselection - - Creation on-the-fly -- [ ] `IngredientList.test.tsx` (6 tests) - - Ajout/suppression ligne - - Autocomplete - - Quantite -- [ ] `RecipeFormPage.test.tsx` (10 tests) - - Mode creation - - Mode edition (pre-remplissage) - - Validation - - Submit succes/erreur - -### Sprint 6: Frontend Pages & Layout - ~25 tests -- [ ] `RecipeDetailPage.test.tsx` (6 tests) - - Affichage complet - - Boutons actions - - 404 handling -- [ ] `RecipesPage.test.tsx` (5 tests) - - Liste recettes - - Pagination - - Empty state -- [ ] `MainLayout.test.tsx` (6 tests) - - Responsive sidebar - - Toggle compact -- [ ] `Sidebar.test.tsx` (5 tests) - - Navigation items - - Mode compact -- [ ] `HomePage.test.tsx` (3 tests) - - Redirect selon auth - -### Sprint 7: Documentation & DEVELOPMENT_ROADMAP.md -- [ ] Ajouter section "Tests" apres chaque phase dans DEVELOPMENT_ROADMAP.md -- [ ] Template pour futures fonctionnalites avec tests -- [ ] Checklist validation tests - ---- - -## Fichiers a Creer - -### Backend -``` -backend/src/__tests__/integration/ - adminTags.test.ts - adminIngredients.test.ts - adminFeatures.test.ts - adminCommunities.test.ts - adminDashboard.test.ts - adminActivity.test.ts - tags.test.ts - ingredients.test.ts -``` - -### Frontend -``` -frontend/src/__tests__/ - unit/components/ - LoginModal.test.tsx - Modal.test.tsx - ProtectedRoute.test.tsx - NavBar.test.tsx - admin/ - AdminProtectedRoute.test.tsx - AdminLayout.test.tsx - recipes/ - RecipeCard.test.tsx - RecipeListRow.test.tsx - RecipeFilters.test.tsx - form/ - TagSelector.test.tsx - IngredientList.test.tsx - unit/pages/ - SignUpPage.test.tsx - HomePage.test.tsx - RecipesPage.test.tsx - RecipeDetailPage.test.tsx - RecipeFormPage.test.tsx - admin/ - AdminLoginPage.test.tsx - AdminDashboardPage.test.tsx - unit/layout/ - MainLayout.test.tsx - Sidebar.test.tsx -``` - ---- - -## Fichiers a Modifier - -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 - ---- - -## Commandes de Verification - -```bash -# Backend -cd backend && npm test -cd backend && npm run test:coverage - -# Frontend -cd frontend && npm test -cd frontend && npm run test:coverage - -# CI/CD (via GitHub Actions) -# Les jobs test-backend et test-frontend doivent etre verts -``` - ---- - -## Objectifs de Couverture - -| Categorie | Cible | -|-----------|-------| -| Backend controllers/routes | > 80% | -| Frontend composants critiques | > 70% | - ---- - -## Estimation Finale - -| Categorie | Tests existants | Tests a ajouter | Total | -|-----------|-----------------|-----------------|-------| -| Backend | 61 | ~60 | ~121 | -| Frontend | 13 | ~110 | ~123 | -| **Total** | **74** | **~170** | **~244** | - ---- - -## Criteres de Succes - -- [ ] Tous les tests passent localement -- [ ] CI/CD `test-backend` et `test-frontend` verts -- [ ] Couverture backend > 80% -- [ ] Couverture frontend > 70% -- [ ] DEVELOPMENT_ROADMAP.md mis a jour avec sections tests -- [ ] Template de tests pour futures fonctionnalites documente - ---- - -## Notes Techniques - -### Backend -- Tests executent sequentiellement (`singleFork: true`) pour eviter conflits DB -- `afterEach` nettoie toutes les tables dans le bon ordre (FK) -- Rate limiting admin desactive en mode test (`NODE_ENV=test`) - -### Frontend -- MSW intercepte les appels API -- `resetAuthState()` a appeler dans `beforeEach` pour isoler les tests -- Utiliser `renderWithUserAuth()` ou `renderWithAdminAuth()` selon le contexte - -### CI/CD -- `test-backend` demarre un service PostgreSQL -- `test-frontend` n'a pas besoin de DB (MSW mock) -- Les builds dependent des tests diff --git a/docs/USER_STORIES.md b/docs/USER_STORIES.md deleted file mode 100644 index d7857d24..00000000 --- a/docs/USER_STORIES.md +++ /dev/null @@ -1,700 +0,0 @@ -# User Stories - Forest Manager - -## Priorites - -- **P0** - Critique (MVP bloquant) -- **P1** - Important (MVP) -- **P2** - Souhaite (post-MVP) -- **P3** - Bonus (futur) - ---- - -## Epic 0: SuperAdmin & Briques (NOUVEAU - Plateforme) - -### US-0.1 Creation compte SuperAdmin via CLI [P0 - Critique] -**En tant que** administrateur systeme -**Je veux** creer un compte SuperAdmin via CLI -**Afin de** securiser la creation de comptes privilegies - -**Criteres d'acceptation:** -- [ ] Commande `npm run admin:create` disponible -- [ ] Prompt pour username, email, password -- [ ] Password hashe avec bcrypt -- [ ] Secret TOTP genere automatiquement -- [ ] Compte cree avec totpEnabled = false -- [ ] Aucune API publique pour creer un admin - ---- - -### US-0.2 Connexion SuperAdmin avec 2FA [P0 - Critique] -**En tant que** SuperAdmin -**Je veux** me connecter avec 2FA TOTP -**Afin de** securiser l'acces administration - -**Premiere connexion:** -- [ ] Saisie username + password -- [ ] Affichage QR code + secret TOTP -- [ ] Scan avec Google Authenticator -- [ ] Verification du token TOTP -- [ ] Activation 2FA (totpEnabled = true) -- [ ] Session admin creee - -**Connexions suivantes:** -- [ ] Saisie username + password + token TOTP -- [ ] Verification des trois elements -- [ ] Session admin creee -- [ ] Cookie admin.sid (isole de connect.sid) - ---- - -### US-0.3 Gestion globale des Tags [P0 - Critique] -**En tant que** SuperAdmin -**Je veux** gerer les tags globalement -**Afin de** maintenir une taxonomie propre - -**Criteres d'acceptation:** -- [ ] Liste de tous les tags avec nombre de recettes -- [ ] Creer un nouveau tag -- [ ] Renommer un tag -- [ ] Supprimer un tag (hard delete) -- [ ] Fusionner deux tags (merge source → target) -- [ ] Actions loguees dans AdminActivityLog - ---- - -### US-0.4 Gestion globale des Ingredients [P0 - Critique] -**En tant que** SuperAdmin -**Je veux** gerer les ingredients globalement -**Afin de** maintenir une base propre - -**Criteres d'acceptation:** -- [ ] Liste de tous les ingredients avec nombre de recettes -- [ ] Creer un nouvel ingredient -- [ ] Renommer un ingredient -- [ ] Supprimer un ingredient -- [ ] Fusionner deux ingredients -- [ ] Actions loguees dans AdminActivityLog - ---- - -### US-0.5 Gestion des communautes (Admin global) [P1 - Important] -**En tant que** SuperAdmin -**Je veux** gerer toutes les communautes -**Afin de** administrer la plateforme - -**Criteres d'acceptation:** -- [ ] Liste de toutes les communautes avec stats -- [ ] Detail complet d'une communaute (membres, features, activite) -- [ ] Renommer une communaute -- [ ] Supprimer une communaute (soft delete avec cascade) -- [ ] Actions loguees dans AdminActivityLog - ---- - -### US-0.6 Gestion des Features (Briques) [P0 - Critique] -**En tant que** SuperAdmin -**Je veux** gerer les features disponibles -**Afin de** controler les fonctionnalites de la plateforme - -**Criteres d'acceptation:** -- [ ] Liste des features avec nombre de communautes -- [ ] Creer une nouvelle feature (code, nom, description) -- [ ] Modifier nom/description d'une feature -- [ ] Feature MVP avec isDefault = true - ---- - -### US-0.7 Attribuer/Revoquer une Feature [P0 - Critique] -**En tant que** SuperAdmin -**Je veux** attribuer ou revoquer une feature a une communaute -**Afin de** controler l'acces aux fonctionnalites - -**Criteres d'acceptation:** -- [ ] Voir les features d'une communaute -- [ ] Voir les features disponibles (non attribuees) -- [ ] Attribuer une feature -- [ ] Revoquer une feature (sauf MVP/default) -- [ ] Actions loguees dans AdminActivityLog - ---- - -### US-0.8 Attribution automatique Feature MVP [P0 - Critique] -**En tant que** utilisateur creant une communaute -**Je veux** que la feature MVP soit automatiquement attribuee -**Afin d'** avoir les fonctionnalites de base - -**Criteres d'acceptation:** -- [ ] A la creation d'une communaute -- [ ] Toutes les features avec isDefault = true sont attribuees -- [ ] grantedById = null (auto-attribue) - ---- - -### US-0.9 Dashboard SuperAdmin [P1 - Important] -**En tant que** SuperAdmin -**Je veux** voir des statistiques globales -**Afin de** monitorer la plateforme - -**Criteres d'acceptation:** -- [ ] Nombre total d'utilisateurs (+ actifs 30j, + nouveaux 7j) -- [ ] Nombre total de communautes (+ actives 30j) -- [ ] Nombre total de recettes (+ nouvelles 7j) -- [ ] Propositions en attente -- [ ] Repartition des features attribuees - ---- - -### US-0.10 Journal d'activite admin [P1 - Important] -**En tant que** SuperAdmin -**Je veux** voir l'historique des actions admin -**Afin de** auditer les operations - -**Criteres d'acceptation:** -- [ ] Liste chronologique des actions admin -- [ ] Filtre par type d'action -- [ ] Filtre par admin (si plusieurs) -- [ ] Detail: qui, quoi, quand, cible - ---- - -## Epic 1: Authentification & Profil - -### US-1.1 Inscription [P0 - Critique] -**En tant que** visiteur -**Je veux** creer un compte -**Afin de** pouvoir utiliser l'application - -**Criteres d'acceptation:** -- [ ] Formulaire avec email, username, password -- [ ] Validation des champs (format email, longueur password) -- [ ] Message d'erreur si email/username deja utilise -- [ ] Redirection vers page d'accueil apres inscription -- [ ] Session automatiquement creee - ---- - -### US-1.2 Connexion [P0 - Critique] -**En tant que** utilisateur inscrit -**Je veux** me connecter a mon compte -**Afin de** acceder a mes recettes et communautes - -**Criteres d'acceptation:** -- [ ] Formulaire avec email et password -- [ ] Message d'erreur si identifiants incorrects -- [ ] Redirection vers dashboard apres connexion -- [ ] Session persistee (cookie) - ---- - -### US-1.3 Deconnexion [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** me deconnecter -**Afin de** securiser mon compte - -**Criteres d'acceptation:** -- [ ] Bouton de deconnexion visible -- [ ] Session detruite -- [ ] Redirection vers page d'accueil - ---- - -### US-1.4 Voir mon profil [P1 - Important] -**En tant que** utilisateur connecte -**Je veux** voir mon profil -**Afin de** consulter mes informations - -**Criteres d'acceptation:** -- [ ] Affichage username, email, date d'inscription -- [ ] Nombre de recettes personnelles -- [ ] Liste des communautes rejointes - ---- - -## Epic 2: Catalogue Personnel - -### US-2.1 Creer une recette personnelle [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** creer une recette dans mon catalogue -**Afin de** sauvegarder mes recettes - -**Criteres d'acceptation:** -- [ ] Formulaire: titre, contenu, tags (multi-select), image (optionnel) -- [ ] Editeur de contenu (markdown ou rich text) -- [ ] Gestion des ingredients (ajout/suppression dynamique) -- [ ] Validation des champs -- [ ] Confirmation de creation - ---- - -### US-2.2 Voir mes recettes personnelles [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** voir la liste de mes recettes -**Afin de** retrouver mes creations - -**Criteres d'acceptation:** -- [ ] Liste paginee de mes recettes -- [ ] Affichage: titre, image, tags, date -- [ ] Filtre par tags -- [ ] Recherche par titre - ---- - -### US-2.3 Modifier ma recette [P1 - Important] -**En tant que** createur d'une recette -**Je veux** modifier ma recette -**Afin de** corriger ou ameliorer - -**Criteres d'acceptation:** -- [ ] Acces au formulaire d'edition -- [ ] Pre-remplissage des champs -- [ ] Sauvegarde des modifications - ---- - -### US-2.4 Supprimer ma recette [P1 - Important] -**En tant que** createur d'une recette -**Je veux** supprimer ma recette -**Afin de** nettoyer mon catalogue - -**Criteres d'acceptation:** -- [ ] Confirmation avant suppression -- [ ] Soft delete (pas de suppression definitive) -- [ ] Message de confirmation - ---- - -## Epic 3: Communautes - -### US-3.1 Creer une communaute [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** creer une communaute -**Afin de** partager des recettes avec un groupe - -**Criteres d'acceptation:** -- [ ] Formulaire: nom, description -- [ ] Je deviens admin automatiquement -- [ ] Redirection vers la page de la communaute - ---- - -### US-3.2 Voir mes communautes [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** voir mes communautes -**Afin de** y acceder rapidement - -**Criteres d'acceptation:** -- [ ] Liste de mes communautes avec mon role -- [ ] Indicateur: nombre de membres, nombre de recettes -- [ ] Acces direct a chaque communaute - ---- - -### US-3.3 Voir une communaute [P0 - Critique] -**En tant que** membre d'une communaute -**Je veux** voir la page de la communaute -**Afin de** consulter son contenu - -**Criteres d'acceptation:** -- [ ] Nom, description -- [ ] Liste des membres (avec roles) -- [ ] Liste des recettes -- [ ] Mon role affiche -- [ ] Acces refuse si non-membre - ---- - -### US-3.4 Quitter une communaute [P1 - Important] -**En tant que** membre d'une communaute -**Je veux** quitter la communaute -**Afin de** ne plus y participer - -**Criteres d'acceptation:** -- [ ] Confirmation avant depart -- [ ] Si dernier admin avec d'autres membres: blocage + message -- [ ] Si seul membre: suppression de la communaute -- [ ] Perte d'acces immediate - ---- - -### US-3.5 Modifier la communaute (Admin) [P1 - Important] -**En tant qu'** admin d'une communaute -**Je veux** modifier nom et description -**Afin de** tenir a jour les informations - -**Criteres d'acceptation:** -- [ ] Formulaire d'edition -- [ ] Seuls les admins peuvent modifier -- [ ] Sauvegarde des modifications - ---- - -## Epic 4: Systeme d'invitation (NOUVEAU) - -### US-4.1 Inviter un utilisateur [P0 - Critique] -**En tant qu'** admin d'une communaute -**Je veux** inviter un utilisateur a rejoindre -**Afin d'** agrandir la communaute - -**Criteres d'acceptation:** -- [ ] Recherche par username ou email -- [ ] Autocomplete des utilisateurs existants -- [ ] Erreur si utilisateur deja membre -- [ ] Erreur si invitation deja en attente -- [ ] Invitation creee avec status PENDING -- [ ] Activite loggee (INVITE_SENT) - ---- - -### US-4.2 Voir mes invitations recues [P0 - Critique] -**En tant que** utilisateur connecte -**Je veux** voir les invitations que j'ai recues -**Afin de** decider si je veux rejoindre ces communautes - -**Criteres d'acceptation:** -- [ ] Liste des invitations PENDING -- [ ] Informations: nom communaute, description, inviteur -- [ ] Boutons accepter/refuser -- [ ] Badge de notification si nouvelles invitations - ---- - -### US-4.3 Accepter une invitation [P0 - Critique] -**En tant que** utilisateur invite -**Je veux** accepter une invitation -**Afin de** rejoindre la communaute - -**Criteres d'acceptation:** -- [ ] Clic sur bouton "Accepter" -- [ ] Je deviens MEMBER de la communaute -- [ ] Invitation marquee ACCEPTED -- [ ] Activite loggee (INVITE_ACCEPTED, USER_JOINED) -- [ ] Redirection vers la communaute - ---- - -### US-4.4 Refuser une invitation [P1 - Important] -**En tant que** utilisateur invite -**Je veux** refuser une invitation -**Afin de** decliner la proposition - -**Criteres d'acceptation:** -- [ ] Clic sur bouton "Refuser" -- [ ] Invitation marquee REJECTED -- [ ] Activite loggee (INVITE_REJECTED) -- [ ] L'invitation disparait de ma liste - ---- - -### US-4.5 Voir les invitations envoyees (Admin) [P1 - Important] -**En tant qu'** admin d'une communaute -**Je veux** voir les invitations en cours -**Afin de** suivre les invitations envoyees - -**Criteres d'acceptation:** -- [ ] Liste des invitations avec status -- [ ] Filtrer par status (PENDING, ACCEPTED, REJECTED, CANCELLED) -- [ ] Informations: invitee, inviter, date - ---- - -### US-4.6 Annuler une invitation (Admin) [P1 - Important] -**En tant qu'** admin d'une communaute -**Je veux** annuler une invitation en attente -**Afin de** retirer une invitation erronee - -**Criteres d'acceptation:** -- [ ] Bouton annuler sur invitations PENDING uniquement -- [ ] Invitation marquee CANCELLED -- [ ] Activite loggee (INVITE_CANCELLED) -- [ ] L'invitation disparait de la liste de l'invite - ---- - -## Epic 5: Gestion des membres - -### US-5.1 Promouvoir un membre (Admin) [P1 - Important] -**En tant qu'** admin d'une communaute -**Je veux** promouvoir un membre en admin -**Afin de** deleguer la gestion - -**Criteres d'acceptation:** -- [ ] Bouton "Promouvoir" sur chaque MEMBER -- [ ] Confirmation avant promotion -- [ ] Le membre devient ADMIN -- [ ] Pas de retrogradation possible -- [ ] Activite loggee (USER_PROMOTED) - ---- - -### US-5.2 Retirer un membre (Admin) [P1 - Important] (NOUVEAU) -**En tant qu'** admin d'une communaute -**Je veux** retirer un membre -**Afin de** gerer la composition du groupe - -**Criteres d'acceptation:** -- [ ] Bouton "Retirer" visible uniquement sur les MEMBER (pas sur les ADMIN) -- [ ] Confirmation avant retrait -- [ ] Le membre perd immediatement l'acces -- [ ] Activite loggee (USER_KICKED) -- [ ] Message clair que les ADMIN ne peuvent pas etre retires - ---- - -## Epic 6: Recettes Communautaires - -### US-6.1 Creer une recette dans une communaute [P0 - Critique] -**En tant que** membre d'une communaute -**Je veux** creer une recette dans la communaute -**Afin de** la partager avec les membres - -**Criteres d'acceptation:** -- [ ] Meme formulaire que recette personnelle -- [ ] Creation automatique d'une copie dans mon catalogue -- [ ] Activite loggee dans le feed (RECIPE_CREATED) -- [ ] La recette apparait dans la liste communaute - ---- - -### US-6.2 Voir les recettes d'une communaute [P0 - Critique] -**En tant que** membre d'une communaute -**Je veux** voir les recettes partagees -**Afin de** decouvrir des idees - -**Criteres d'acceptation:** -- [ ] Liste paginee -- [ ] Filtre par tags -- [ ] Affichage du createur -- [ ] Recherche par titre - ---- - -### US-6.3 Voir les details d'une recette communautaire [P0 - Critique] -**En tant que** membre d'une communaute -**Je veux** voir le detail d'une recette -**Afin de** la cuisiner - -**Criteres d'acceptation:** -- [ ] Titre, contenu, ingredients -- [ ] Createur, date -- [ ] Tags -- [ ] Liste deroulante des variantes (si existantes) -- [ ] Badge si c'est un fork (origine affichee) - ---- - -### US-6.4 Modifier ma recette communautaire [P1 - Important] -**En tant que** createur d'une recette communautaire -**Je veux** modifier ma recette -**Afin de** corriger ou ameliorer - -**Criteres d'acceptation:** -- [ ] Seul le createur peut modifier directement -- [ ] Les autres membres doivent proposer une modification - ---- - -## Epic 7: Propositions & Variantes - -### US-7.1 Proposer une mise a jour [P1 - Important] -**En tant que** membre d'une communaute -**Je veux** proposer une modification sur une recette -**Afin d'** ameliorer la recette - -**Criteres d'acceptation:** -- [ ] Bouton "Proposer une modification" (pas sur mes propres recettes) -- [ ] Formulaire pre-rempli avec contenu actuel -- [ ] Modification du titre et/ou contenu -- [ ] Soumission de la proposition -- [ ] Activite loggee (VARIANT_PROPOSED) - ---- - -### US-7.2 Voir les propositions sur mes recettes [P1 - Important] (UPGRADE de P3) -**En tant que** createur de recettes -**Je veux** voir les propositions recues sur mes recettes -**Afin de** les evaluer et y repondre - -**Criteres d'acceptation:** -- [ ] Feed personnel avec propositions recues -- [ ] Filtrer par status (PENDING, ACCEPTED, REJECTED) -- [ ] Detail de chaque proposition (proposeur, contenu) -- [ ] Actions: Accepter / Refuser -- [ ] Notification visuelle des nouvelles propositions - ---- - -### US-7.3 Accepter une proposition [P1 - Important] -**En tant que** createur d'une recette -**Je veux** accepter une proposition -**Afin de** mettre a jour ma recette - -**Criteres d'acceptation:** -- [ ] Comparaison avant/apres -- [ ] Confirmation avant acceptation -- [ ] Mise a jour de la recette communautaire -- [ ] Mise a jour de la recette personnelle liee -- [ ] Proposition marquee ACCEPTED -- [ ] Activite loggee (PROPOSAL_ACCEPTED) - ---- - -### US-7.4 Refuser une proposition [P1 - Important] -**En tant que** createur d'une recette -**Je veux** refuser une proposition -**Afin de** garder ma version originale - -**Criteres d'acceptation:** -- [ ] Confirmation avant refus -- [ ] Creation automatique d'une variante (nouvelle recette) -- [ ] Variante attribuee au proposeur -- [ ] Variante liee a l'originale (originRecipeId, isVariant=true) -- [ ] Proposition marquee REJECTED -- [ ] Activite loggee (VARIANT_CREATED) - ---- - -### US-7.5 Voir les variantes d'une recette [P1 - Important] -**En tant que** membre d'une communaute -**Je veux** voir les variantes d'une recette -**Afin de** decouvrir des alternatives - -**Criteres d'acceptation:** -- [ ] Liste deroulante sur la page recette -- [ ] Clic → navigation vers la variante -- [ ] Indication du createur de chaque variante -- [ ] Tri par date de creation - ---- - -## Epic 8: Partage Inter-Communautes - -### US-8.1 Fork une recette vers une autre communaute [P2 - Normal] -**En tant que** membre de plusieurs communautes -**Je veux** partager une recette d'une communaute a une autre -**Afin de** diffuser une bonne recette - -**Criteres d'acceptation:** -- [ ] Bouton "Partager dans une autre communaute" -- [ ] Selection de la communaute cible (parmi celles ou je suis membre) -- [ ] Verification: admin source OU admin cible OU createur -- [ ] Creation d'une copie independante -- [ ] Tracabilite: originRecipeId, sharedFromCommunityId -- [ ] Je deviens createur de la copie -- [ ] Activite loggee dans les deux communautes (RECIPE_SHARED) - ---- - -### US-8.2 Voir l'origine d'une recette forkee [P2 - Normal] -**En tant que** membre d'une communaute -**Je veux** voir d'ou vient une recette forkee -**Afin de** connaitre son origine - -**Criteres d'acceptation:** -- [ ] Badge "Partage depuis [Communaute X]" si applicable -- [ ] Lien vers la communaute d'origine (si j'y ai acces) - ---- - -## Epic 9: Activity Feed - -### US-9.1 Voir le feed d'une communaute [P1 - Important] -**En tant que** membre d'une communaute -**Je veux** voir l'activite recente -**Afin de** suivre ce qui se passe - -**Criteres d'acceptation:** -- [ ] Liste chronologique des evenements -- [ ] Types: nouvelle recette, proposition, variante creee, nouveau membre, depart -- [ ] Pagination ou infinite scroll -- [ ] Lien vers l'element concerne - ---- - -### US-9.2 Voir mon feed personnel [P1 - Important] (UPGRADE de P3) -**En tant que** utilisateur connecte -**Je veux** voir l'activite sur mes recettes et mes invitations -**Afin de** etre informe des interactions - -**Criteres d'acceptation:** -- [ ] Propositions recues sur mes recettes -- [ ] Variantes creees a partir de mes recettes -- [ ] Invitations recues -- [ ] Decisions sur mes propositions -- [ ] Pagination - ---- - -## Epic 10: Tags & Recherche - -### US-10.1 Ajouter des tags a une recette [P1 - Important] -**En tant que** createur d'une recette -**Je veux** ajouter des tags -**Afin de** categoriser ma recette - -**Criteres d'acceptation:** -- [ ] Selection parmi tags existants (autocomplete) -- [ ] Creation de nouveau tag a la volee -- [ ] Maximum 10 tags par recette - ---- - -### US-10.2 Filtrer par tags [P1 - Important] -**En tant que** utilisateur -**Je veux** filtrer les recettes par tags -**Afin de** trouver ce que je cherche - -**Criteres d'acceptation:** -- [ ] Liste des tags disponibles (avec compteur) -- [ ] Selection multiple -- [ ] Resultats filtres en temps reel - ---- - -### US-10.3 Rechercher par titre [P1 - Important] -**En tant que** utilisateur -**Je veux** rechercher des recettes par titre -**Afin de** retrouver une recette specifique - -**Criteres d'acceptation:** -- [ ] Champ de recherche avec debounce -- [ ] Resultats filtres en temps reel - ---- - -## Resume des priorites - -| Priorite | Description | User Stories | -|----------|-------------|--------------| -| **P0 - Critique** | Indispensable au MVP | US-0.1 a US-0.4, US-0.6 a US-0.8, US-1.1 a US-1.3, US-2.1 a US-2.2, US-3.1 a US-3.3, US-4.1 a US-4.3, US-6.1 a US-6.3 | -| **P1 - Important** | Important pour l'experience | US-0.5, US-0.9, US-0.10, US-1.4, US-2.3 a US-2.4, US-3.4 a US-3.5, US-4.4 a US-4.6, US-5.1 a US-5.2, US-6.4, US-7.1 a US-7.5, US-9.1 a US-9.2, US-10.1 a US-10.3 | -| **P2 - Normal** | Ajout de valeur | US-8.1 a US-8.2 | -| **P3 - Bonus** | Futur | Analytics visibles, Notifications push, Export recettes, Nouvelles briques | - ---- - -## Changements par rapport a la version precedente - -1. **Epic 0 - SuperAdmin & Briques** - Nouveau (P0) - - Comptes SuperAdmin isoles avec 2FA TOTP - - Creation via CLI uniquement (`npm run admin:create`) - - Gestion globale tags/ingredients/communautes - - Systeme de briques (Features) attribuables aux communautes - - Feature MVP attribuee automatiquement - - Dashboard et audit log admin - -2. **Systeme d'invitation** (Epic 4) - Nouveau - - Invitations avec acceptation explicite - - Workflow complet: envoyer, accepter, refuser, annuler - -3. **Retirer un membre** (US-5.2) - Nouveau - - Admin peut kick un MEMBER (mais pas un ADMIN) - -4. **Feed personnel** (US-9.2) - Upgrade de P3 a P1 - - Inclus dans le MVP pour voir les propositions sur ses recettes - -5. **Reorganisation des epics** - - Epic 0 = SuperAdmin & Briques (nouveau) - - Epic 1-3 = Auth, Catalogue, Communautes - - Epic 4 = Invitations (nouveau) - - Epic 5 = Gestion des membres - - Epic 6-10 = Recettes, Propositions, Partage, Feed, Tags diff --git a/docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md b/docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md new file mode 100644 index 00000000..13d18247 --- /dev/null +++ b/docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md @@ -0,0 +1,411 @@ +# Specification - Audit & Refactorisation Complete + +## Objectif + +Amener le projet a un niveau de qualite production : code propre, DRY, performant, securise, bien teste. Chaque chantier est defini avec son scope, sa justification, et les actions concretes. + +--- + +## 1. Audit Securite + +### 1.1 CSRF Protection + +**Constat :** Aucune protection CSRF en place. L'app utilise des sessions cookie-based (`connect.sid`, `admin.sid`) avec `credentials: include` cote Axios. Un site tiers pourrait forger des requetes POST/PATCH/DELETE avec le cookie de session. + +**Solution :** Double-submit cookie pattern. + +- Le serveur genere un token CSRF et le place dans un cookie (`XSRF-TOKEN`, httpOnly: false) +- Le client lit ce cookie et l'envoie dans un header (`X-XSRF-TOKEN`) a chaque requete mutante +- Le serveur verifie que cookie et header correspondent +- Alternative : library `csrf-csrf` (maintenue, compatible express-session) + +**Scope :** + +- Backend : middleware CSRF sur toutes les routes POST/PATCH/PUT/DELETE +- Frontend : intercepteur Axios pour lire le cookie et ajouter le header +- Exclure : routes publiques (login, signup), health check + +### 1.2 IDOR (Insecure Direct Object Reference) + +**Constat :** La plupart des endpoints verifient l'ownership/membership via `memberOf` middleware ou `requireRecipeAccess`. A auditer systematiquement. + +**Actions :** + +- [ ] Lister tous les endpoints qui prennent un ID en parametre +- [ ] Verifier pour chacun que l'utilisateur a le droit d'acceder/modifier la ressource +- [ ] Points sensibles : PATCH/DELETE sur recipes, communities, proposals, invites, notifications +- [ ] Verifier que les soft-deleted entities ne sont pas accessibles + +### 1.3 Mass Assignment + +**Constat :** Verifier qu'aucun controller ne passe `req.body` directement a Prisma sans filtrage explicite des champs. + +**Actions :** + +- [ ] Auditer chaque `prisma.create()` et `prisma.update()` dans les controllers +- [ ] S'assurer que seuls les champs explicites sont passes dans `data: { ... }` +- [ ] Attention aux champs sensibles : `role`, `deletedAt`, `isVerified`, `totpSecret` + +### 1.4 XSS + +**Constat :** React echappe par defaut le contenu rendu. Verifier les exceptions. + +**Actions :** + +- [ ] Rechercher `dangerouslySetInnerHTML` dans le frontend +- [ ] Verifier que les donnees utilisateur (titres, descriptions, noms) ne sont jamais injectees dans des attributs HTML sans echappement +- [ ] Verifier le CSP Helmet (script-src, style-src) + +### 1.5 Session Security + +**Actions :** + +- [ ] Verifier `req.session.regenerate()` apres login (previent session fixation) +- [ ] Verifier que les cookies ont `secure: true` en production +- [ ] Verifier `sameSite` stricte sur les cookies admin +- [ ] Verifier que logout detruit bien la session (`req.session.destroy()`) + +### 1.6 Upload Security + +**Actions :** + +- [ ] Verifier la validation du type MIME a l'upload (pas juste l'extension) +- [ ] Verifier la taille max par fichier +- [ ] Verifier que les fichiers uploades ne sont pas executables +- [ ] Verifier les presigned URLs : expiration, scope, permissions + +### 1.7 Logging & Secrets + +**Actions :** + +- [ ] Verifier qu'aucun mot de passe, token, ou secret n'apparait dans les logs Pino +- [ ] Verifier que les .env ne sont pas commites (check .gitignore) +- [ ] Verifier que les error responses ne leakent pas de stack traces en production + +--- + +## 2. NPM Audit + +**Actions :** + +- [ ] `cd backend && npm audit` - corriger critical + high +- [ ] `cd frontend && npm audit` - corriger critical + high +- [ ] Documenter les vulnerabilites low/moderate non resolvables (dependances transitives) +- [ ] Mettre a jour les dependances majeures si necessaire (verifier les breaking changes) +- [ ] Ajouter `npm audit` dans le CI (fail on high+) + +--- + +## 3. Lint & Formatage + +### 3.1 ESLint - Regles strictes + +**Etat actuel :** Config minimale (recommended + no-unused-vars). + +**Regles a ajouter :** + +Backend (`eslint.config.mjs`) : + +```javascript +rules: { + "@typescript-eslint/no-explicit-any": "warn", // Puis "error" progressivement + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/prefer-const": "error", + "@typescript-eslint/no-floating-promises": "error", // Requiert parserOptions.project + "no-console": "warn", // Utiliser Pino +} +``` + +Frontend (`eslint.config.mjs`) : + +```javascript +rules: { + "@typescript-eslint/no-explicit-any": "warn", + "react/self-closing-comp": "error", + "react/jsx-no-target-blank": "error", +} +``` + +### 3.2 Prettier + +**Action :** Ajouter Prettier pour le formatage automatique. + +- Config : `.prettierrc` a la racine (tabs vs spaces, trailing commas, etc.) +- Integration ESLint : `eslint-config-prettier` pour desactiver les regles conflictuelles +- Scripts : `format`, `format:check` + +### 3.3 Pre-commit hooks + +**Action :** Installer Husky + lint-staged. + +- Pre-commit : `lint-staged` execute ESLint + Prettier sur les fichiers stages +- Config dans `package.json` ou `.lintstagedrc` +- Garantit qu'aucun code non conforme n'est commite + +### 3.4 CI Integration + +- [ ] Verifier que GitHub Actions execute `npm run lint` et `npm run format:check` +- [ ] Faire echouer le build si lint ou format non conforme + +--- + +## 4. DRY Backend + +### 4.1 Error Codes centralises + +**Actuellement :** Codes erreur en strings dans les controllers (`"AUTH_001: ..."`, `"RECIPE_003: ..."`). + +**Solution :** Fichier `constants/errorCodes.ts` : + +```typescript +export const ERROR_CODES = { + AUTH_001: "AUTH_001: You are not authenticated", + AUTH_002: "AUTH_002: Invalid credentials", + // ... +} as const; +``` + +- Autocompletion dans les controllers +- Impossible de faire une typo +- Source unique de verite pour les messages + +### 4.2 Rate Limiter Factory + +**Actuellement :** 3 definitions quasi identiques. + +**Solution :** `config/rateLimiter.ts` : + +```typescript +export function createRateLimiter(windowMs: number, max: number, message: string) { + return rateLimit({ windowMs, max, message, standardHeaders: true, legacyHeaders: false }); +} +``` + +### 4.3 Middleware validateBody + +**Solution :** Si Zod adopte : + +```typescript +export function validateBody(schema: ZodSchema): RequestHandler { + return (req, res, next) => { + const result = schema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ error: formatZodError(result.error) }); + } + req.body = result.data; + next(); + }; +} +``` + +Permet de declarer la validation au niveau de la route : + +```typescript +router.post("/", validateBody(createRecipeSchema), RecipesController.createRecipe); +``` + +### 4.4 Extraction config session + +Extraire la configuration des deux sessions (user + admin) de `app.ts` vers `config/session.ts`. + +--- + +## 5. DRY Frontend + +### 5.1 SearchSelector generique + +**Actuellement :** `TagSelector` et `IngredientSelector` partagent ~80% de logique (recherche debounced, dropdown, multi-select, keyboard, badges). + +**Solution :** Composant `SearchSelector` parametrable : + +```tsx + + searchFn={(q) => APIManager.searchTags(q)} + renderItem={(tag) => tag.name} + getKey={(tag) => tag.id} + allowCreate={true} + onCreateNew={(name) => ...} + selected={selectedTags} + onChange={setSelectedTags} +/> +``` + +### 5.2 useAsyncData hook + +**Pattern repete dans chaque page :** + +```typescript +const [data, setData] = useState(null); +const [loading, setLoading] = useState(true); +const [error, setError] = useState(null); +useEffect(() => { + fetchData() + .then(setData) + .catch(setError) + .finally(() => setLoading(false)); +}, []); +``` + +**Solution :** + +```typescript +const { data, loading, error, refetch } = useAsyncData(() => APIManager.getRecipe(id), [id]); +``` + +### 5.3 useImageUpload hook + +**Logique dupliquee :** get presigned URL → upload to MinIO → confirm upload. + +**Solution :** + +```typescript +const { upload, uploading, imageUrl, error } = useImageUpload("recipe", recipeId); +// upload(file) gere tout le flow +``` + +### 5.4 DataContainer composant + +**Pattern repete :** loading spinner → error alert → empty state → contenu. + +**Solution :** + +```tsx + + {data.map((recipe) => ( + + ))} + +``` + +### 5.5 AdminListPage generique + +**3+ pages admin avec le meme pattern :** fetch pagine, table, modal create/edit, search, delete. + +**Solution :** Composant configurable ou hook `useAdminCrud()` qui gere le state CRUD complet. + +--- + +## 6. Clean Code + +### 6.1 Fichiers longs (>300 lignes) + +- [ ] Auditer et decouper les fichiers les plus longs +- [ ] Extraire les sous-composants React dans des fichiers separes +- [ ] Extraire les helpers de controllers dans des services + +### 6.2 Code mort + +- [ ] Scanner avec `ts-prune` ou ESLint `no-unused-vars` strict +- [ ] Supprimer les imports inutilises +- [ ] Supprimer les fonctions non appelees +- [ ] Supprimer le code commente + +### 6.3 Coherence des patterns + +- [ ] Verifier que tous les controllers suivent le meme pattern (try/catch → next) +- [ ] Verifier que toutes les reponses suivent le format `{ data: ... }` ou `{ data: ..., pagination: ... }` +- [ ] Verifier la coherence des status codes (201 pour create, 200 pour update, 204 pour delete) + +--- + +## 7. Tests + +### 7.1 Couverture + +- [ ] Mesurer la couverture actuelle (`npm run test -- --coverage`) +- [ ] Definir un seuil minimum : 80% statements, 70% branches +- [ ] Ajouter la verification de couverture dans le CI + +### 7.2 Tests manquants + +- [ ] Identifier les controllers/services sans tests d'integration +- [ ] Prioriser les flux critiques : auth, recipe CRUD, community membership, sharing +- [ ] Ajouter des tests pour les cas limites (permissions, soft delete, concurrence) + +### 7.3 Tests E2E + +- [ ] Evaluer Playwright pour les flux critiques +- [ ] Flux minimum : signup → login → create community → invite → create recipe → share +- [ ] Integrer dans le CI (sur un environment de test docker) + +### 7.4 Qualite des tests existants + +- [ ] Verifier que les tests sont independants (pas d'ordre d'execution requis) +- [ ] Verifier que les tests nettoient bien leurs donnees (afterEach) +- [ ] Verifier qu'il n'y a pas de tests flaky + +--- + +## 8. Performances + +### 8.1 Backend + +- [ ] Activer les logs de requetes Prisma lentes en dev +- [ ] `EXPLAIN ANALYZE` sur les requetes frequentes (getRecipes, getCommunityRecipes, searchTags) +- [ ] Verifier les index manquants +- [ ] Evaluer un cache Redis pour les donnees quasi-statiques (unites, tags globaux) +- [ ] Verifier les payloads JSON (pas de champs inutiles retournes) + +### 8.2 Frontend + +- [ ] Analyse du bundle (`npx vite-bundle-visualizer`) +- [ ] `React.lazy()` pour les pages admin, les modales lourdes (ImportRecipeModal, ShareModal) +- [ ] Profiler les re-renders avec React DevTools +- [ ] Verifier le lazy loading des images +- [ ] Verifier que les listes longues sont paginées cote UI aussi + +### 8.3 Infrastructure + +- [ ] Verifier la config Docker (multi-stage build, layer caching) +- [ ] Verifier les health checks +- [ ] Evaluer la compression gzip/brotli sur les reponses API + +--- + +## 9. App.ts & App.tsx - Lisibilite + +### 9.1 app.ts (backend) + +- [ ] Extraire la config sessions dans `config/session.ts` +- [ ] Extraire le error handler dans `middleware/errorHandler.ts` +- [ ] Regrouper les middlewares de securite (helmet, cors, https) dans `middleware/security.ts` +- [ ] Objectif : `app.ts` ne fait que composer les middlewares et monter les routes, ~50 lignes max + +### 9.2 App.tsx (frontend) + +- [ ] Extraire les definitions de routes dans `routes/userRoutes.tsx` et `routes/adminRoutes.tsx` +- [ ] Deplacer `NotificationHandler` dans `MainLayout` +- [ ] Objectif : `App.tsx` ne fait que le provider stack + le router switch, ~40 lignes max + +--- + +## 10. Zod (Migration Progressive) + +### 10.1 Strategie de migration + +- **Phase 1 :** Installer Zod, creer le middleware `validateBody`, migrer 2-3 endpoints simples (auth signup/login, user profile update) +- **Phase 2 :** Migrer les schemas recipe (create, update) - plus complexes avec ingredients/steps/tags +- **Phase 3 :** Migrer les schemas community, invite, proposal +- **Phase 4 :** Migrer les schemas admin +- **Phase 5 :** Supprimer les anciennes assertions devenues inutiles + +### 10.2 Organisation des schemas + +``` +backend/src/schemas/ + auth.schema.ts # signupSchema, loginSchema + recipe.schema.ts # createRecipeSchema, updateRecipeSchema + community.schema.ts # createCommunitySchema, updateCommunitySchema + proposal.schema.ts # createProposalSchema + invite.schema.ts # sendInviteSchema + admin.schema.ts # admin-specific schemas + common.schema.ts # schemas partages (pagination, uuid, etc.) +``` + +### 10.3 Coexistence + +Pendant la migration, les deux systemes coexistent : + +- Nouveaux endpoints : Zod + `validateBody` middleware +- Anciens endpoints : assertions existantes, migres progressivement +- `validation.ts` conserve les constantes (longueurs, regex) utilisables dans les schemas Zod diff --git a/docs/features/e2e-testing/ROADMAP.md b/docs/features/e2e-testing/ROADMAP.md new file mode 100644 index 00000000..f989da41 --- /dev/null +++ b/docs/features/e2e-testing/ROADMAP.md @@ -0,0 +1,115 @@ +# Roadmap : Tests E2E (Playwright) + +> **Spec** : `docs/features/e2e-testing/SPEC_E2E_TESTING.md` + +--- + +## Phase A — Setup Playwright + +### A.1 - Installation et config + +- [ ] Installer Playwright (`npm init playwright@latest` dans un dossier `e2e/`) +- [ ] Creer `playwright.config.ts` (baseURL localhost:3000, timeout 30s, retries 1 en CI) +- [ ] Ajouter scripts npm racine : `test:e2e`, `test:e2e:ui`, `test:e2e:debug` +- [ ] Ajouter `e2e/` au `.prettierrc` et ESLint config +- [ ] Verifier que `npx playwright test` tourne (test placeholder) + +### A.2 - Fixtures et Page Objects + +- [ ] Creer `e2e/fixtures/auth.fixture.ts` (login + storageState) +- [ ] Creer Page Objects de base : + - `LoginPage.ts` (email, password, submit) + - `SignUpPage.ts` (username, email, password, submit) +- [ ] Ajouter `data-testid` sur les elements critiques du frontend (boutons submit, inputs, liens nav) + +### A.3 - Global setup/teardown + +- [ ] Creer `e2e/global-setup.ts` (verifier que Docker est up, seed DB test) +- [ ] Creer `e2e/global-teardown.ts` (cleanup) +- [ ] Seed E2E minimal et deterministe (users de test, 1 communaute, 1 recette) + +--- + +## Phase B — Flux Auth + Recettes + +### B.1 - Tests auth + +- [ ] `auth.spec.ts` : signup → redirect dashboard +- [ ] Login → session → refresh page → toujours connecte +- [ ] Logout → redirect home +- [ ] Acces page protegee sans auth → redirect login + +### B.2 - Tests recettes + +- [ ] Page Object `RecipeFormPage.ts`, `RecipeDetailPage.ts`, `RecipesPage.ts` +- [ ] `recipes.spec.ts` : creer recette (titre, 2 etapes, 1 ingredient) → voir dans la liste +- [ ] Voir detail recette → verifier contenu +- [ ] Editer recette (modifier titre) → verifier mise a jour +- [ ] Supprimer recette → disparait de la liste + +--- + +## Phase C — Flux Communautes + Partage + +### C.1 - Tests communautes + +- [ ] Page Object `CommunityPage.ts`, `CommunityCreatePage.ts` +- [ ] `communities.spec.ts` : creer communaute → visible dans la liste +- [ ] Inviter un membre (2e user) → accepter → membre visible + +### C.2 - Tests partage + +- [ ] `sharing.spec.ts` : publier recette perso vers communaute → visible dans recettes communaute +- [ ] Forker recette vers autre communaute + +### C.3 - Tests propositions + +- [ ] `proposals.spec.ts` : membre propose modification → owner voit la proposition +- [ ] Owner accepte → recette mise a jour +- [ ] Owner rejette → variante creee + +--- + +## Phase D — Flux Import + Upload + +### D.1 - Tests import + +- [ ] `import.spec.ts` : ouvrir modale import → coller texte brut → analyser → formulaire pre-rempli +- [ ] Sauvegarder la recette importee → verifier en detail + +### D.2 - Tests upload + +- [ ] `upload.spec.ts` : uploader image sur recette → image affichee +- [ ] Remplacer image → nouvelle image affichee +- [ ] Supprimer image → placeholder affiche + +--- + +## Phase E — CI Integration + +### E.1 - GitHub Actions + +- [ ] Ajouter job `e2e` dans `.github/workflows/deploy.yml` +- [ ] Docker Compose up complet (backend, frontend, postgres, minio) +- [ ] Wait-on health checks (backend /health, frontend /) +- [ ] `npx playwright test` headless +- [ ] Upload artifacts (traces, screenshots) en cas d'echec + +### E.2 - Optimisation + +- [ ] Evaluer sharding (2 workers) si temps > 3 min +- [ ] Evaluer si le job doit etre bloquant ou informatif (allow-failure) + +--- + +## Resume + +| Phase | Scope | Dependances | +| ----- | --------------------- | ----------- | +| **A** | Setup Playwright | Aucune | +| **B** | Auth + Recettes | A | +| **C** | Communautes + Partage | B | +| **D** | Import + Upload | B | +| **E** | CI | B+C+D | + +Phases C et D sont independantes et peuvent etre developpees en parallele. diff --git a/docs/features/e2e-testing/SPEC_E2E_TESTING.md b/docs/features/e2e-testing/SPEC_E2E_TESTING.md new file mode 100644 index 00000000..fc5e2d1f --- /dev/null +++ b/docs/features/e2e-testing/SPEC_E2E_TESTING.md @@ -0,0 +1,100 @@ +# Spec : Tests End-to-End (E2E) + +## Contexte + +L'app dispose de 1271 tests (802 backend integration/unit + 469 frontend unit) mais aucun test E2E qui valide les flux utilisateur complets a travers le navigateur. Les tests actuels mockent soit le backend (MSW cote frontend) soit le frontend (Supertest cote backend). Un bug d'integration entre les deux couches peut passer inapercu. + +## Objectifs + +- Valider les flux critiques utilisateur de bout en bout (navigateur reel → API → DB) +- Detecter les regressions d'integration (routing, sessions, CSRF, uploads) +- Executer en CI sans flakiness + +## Choix technique : Playwright + +**Pourquoi Playwright plutot que Cypress :** + +- Support natif multi-navigateurs (Chromium, Firefox, WebKit) +- Architecture headless performante (pas de serveur Electron) +- API `expect` native avec auto-wait (moins de flakiness) +- Parallelisme natif (workers) +- Trace viewer integre pour debug CI +- Meilleur support Docker (images officielles `mcr.microsoft.com/playwright`) + +## Perimetre + +### Flux critiques a couvrir + +1. **Auth** : signup → login → session persistante → logout +2. **Recettes** : creer recette (titre, etapes, ingredients, tags) → voir detail → editer → supprimer +3. **Communautes** : creer communaute → inviter membre → accepter invitation → voir communaute +4. **Partage** : publier recette perso vers communaute → voir dans la communaute +5. **Propositions** : proposer modification → owner accepte/rejette +6. **Import** : importer recette via texte brut → formulaire pre-rempli → sauvegarder +7. **Upload** : uploader image recette → affichage → remplacement → suppression + +### Hors perimetre (premiere iteration) + +- Admin (2FA TOTP rend l'automatisation complexe) +- Notifications temps reel (WebSocket testing specifique) +- Multi-navigateurs (Chromium uniquement en premiere passe) + +## Architecture + +``` +e2e/ +├── playwright.config.ts # Config (baseURL, timeout, retries, workers) +├── global-setup.ts # Seed DB test, demarrer containers si besoin +├── global-teardown.ts # Cleanup +├── fixtures/ +│ └── auth.fixture.ts # Login fixture reutilisable (storageState) +├── pages/ # Page Object Model +│ ├── LoginPage.ts +│ ├── RecipesPage.ts +│ ├── RecipeFormPage.ts +│ ├── CommunityPage.ts +│ └── ... +└── tests/ + ├── auth.spec.ts + ├── recipes.spec.ts + ├── communities.spec.ts + ├── sharing.spec.ts + ├── proposals.spec.ts + ├── import.spec.ts + └── upload.spec.ts +``` + +### Patterns + +- **Page Object Model** : encapsuler les selecteurs et actions par page +- **Auth fixture** : login une fois, sauvegarder `storageState`, reutiliser dans tous les tests +- **Cleanup** : chaque test cree ses propres donnees, cleanup en afterEach +- **Selecteurs** : privilegier `data-testid` pour la stabilite + +## Environnement d'execution + +### Local + +```bash +npm run test:e2e # Lancer les tests E2E +npm run test:e2e:ui # Mode interactif Playwright +npm run test:e2e:debug # Debug mode (headed + inspector) +``` + +Prerequis : `docker compose up` (backend + frontend + DB + MinIO operationnels) + +### CI (GitHub Actions) + +- Job separe `e2e` apres les jobs `test-backend` et `test-frontend` +- Docker Compose up complet (backend, frontend, postgres, minio) +- Wait-on pour health checks +- Playwright en mode headless +- Artifacts : traces + screenshots en cas d'echec +- Optionnel : sharding sur 2 workers pour reduire le temps + +## Contraintes + +- Les tests E2E sont lents (~2-5 min total) → job CI separe, pas bloquant en premiere iteration +- Pas de dependance a des services externes (tout en local/Docker) +- Le seed E2E est distinct du seed dev (donnees minimales, deterministes) +- MinIO doit etre operationnel pour les tests upload 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..26c6b814 --- /dev/null +++ b/docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md @@ -0,0 +1,625 @@ +# 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) +``` diff --git a/docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md b/docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md new file mode 100644 index 00000000..b803ffe7 --- /dev/null +++ b/docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md @@ -0,0 +1,139 @@ +# Spec : Securisation des entrees (Input Validation & Injection Protection) + +> **Contexte** : Audit de securite realise le 2026-03-04. Aucune faille d'injection SQL +> detectee (Prisma parametre tout). Les problemes identifies concernent principalement +> l'absence de validation/sanitisation des entrees utilisateur au niveau backend et +> l'absence de headers de securite sur le frontend servi par nginx. + +--- + +## 1. Principes directeurs + +1. **Defense en profondeur** : valider cote frontend ET backend, ne jamais faire confiance au client +2. **Fail-fast avec 400** : rejeter les entrees invalides le plus tot possible avec un code 400 clair +3. **Centraliser** : un middleware/utilitaire commun plutot que des checks eparpilles dans chaque controller +4. **Ne pas casser l'existant** : les validations ajoutees doivent etre compatibles avec les donnees deja en base + +--- + +## 2. Inventaire des failles par severite + +### CRITIQUE + +| ID | Description | Fichier(s) | +| --- | ---------------------------------------------------------------- | ------------------------------------------------ | +| C1 | Admin recipe update : zero validation sur title, servings, times | `admin/controllers/recipesController.ts:107-111` | + +### HIGH + +| ID | Description | Fichier(s) | +| --- | --------------------------------------------------------------------- | ------------------------------------------------------------- | +| H1 | Pas de `typeof === 'string'` sur password avant `.length` et `bcrypt` | `auth.ts:80`, `users.ts:83-93` | +| H2 | Tableaux de filtres `?tags=...&ingredients=...` illimites → DoS query | `recipes.ts:26`, `communityRecipes.ts:147` | +| H3 | `ingredients[].quantity` accepte negatif, Infinity, NaN | `recipes.ts:210`, `communityRecipes.ts:16`, `proposals.ts:21` | +| H4 | Frontend SPA servie par nginx sans CSP ni headers securite | `frontend/Dockerfile` (nginx config) | + +### MEDIUM + +| ID | Description | Fichier(s) | +| --- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | +| M1 | Pas de maxLength sur recipe title (backend + frontend) | `recipes.ts:233`, `RecipeFormPage.tsx:176` | +| M2 | Pas de type-check array sur tags/ingredients body | `recipes.ts:227`, `communityRecipes.ts:38` | +| M3 | MAX_TAGS_PER_RECIPE non applique a la creation/update | `recipes.ts` | +| M4 | Pas de validation email dans invite | `invites.ts:36` | +| M5 | Pas de validation UUID format sur les route params → 500 au lieu de 400 | Tous les controllers | +| M6 | imageUrl sans allowlist de scheme (accepte data:, javascript:) | `validation.ts:30`, `RecipeFormPage.tsx` | +| M7 | ProfilePage : pas de validation format username/email | `ProfilePage.tsx:101-126` | +| M8 | Password sans maxLength → CPU exhaustion bcrypt | `auth.ts:80` | +| M9 | Admin name/reason fields sans maxLength | `admin/controllers/unitsController.ts`, `ingredientsController.ts` | + +### LOW + +| ID | Description | Fichier(s) | +| --- | ---------------------------------------------------- | ------------------------ | +| L1 | Email regex faible (accepte `a@b.c`) | `validation.ts:5` | +| L2 | Username sans maxLength | `validation.ts:7` | +| L3 | Status query params non encodes dans api.ts frontend | `api.ts:166,230,427,446` | +| L4 | express.json() sans limit explicite | `app.ts:52` | +| L5 | styleSrc unsafe-inline dans CSP backend | `security.ts` | + +--- + +## 3. Strategie de fix + +### 3.1 Middleware de validation UUID (fix M5) + +Creer `backend/src/middleware/validateUUID.ts` : + +- Fonction middleware qui valide tout `req.params` matching un pattern `*Id` ou `id` +- Regex : `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i` +- Retourne 400 avec message clair si invalide +- Applique sur toutes les routes avec params UUID + +### 3.2 Fonctions de validation centralisees (fix H1, M1, M2, M8, M9, L2) + +Enrichir `backend/src/util/validation.ts` avec : + +```typescript +// Type guards +function assertString(value: unknown, field: string): asserts value is string; +function assertArray(value: unknown, field: string): asserts value is unknown[]; +function assertNumber(value: unknown, field: string): asserts value is number; + +// Constantes maxLength +MAX_USERNAME_LENGTH = 30; +MAX_PASSWORD_LENGTH = 128; +MAX_TITLE_LENGTH = 200; +MAX_NAME_LENGTH = 100; +MAX_REASON_LENGTH = 500; +MAX_URL_LENGTH = 2048; +MAX_FILTER_ITEMS = 20; + +// Validation quantity +function validateQuantity(val: unknown): number | null; +// → doit etre null, ou number > 0, <= 99999, Number.isFinite() +``` + +### 3.3 Validation imageUrl scheme (fix M6) + +Dans `isValidHttpUrl()` : renforcer pour n'accepter que `https://` en prod (+ `http://` en dev). +Rejeter explicitement `data:`, `javascript:`, `ftp:`, etc. + +### 3.4 Application des validations dans les controllers + +Chaque controller sera mis a jour pour utiliser les fonctions centralisees. +Pas de changement de logique metier, uniquement ajout de guards en debut de handler. + +### 3.5 Headers securite nginx (fix H4) + +Ajouter dans la config nginx du frontend Dockerfile : + +```nginx +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none';" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +``` + +### 3.6 Frontend : validation formulaires (fix M7) + +Aligner ProfilePage avec SignUpPage : memes regex username/email. +Ajouter maxLength sur les champs titre recette, etc. + +### 3.7 Express body limit (fix L4) + +```typescript +app.use(express.json({ limit: "50kb" })); +``` + +### 3.8 Frontend api.ts : encoder les query params (fix L3) + +Utiliser `buildQueryString` ou `encodeURIComponent` partout. + +--- + +## 4. Ce qui ne sera PAS change + +- **Email regex (L1)** : la regex actuelle est suffisante pour l'usage. Une validation stricte RFC 5322 apporterait plus de faux positifs que de securite. +- **styleSrc unsafe-inline (L5)** : necessaire pour Tailwind/DaisyUI, pas de contournement simple. +- **Migration vers Zod** : hors scope de cette phase. Les validations manuelles centralisees suffisent. A envisager dans une future phase de refactoring. diff --git a/docs/features/mobile-rework/ROADMAP.md b/docs/features/mobile-rework/ROADMAP.md new file mode 100644 index 00000000..0d171495 --- /dev/null +++ b/docs/features/mobile-rework/ROADMAP.md @@ -0,0 +1,278 @@ +# Roadmap : Rework Mobile Complet + +> **Spec** : `docs/features/mobile-rework/SPEC_MOBILE_REWORK.md` +> **Branche** : `MobileFrontUpdate` + +--- + +## Phase 1 — Fondations + +Creer l'infrastructure mobile sans casser le desktop. + +### 1.1 - Hooks + +- [x] Creer `frontend/src/hooks/useIsMobile.ts` + - `useIsMobile(): boolean` base sur `window.matchMedia('(max-width: 767px)')` + - Ecoute `change` event pour reagir au resize + - Memo via state pour eviter les re-renders inutiles +- [x] Creer `frontend/src/hooks/useKeyboardVisible.ts` + - Detection via `visualViewport` API + - `useKeyboardVisible(): boolean` + - Seuil : `vv.height < window.innerHeight - 150` + - Fallback gracieux si `visualViewport` non supporte + +### 1.2 - BottomTabBar + +- [x] Creer `frontend/src/components/mobile/BottomTabBar.tsx` + - 4 onglets : Home (`/dashboard`), Recipes (`/recipes`), Notifs (`/notifications`), Profile (`/profile`) + - Hauteur 56px + `env(safe-area-inset-bottom)` + - Badge compteur non-lus sur l'onglet Notifs + - Highlight onglet actif via `useLocation()` + - Masquage automatique quand clavier ouvert (`useKeyboardVisible`) + - Rendu uniquement si `useIsMobile() && user authentifie` + +### 1.3 - BottomSheet + +- [x] Creer `frontend/src/components/mobile/BottomSheet.tsx` + - Props : `isOpen`, `onClose`, `title?`, `children`, `maxHeight?` + - Overlay `bg-black/50`, tap pour fermer + - Animation slide-up 250ms `ease-out`, slide-down 200ms `ease-in` + - Poignee de swipe visuelle (32x4px, `bg-base-300`, `rounded-full`) + - Scroll interne avec `overscroll-behavior: contain` + - Max height : 85vh par defaut (configurable) + - `z-50`, `padding-bottom: env(safe-area-inset-bottom)` + - Focus trap et fermeture via Escape + +### 1.4 - ActionSheet + +- [x] Creer `frontend/src/components/mobile/ActionSheet.tsx` + - Basé sur `BottomSheet`, style specifique listes d'actions + - Props : `items: ActionItem[]` (label, icon, onClick, destructive?) + - Items 56px de haut, icone a gauche, label + - Items destructifs en `text-error` + - Bouton "Annuler" en bas, separe visuellement + +### 1.5 - CSS et meta + +- [x] `index.html` : ajouter `viewport-fit=cover` au meta viewport +- [x] `styles/global.css` : ajouter variables safe-area (`--safe-area-top`, `--safe-area-bottom`, etc.) +- [x] `styles/global.css` : ajouter `@media (prefers-reduced-motion: reduce)` (durees a 0.01ms) + +### 1.6 - Migration breakpoints + +- [x] `MainLayout.tsx` : remplacer `pointer-fine:drawer-open` par `md:drawer-open` +- [x] `Sidebar.tsx` : remplacer `pointer-fine:`/`pointer-coarse:` par `md:`/breakpoints standard + +### 1.7 - Tests Phase 1 + +- [x] Tests unitaires `useIsMobile` (4 tests) +- [x] Tests unitaires `useKeyboardVisible` (5 tests) +- [x] Tests `BottomSheet` (9 tests) +- [x] Tests `ActionSheet` (7 tests) +- [x] Tests `BottomTabBar` (8 tests) + +--- + +## Phase 2 — Navigation + +Restructurer la navigation mobile. + +### 2.1 - App.tsx + +- [x] Conditionner `NavBar` : hidden quand user connecte sur mobile +- [x] Ajouter `BottomTabBar` quand `useIsMobile() && user` +- [x] Position toasts : `bottom-center` mobile (bottom: 72px), `top-right` desktop + +### 2.2 - MainLayout + +- [x] Appliquer `md:drawer-open` (fait en Phase 1.6) +- [x] Ajouter `padding-bottom: calc(56px + var(--safe-area-bottom))` sur le contenu mobile +- [x] Supprimer la barre hamburger mobile (remplacee par BottomTabBar) + +### 2.3 - Sidebar + +- [x] Touch targets 44px min-h sur les items de navigation +- [x] Breakpoints `md:flex`/`md:hidden` (fait en Phase 1.6) +- [x] Ajouter theme toggle (sun/moon) dans le footer du drawer sidebar + +### 2.4 - Dropdowns -> navigation + +- [x] `NotificationDropdown.tsx` : sur mobile, `navigate('/notifications')` au lieu d'ouvrir le dropdown +- [x] `NavBarLoggedInView.tsx` : non rendu sur mobile (NavBar masquee dans App.tsx) + +### 2.5 - ProfilePage hub mobile + +- [x] Ajouter lien vers Invitations (`/invitations`) visible uniquement sur mobile +- [x] Ajouter theme toggle visible uniquement sur mobile +- [x] Ajouter bouton Logout visible uniquement sur mobile + +### 2.6 - Tests Phase 2 + +- [x] Test NavBar masquee sur mobile (via App.tsx conditional rendering) +- [x] Test BottomTabBar visible sur mobile connecte (Phase 1 tests) +- [x] Test NotificationDropdown navigate sur mobile (architecture) +- [x] Test ProfilePage liens supplementaires (3 tests: invitations, theme, logout) + +--- + +## Phase 3 — Corrections de layout + +Fixer les debordements et les layouts casses. + +### 3.1 - RecipeDetailPage + +- [x] Action buttons (6) : remplacer par un bouton "..." ouvrant un `ActionSheet` sur mobile +- [x] Image hero : hauteur `h-48` au lieu de `h-64` sur mobile +- [x] Layout general : adaptation responsive existante a verifier + +### 3.2 - CommunityDetailPage + +- [x] SidePanel : `BottomSheet` sur mobile au lieu du panneau lateral +- [x] Boutons header : labels visibles au lieu de tooltips sur mobile (touch targets) +- [x] Suppression du resize handle sur mobile (inutile en BottomSheet) + +### 3.3 - Filtres recettes + +- [x] `RecipeFilters.tsx` : supprimer `min-w-[200px]` sur mobile +- [x] Layout vertical (un filtre par ligne) sur mobile +- [x] Filtres repliables : bouton "Filtres" avec badge nombre actifs, collapse/expand slide-down + +### 3.4 - Listes et grilles + +- [x] `MembersList.tsx` : cartes au lieu de table sur mobile +- [x] `RecipesPageLoggedInView.tsx` : masquer toggle card/list sur mobile, forcer card view +- [x] `CommunityRecipesList.tsx` : idem, forcer card view mobile + +### 3.5 - Pages formulaires + +- [x] `RecipeFormPage.tsx` : stacker titre + bouton import verticalement sur mobile +- [x] `NotificationsPage.tsx` : layout filtres adapte mobile (vertical, toggle en dessous) + +### 3.6 - Etats vides + +- [x] CTA dans la thumb zone (centre-bas), boutons 48px (`btn-lg`), messages courts +- [x] Concerne : `DataContainer.tsx`, `DashboardPage.tsx`, `RecipesPageLoggedInView.tsx`, `CommunityRecipesList.tsx` + +### 3.7 - Tests Phase 3 + +- [x] Tests visuels RecipeDetailPage (ActionSheet au lieu de boutons en ligne) +- [x] Tests MembersList (cards vs table selon viewport) +- [x] Tests filtres repliables (collapse/expand, badge compteur) + +--- + +## Phase 4 — Formulaires tactiles + +Optimiser les composants de saisie pour le tactile. + +### 4.1 - IngredientList + +- [x] Layout stacke sur mobile : quantite + unite sur 1 ligne, ingredient sur la ligne suivante +- [x] Touch targets 44px sur boutons supprimer/ajouter + +### 4.2 - StepEditor + +- [x] Layout mobile : boutons (supprimer, deplacer) sous le textarea au lieu de sur le cote +- [x] Touch targets 44px sur boutons drag handle, supprimer +- [x] Verification drag-and-drop tactile (@dnd-kit touch sensors) + +### 4.3 - SearchSelector + +- [x] Touch targets 44px sur les items du dropdown de resultats +- [x] Padding augmente sur les chips (faciliter la suppression) + +### 4.4 - RecipeCard + +- [x] Touch targets 44px sur les boutons d'action (edit, delete, share) +- [x] Espacement suffisant entre les boutons + +### 4.5 - Modales complexes + +- [x] `ProposeModificationModal.tsx` : plein ecran sur mobile (`inset-0` au lieu de modal centree) +- [x] Gestion back button via `history.pushState` si necessaire + +### 4.6 - Upload images + +- [x] `ImageUpload.tsx` / `ImagePicker.tsx` : texte adapte au tactile ("Appuyez pour ajouter" au lieu de "Glissez-deposez") + +### 4.7 - Tests Phase 4 + +- [x] Tests IngredientList layout mobile (stacked) +- [x] Tests StepEditor layout mobile (boutons en dessous) +- [x] Tests ProposeModificationModal plein ecran mobile + +--- + +## Phase 5 — Polish et QA + +### 5.1 - Audit touch targets + +- [x] Audit systematique 44px minimum sur tous les elements interactifs +- [x] Corriger les elements detectes en dessous du seuil + +### 5.2 - Scroll et spacing + +- [x] Verifier scroll derriere la bottom tab bar (padding-bottom adequat) +- [x] Verifier que les derniers elements des listes sont visibles au-dessus de la tab bar +- [x] Verifier comportement clavier virtuel (input focus, scroll, tab bar masquee) + +### 5.3 - Tests multi-devices + +- [x] iPhone SE (375px) — plus petit ecran supporte +- [x] iPhone 14 (390px) — taille standard +- [x] iPhone 14 Pro Max (430px) — grand ecran +- [x] Samsung Galaxy (360px) — Android petit +- [x] Pixel (412px) — Android standard +- [x] Mode paysage sur chaque device + +### 5.4 - Tests themes et accessibilite + +- [x] Theme coffee sur mobile — contraste, lisibilite +- [x] Theme winter sur mobile — contraste, lisibilite +- [x] `prefers-reduced-motion` : verifier que toutes les animations sont desactivees + +### 5.5 - Performance + +- [x] Verifier que les re-renders lies a `useIsMobile()` sont minimaux +- [x] Profiler la bottom tab bar (pas de re-render a chaque scroll) +- [x] Verifier le poids des nouveaux composants sur le bundle + +### 5.6 - Mise a jour documentation + +- [x] Mettre a jour `.claude/context/FILE_MAP.md` (nouveaux fichiers) +- [x] Mettre a jour `.claude/context/PROGRESS.md` +- [x] Cocher la feature dans `docs/0 - brainstorming futur.md` + +--- + +## Resume + +| Phase | Scope | Dependances | +| ----- | ------------------------------------------ | ----------- | +| **1** | Hooks, composants mobile, CSS, breakpoints | Aucune | +| **2** | Navigation (tab bar, sidebar, NavBar) | Phase 1 | +| **3** | Corrections layout pages et composants | Phase 2 | +| **4** | Formulaires et saisie tactile | Phase 1 | +| **5** | Polish, audit QA, tests devices, docs | Phases 3+4 | + +Phases 3 et 4 sont partiellement independantes (toutes deux dependent de Phase 1, mais pas l'une de l'autre). + +--- + +## Impact + +- **Frontend uniquement** — aucun changement backend, API, ou DB +- **Aucune route API ajoutee** +- **Desktop preserve** — toutes les modifications sont conditionnelles (mobile uniquement) +- **Composants crees** : 5 (BottomTabBar, BottomSheet, ActionSheet, useIsMobile, useKeyboardVisible) +- **Composants modifies** : ~20 fichiers frontend + +--- + +## Notes pour la reprise + +1. Consulter cette roadmap pour voir les cases cochees +2. La spec complete est dans `SPEC_MOBILE_REWORK.md` (meme dossier) +3. Les phases 3 et 4 peuvent etre parallelisees +4. Aucune dependance npm a ajouter (composants custom uniquement) +5. Tester systematiquement sur Chrome DevTools mobile (responsive mode) pendant le dev diff --git a/docs/features/mobile-rework/SPEC_MOBILE_REWORK.md b/docs/features/mobile-rework/SPEC_MOBILE_REWORK.md new file mode 100644 index 00000000..64657d0d --- /dev/null +++ b/docs/features/mobile-rework/SPEC_MOBILE_REWORK.md @@ -0,0 +1,1091 @@ +# Spec : Rework Mobile Complet + +## 1. Constat + +L'application est actuellement inutilisable sur mobile. L'audit exhaustif de chaque composant et page du frontend revele 26 problemes concrets, regroupes en 5 categories. + +### A. Problemes structurels / architecturaux + +**A1. Double couche de navigation = perte d'espace vertical** + +La NavBar (`App.tsx`) occupe ~64px en haut. En dessous, le bouton hamburger mobile du drawer (`MainLayout.tsx`) ajoute ~48px. Total : **~112px de chrome** avant le contenu. Sur un iPhone SE (667px de hauteur visible), c'est 16.8% de l'ecran consomme par la navigation seule. + +``` +┌────────────────────────────────────┐ +│ NavBar: Forest Manager 🌙 🔔 👤 │ ~64px +├────────────────────────────────────┤ +│ ☰ Menu │ ~48px +├────────────────────────────────────┤ +│ │ +│ Contenu visible │ ~455px restants +│ (68% de l'ecran) │ +│ │ +└────────────────────────────────────┘ +``` + +**A2. Sidebar en overlay plein ecran** + +Quand le drawer s'ouvre sur mobile, il couvre 100% du contenu. Apres un clic sur une communaute, le drawer se ferme. Aucun contexte visuel n'est conserve. + +**A3. Pas de navigation en bas d'ecran** + +Toute la navigation est en haut. Sur les smartphones modernes (6.1"-6.7"), le haut de l'ecran est la zone la plus difficile a atteindre avec le pouce. La "thumb zone" (zone de confort du pouce) se situe dans le tiers inferieur de l'ecran. + +**A4. Pas de gestion des safe areas** + +Aucun `env(safe-area-inset-*)` pour les ecrans avec encoche (iPhone X+), Dynamic Island, ou barre de navigation Android gestuelle. Le contenu peut passer derriere. + +### B. Problemes de debordement (overflow) + +**B1. NotificationDropdown : `w-96` (384px)** + +L'iPhone SE a un viewport de 375px. Le dropdown deborde de 9px. Avec `absolute right-0`, il sort a gauche de l'ecran. + +**B2. VariantsDropdown : `w-72` (288px)** + +Meme probleme avec `absolute right-0`. Moins grave car 288 < 375, mais le padding du parent peut le faire deborder. + +**B3. RecipeDetailPage : jusqu'a 6 boutons d'action en ligne** + +Boutons possibles : Variants, Share, Suggest tag, Propose changes, Edit, Delete. Chaque bouton fait ~80-120px (icone + texte). Total minimum : ~500px. Sur un ecran de 375px, ca deborde massivement. Aucun `flex-wrap` n'est applique. + +```tsx +// RecipeDetailPage.tsx L196-259 — aucune gestion du wrap +
    {/* 6 boutons potentiels sans flex-wrap */}
    +``` + +**B4. RecipeFilters : `min-w-[200px]` sur 3 filtres** + +Trois filtres avec `min-w-[200px]` dans un `flex gap-4 flex-wrap`. Le wrap fonctionne mais chaque filtre occupe ensuite toute la largeur, ce qui empile 3 blocs de 200px+ verticalement — beaucoup d'espace utilise. + +### C. Problemes d'interaction tactile + +**C1. SidePanel : resize uniquement a la souris** + +`SidePanel.tsx` utilise `onMouseDown`/`onMouseMove`/`onMouseUp`. Aucun event tactile. Le resize est inutilisable sur mobile. Pire : le panel fait minimum 250px dans un `flex gap-6`. Sur un ecran de 375px : 375 - 250 - 24 = **101px pour le contenu** — inutilisable. + +**C2. Tooltips DaisyUI sur touch** + +`CommunityDetailPage.tsx` : tous les boutons de l'en-tete (Members, Activity, Invitations, Tags, Edit) utilisent `tooltip tooltip-bottom data-tip="..."`. Ces tooltips sont hover-only, donc **invisibles sur mobile**. L'utilisateur ne sait pas ce que font les boutons icones. + +**C3. Cibles tactiles trop petites** + +De nombreux elements interactifs utilisent `btn-xs` (~24px) ou `btn-sm btn-circle` (~32px). La taille minimum recommandee est 44x44px (Apple HIG) / 48x48dp (Material Design). + +Elements concernes : + +- Actions membres : Promote, Kick (`btn-ghost btn-xs`) +- Mark as read dans les notifications (`btn-ghost btn-xs`) +- StepEditor : boutons Move up/down/Delete (`btn-ghost btn-xs`) +- Chips remove button dans SearchSelector (12px FaTimes) + +**C4. Drag handle du StepEditor trop petit** + +`btn-xs` avec `FaGripVertical`. La zone de prise est ~24x24px, bien en dessous des 44px recommandes. Le reordonnement par drag fonctionne (PointerSensor de @dnd-kit supporte le touch) mais est penible a utiliser. + +**C5. IngredientRow : 4 colonnes horizontales comprimees** + +Layout : `flex gap-2` avec nom (flex-1) + quantite (w-24) + unite (w-28) + bouton supprimer. +Sur 375px : 375 - 32(padding page) - 16(padding form) - 24 - 28\*4 - 8(gaps) ~= 183px pour le nom. Utilisable mais inconfortable, surtout avec le clavier virtuel ouvert qui reduit la vue. + +### D. Problemes de mise en page du contenu + +**D1. CommunityDetailPage : SidePanel ecrase le contenu** + +`flex gap-6` avec le SidePanel (min 250px). Sur mobile, le contenu principal est comprime a quelques pixels. Le panel devrait etre un overlay, pas un element lateral. + +**D2. RecipeDetailPage : image hero trop haute** + +`h-64` (256px) sur mobile. Sur un iPhone SE, c'est 38% de l'ecran visible occupe par l'image seule. L'utilisateur doit scroller pour voir le titre. + +**D3. MembersList : tableau HTML non adapte** + +`` avec 4 colonnes (Username, Role, Joined, Actions). Les tableaux ne se compressent pas bien sur mobile. Les boutons d'action ajoutent encore plus de largeur. + +**D4. ProposeModificationModal : formulaire complexe dans un modal** + +Le modal contient : titre, servings, 3 champs temps, IngredientList complet, StepEditor complet. Sur mobile, ce modal (meme en `modal-bottom`) devient un formulaire scrollable de 1000px+ de haut dans une boite contrainte. Devrait etre une page plein ecran. + +### E. Problemes d'UX mobile + +**E1. Dropdowns au lieu de pages** + +NotificationDropdown et UserMenu utilisent des panneaux absolus. Sur mobile, il faut scroller dans un petit dropdown, facile a fermer accidentellement. Les apps mobiles utilisent des pages pleines ou des bottom sheets pour ce type de contenu. + +**E2. Texte d'upload non adapte au tactile** + +"Cliquez ou glissez une image ici" — le drag & drop n'existe pas sur mobile. Le texte devrait dire simplement "Appuyez pour ajouter une image". + +**E3. Pas de gestes de swipe** + +Aucun swipe-to-dismiss pour les modals, aucun swipe pour ouvrir/fermer le drawer, aucun pull-to-refresh. + +**E4. Variante `pointer-fine`/`pointer-coarse` fragile** + +Le code utilise des variantes Tailwind custom `pointer-fine` (desktop) / `pointer-coarse` (mobile) basees sur `@media (pointer: fine/coarse)`. Probleme : un laptop avec ecran tactile reporte les deux. Un iPad avec clavier Bluetooth reporte `pointer-fine`. La detection par viewport width est plus fiable. + +--- + +## 2. Principes de design mobile + +### 2.1 Mobile-first ne signifie pas "desktop reduit" + +Le mobile a son propre paradigme de navigation. On ne peut pas simplement compresser la version desktop. Les interactions tactiles, la zone de confort du pouce, et les patterns d'utilisation sont fondamentalement differents. + +### 2.2 Thumb zone priority + +Sur un smartphone tenu a une main, le pouce atteint confortablement : + +- **Zone facile** : tiers inferieur + centre +- **Zone atteignable** : milieu de l'ecran +- **Zone difficile** : haut de l'ecran, coin oppose au pouce + +Les actions principales doivent etre dans la zone facile. + +### 2.3 Touch targets = 44px minimum + +Toute zone cliquable/tapable doit faire au minimum 44x44px (recommandation Apple HIG). Les zones critiques (navigation, actions destructives) devraient faire 48px. + +### 2.4 Contenu d'abord + +Maximiser l'espace pour le contenu. Le chrome (navigation, barres d'outils) doit etre minimal et se cacher/apparaitre intelligemment. + +### 2.5 Patterns natifs + +Utiliser les patterns que les utilisateurs connaissent deja : + +- Bottom tab bar pour la navigation principale +- Bottom sheets pour les menus contextuels +- Swipe pour naviguer entre sections +- Pull-to-refresh (futur) + +--- + +## 3. Strategie de breakpoints + +### 3.1 Definitions + +| Nom | Range | Comportement | +| ----------- | --------- | ------------------------------------------------ | +| **Mobile** | 0 – 767px | Bottom tabs, pas de NavBar, sidebar en drawer | +| **Desktop** | 768px+ | NavBar visible, sidebar fixe, pas de bottom tabs | + +Le seuil de 768px correspond au `COMPACT_BREAKPOINT` existant et au breakpoint `md:` de Tailwind. + +### 3.2 Remplacement de pointer-fine/pointer-coarse + +Les variantes `pointer-fine` et `pointer-coarse` seront remplacees par des breakpoints Tailwind standards : + +- `pointer-fine:xxx` → `md:xxx` +- `pointer-coarse:xxx` → valeur par defaut (mobile-first) ou condition inverse + +Les variantes custom restent dans `tailwind.config.js` pour d'eventuels usages futurs mais ne sont plus utilisees pour la logique mobile/desktop. + +### 3.3 Detection JS + +Un hook `useIsMobile()` est necessaire pour les cas ou le **comportement** (pas juste le style) change : + +```ts +// hooks/useIsMobile.ts +export function useIsMobile(): boolean { + // Utilise window.matchMedia('(max-width: 767px)') + // Reactif au resize sans event listener lourd +} +``` + +**Cas d'usage du hook (JS necessaire)** : + +- NotificationDropdown : ouvre un dropdown (desktop) vs navigate vers `/notifications` (mobile) +- UserMenu : ouvre un dropdown (desktop) vs navigate ou ouvre bottom sheet (mobile) +- SidePanel : rendu en panel lateral (desktop) vs bottom sheet (mobile) +- RecipeDetailPage : boutons inline (desktop) vs menu "..." (mobile) + +**Tout le reste** : Tailwind breakpoints `md:` (CSS pur, pas de JS). + +--- + +## 4. Architecture de navigation mobile + +### 4.1 Vue d'ensemble + +``` +DESKTOP (>= 768px) MOBILE (< 768px) +┌──────────────────────────────┐ ┌──────────────────────┐ +│ NavBar: FM 🌙 🔔 👤 │ │ │ +├──────┬───────────────────────┤ │ │ +│ │ │ │ Contenu plein │ +│ Side │ Contenu │ │ ecran │ +│ bar │ │ │ │ +│ │ │ │ │ +│ │ │ ├──────────────────────┤ +│ │ │ │ [🏠] [📖] [🔔] [👤] │ +└──────┴───────────────────────┘ └──────────────────────┘ +``` + +### 4.2 Bottom Tab Bar + +**4 onglets** : + +| Position | Icone | Label | Route | Contenu | +| -------- | ------ | ------- | -------------- | ------------------------------------------------------ | +| 1 | FaHome | Home | /dashboard | Dashboard (communautes + recettes recentes + activite) | +| 2 | FaBook | Recipes | /recipes | Mes recettes perso avec filtres | +| 3 | FaBell | Notifs | /notifications | Page notifications complete | +| 4 | FaUser | Profile | /profile | Profil + preferences + invitations + deconnexion | + +**Pourquoi pas 5 onglets avec Communities ?** +Les communautes sont accessibles depuis : + +1. Le dashboard (cartes communautes) +2. Le drawer lateral (icone hamburger ou swipe depuis le bord gauche sur le tab Home) +3. Les notifications (lien direct) + +Un 5e onglet surchargerait la barre sur les petits ecrans (375px / 5 = 75px par onglet). + +**Design du composant `BottomTabBar`** : + +- Hauteur : 56px + `env(safe-area-inset-bottom)` pour les telephones a encoche +- Position : `fixed bottom-0 left-0 right-0` +- Background : `bg-base-100 border-t border-base-300` +- Z-index : `z-50` +- Chaque onglet : icone (20px) + label (10px), zone cliquable = toute la surface (minimum 44px de haut) +- Onglet actif : icone `text-primary` + label `text-primary font-semibold` +- Badge notifications : identique au badge actuel du NotificationDropdown, positionne sur l'icone bell +- Le theme toggle (lune/soleil) est deplace dans la page Profile + +**Padding du contenu** : +Le `
    ` doit avoir un `padding-bottom` de `56px + safe-area` pour ne pas passer derriere la tab bar. + +### 4.3 NavBar : comportement conditionnel + +| Contexte | NavBar | Bottom tabs | +| ---------------------------- | ------------------ | ----------- | +| Non connecte, toutes tailles | Visible (inchange) | Masques | +| Connecte, desktop (>= 768px) | Visible (inchange) | Masques | +| Connecte, mobile (< 768px) | **Masquee** | Visibles | + +Implementation dans `App.tsx` : + +```tsx +// Pseudo-code +
    + {/* NavBar : visible desktop + non-connecte */} +
    + {" "} + {/* ou logique conditionnelle */} + +
    + +
    + {userRoutes} +
    + + {/* Bottom tabs : visible mobile + connecte */} + {user && } + + + +
    +``` + +Note : pour les pages publiques (non connecte), la NavBar reste visible sur mobile car elle est legere (logo + login) et il n'y a pas de bottom tabs. + +### 4.4 Sidebar sur mobile + +Le sidebar (drawer) reste accessible sur mobile **uniquement depuis le tab Home** : + +- Via un bouton hamburger dans l'en-tete du dashboard +- OU via un swipe depuis le bord gauche de l'ecran (optionnel, phase ulterieure) + +Le drawer s'ouvre en overlay (comme actuellement) mais avec des ameliorations : + +- Largeur : `w-72` (288px) au lieu de `w-64` (plus de place pour les noms de communautes) +- Touch targets : 48px minimum pour chaque element de navigation +- Fermeture : tap sur l'overlay OU swipe vers la gauche +- En-tete : "Forest Manager" + bouton fermer (X) + +Sur les tabs Recipes, Notifications et Profile, **pas d'acces au drawer**. La navigation entre communautes se fait en revenant au tab Home. + +### 4.5 Modification de `MainLayout.tsx` + +Le `MainLayout` actuel gere le drawer DaisyUI. Sur mobile, il doit : + +1. Ne plus afficher le bouton hamburger en haut (remplace par le bottom tab) +2. Ne plus forcer `pointer-fine:drawer-open` — utiliser `md:drawer-open` +3. Ajouter le padding bottom pour la tab bar + +```tsx +// MainLayout.tsx — changements cles +
    + {/* ... drawer-toggle ... */} +
    + {/* Mobile: plus de barre hamburger ici — gere par BottomTabBar/DashboardPage */} +
    {children}
    +
    + {/* Sidebar drawer */} +
    {/* ... inchange ... */}
    +
    +``` + +--- + +## 5. Composant par composant : comportement mobile + +### 5.1 NotificationDropdown + +| Desktop | Mobile | +| ---------------------------------------- | ---------------------------------------------- | +| Clic sur cloche → dropdown `w-96` absolu | Clic sur cloche → `navigate('/notifications')` | +| Auto-mark as read apres 3s | N/A (gere par la page) | +| "Voir tout" → navigate | N/A | + +Le composant utilise `useIsMobile()`. Sur mobile, le `handleToggle` fait un `navigate('/notifications')` au lieu d'ouvrir le dropdown. Le dropdown n'est jamais rendu sur mobile. + +La cloche avec badge reste dans la `BottomTabBar`, pas dans le `NotificationDropdown`. + +### 5.2 NavBarLoggedInView (UserMenu) + +| Desktop | Mobile | +| ----------------------------------- | --------------------------------------------- | +| Clic → dropdown `w-64` absolu | Clic sur onglet Profile → navigate `/profile` | +| Menu : Profile, Invitations, Logout | N/A (ces liens sont dans la page Profile) | + +Le composant n'est plus rendu sur mobile (NavBar est masquee). L'acces au profil, invitations et deconnexion se fait depuis la page Profile, qui doit etre enrichie avec un bouton "Invitations" et un bouton "Logout". + +**Modification de `ProfilePage.tsx`** (mobile uniquement) : + +- Ajouter un lien vers "Mes invitations" avec badge (+ nombre) +- Ajouter le toggle theme (lune/soleil) +- Ajouter le bouton "Se deconnecter" en rouge + +### 5.3 SidePanel (CommunityDetailPage) + +| Desktop | Mobile | +| ------------------------------- | ----------------------------------- | +| Panel lateral resizable 250-50% | Bottom sheet overlay 80% hauteur | +| Mouse drag pour resize | Tap overlay ou bouton X pour fermer | +| Panel visible a cote du contenu | Overlay par-dessus le contenu | + +**Composant `BottomSheet`** (nouveau, reutilisable) : + +- Overlay sombre (`bg-black/50`) avec tap-to-close +- Panel blanc qui monte depuis le bas (animation `translate-y`) +- Hauteur : 80vh par defaut, scrollable internement +- Barre de poignee en haut (petite barre grise horizontale) +- Fermeture : tap overlay, swipe down, bouton X + +Le `CommunityDetailPage` utilise `useIsMobile()` pour choisir : + +- Desktop : `` (inchange) +- Mobile : `` avec le meme contenu + +Les boutons icones de l'en-tete de communaute (Members, Activity, etc.) deviennent plus grands sur mobile et affichent leur label texte au lieu des tooltips : + +``` +Desktop: [👥] [📊] [✉️] [🏷️] [✏️] (icones avec tooltips) +Mobile: [👥 Members] [📊 Activity] ... (icones + labels, flex-wrap) +``` + +### 5.4 RecipeDetailPage + +**Boutons d'action** : + +| Desktop | Mobile | +| ------------------------- | ------------------------------------- | +| Tous les boutons en ligne | 1-2 boutons principaux + bouton "..." | +| `flex gap-2 items-center` | Bouton principal + action sheet | + +Sur mobile, logique conditionnelle : + +- **Proprietaire** : bouton "Edit" visible + bouton "..." qui ouvre un bottom sheet avec : Share, Delete +- **Non-proprietaire (communaute)** : bouton "Propose" visible + bouton "..." avec : Suggest tag, Variants +- **Recette perso (non proprio)** : rien de special + +Le bottom sheet liste les actions avec icones, labels, et zones de 48px de haut. + +**Image hero** : + +```tsx +// Actuel +
    + +// Mobile : plus court pour voir le titre sans scroller +
    +``` + +**Metadata et tags** : inchanges (flex-wrap fonctionne deja bien). + +**Ingredients et etapes** : inchanges (layout vertical, fonctionne bien). + +### 5.5 RecipeFormPage + +Le formulaire fonctionne deja raisonnablement sur mobile (`max-w-2xl`, `space-y-6`). Corrections : + +1. **En-tete** : `flex justify-between items-center` → sur mobile, le bouton "Importer une recette" deborde. Solution : stacker le titre et le bouton sur mobile. + +```tsx +
    +``` + +2. **Champs temps** : `grid grid-cols-1 sm:grid-cols-3` — deja correct. + +3. **Boutons Save/Cancel** : `flex justify-end gap-4` — OK sur mobile. + +### 5.6 RecipeFilters + +| Desktop | Mobile | +| ---------------------------- | -------------------------------------- | +| 3 filtres en ligne avec wrap | 3 filtres empiles verticalement | +| Toujours visible | Repliable derriere un bouton "Filtres" | + +Sur mobile (repliable des la v1) : + +- Bouton "Filtres" avec badge du nombre de filtres actifs (ex: `Filtres (2)`) +- Au tap, les filtres apparaissent en slide-down (collapse/expand, 200ms `ease-in-out`) +- Chaque filtre occupe 100% de la largeur +- Supprimer `min-w-[200px]` sur mobile +- Les filtres se replient automatiquement apres selection d'un tag/ingredient (pour voir les resultats) + +```tsx +// Mobile : bouton toggle + section collapsible +const [filtersOpen, setFiltersOpen] = useState(false); +const activeCount = (search ? 1 : 0) + tags.length + ingredients.length; + +<> + {/* Bouton toggle — mobile uniquement */} + + + {/* Filtres — toujours visibles desktop, collapsible mobile */} +
    +
    +
    {/* Search */}
    + {/* ... */} +
    +
    +; +``` + +### 5.7 IngredientList / IngredientRow + +| Desktop | Mobile | +| ------------------- | ----------------------- | +| 4 elements en ligne | Layout en carte stackee | + +**Mobile layout pour chaque ingredient** : + +``` +┌──────────────────────────────────────┐ +│ [Ingredient name input ] [✕] │ +│ [Qty ] [Unit ▾ ] │ +└──────────────────────────────────────┘ +``` + +Implementation : + +```tsx +// Desktop : flex horizontal (inchange) +// Mobile : grid/flex-col +
    +
    +
    {/* Input nom */}
    + +
    +
    + + + +
    +
    +``` + +### 5.8 StepEditor + +| Desktop | Mobile | +| ---------------------------------------- | ---------------------------------------------------------------- | +| [Drag] [Badge] [Textarea] [↑↓✕] vertical | [Drag+Badge] [Textarea full-width] [↑ ↓ ✕] horizontal en dessous | + +**Mobile layout** : + +``` +┌──────────────────────────────────────┐ +│ ☰ [1] │ +│ ┌──────────────────────────────────┐ │ +│ │ Textarea instruction... │ │ +│ │ ... │ │ +│ └──────────────────────────────────┘ │ +│ [↑ Move up] [↓ Move down] [✕] │ +└──────────────────────────────────────┘ +``` + +- Drag handle : 48x48px minimum +- Boutons move/delete : 44px de haut, texte visible (pas juste icone) +- Textarea : `w-full` + +### 5.9 SearchSelector + +Le composant fonctionne bien sur mobile (dropdown `w-full`, chips wrap). Corrections mineures : + +- Chips : padding augmente de `px-2 py-1` a `px-3 py-1.5` sur mobile pour meilleure zone tactile +- Bouton remove (FaTimes 12px) : wrapper de 28px minimum +- Dropdown items : `py-2` → `py-3` sur mobile pour 44px de haut par item + +### 5.10 CommunityRecipesList + +Le composant fonctionne correctement. Les grids responsive sont deja en place. + +Correction : le header (`flex justify-between items-center mb-6`) peut deborder si le titre + boutons sont trop larges. Stacker sur mobile : + +```tsx +
    +``` + +### 5.11 MembersList + +| Desktop | Mobile | +| ------------------------ | --------------- | +| Table HTML avec colonnes | Liste de cartes | + +**Mobile layout** (chaque membre = une carte) : + +``` +┌──────────────────────────────────────┐ +│ alice_wonder (you) [MODERATOR] │ +│ Joined Jan 15, 2026 │ +│ [Leave] │ +└──────────────────────────────────────┘ +┌──────────────────────────────────────┐ +│ bob_chef [MEMBER] │ +│ Joined Mar 2, 2026 │ +│ [Promote] [Kick] │ +└──────────────────────────────────────┘ +``` + +Implementation : afficher la `
    ` sur desktop (`hidden md:block`) et les cartes sur mobile (`md:hidden`). + +### 5.12 Modal + +Le composant `Modal.tsx` utilise deja `modal-bottom sm:modal-middle` — c'est **correct** pour la plupart des modals. + +**Exception pour les modals complexes** : `ProposeModificationModal` doit devenir plein ecran sur mobile car il contient des sous-composants complexes (IngredientList, StepEditor). + +Solution : ajouter une prop `fullScreenMobile?: boolean` au composant Modal : + +```tsx +const modalClass = cn({ + "modal modal-bottom sm:modal-middle": !fullScreenMobile, + "modal modal-open": !fullScreenMobile, + // Full screen mobile + "fixed inset-0 z-50 bg-base-100 overflow-y-auto": fullScreenMobile, + "md:modal md:modal-open md:modal-middle md:relative md:inset-auto": fullScreenMobile, +}); +``` + +Ou plus simple : le `ProposeModificationModal` n'utilise plus `` sur mobile et rend a la place une page plein ecran avec un header (bouton retour + titre) et un formulaire scrollable. + +### 5.13 ImageUpload / ImagePicker + +Corrections : + +- Texte "Cliquez ou glissez une image ici" → sur mobile : "Appuyez pour ajouter une image" +- Detection via `useIsMobile()` ou via un message plus universel : "Ajouter une image" tout court +- Zone de drop : augmenter `p-6` a `p-8` sur mobile pour un meilleur tap target + +### 5.14 ImportRecipeModal + +Fonctionne bien dans un modal-bottom. Le textarea est `w-full min-h-[200px]`. Aucun changement necessaire. + +### 5.15 LoginModal + +Fonctionne bien (modal-bottom sur mobile). Aucun changement necessaire. + +### 5.16 CommunityCard + +Fonctionne bien. Les cartes s'empilent en colonne sur mobile. Aucun changement. + +### 5.17 RecipeCard + +Fonctionne bien. Les cartes s'empilent en colonne sur mobile. + +Correction mineure : les boutons d'action en bas de carte (`btn-ghost btn-sm`) sont un peu petits pour le tactile. Augmenter a `btn-md` sur mobile ou s'assurer que la zone cliquable fait au moins 44px. + +### 5.18 RecipeListRow + +Sur mobile, la vue liste est moins pratique que les cartes. Deux options : + +1. Forcer le mode carte sur mobile (masquer le toggle list/card) +2. Adapter le list row pour mobile (empiler les elements) + +**Recommandation** : option 1 (forcer carte sur mobile). Le toggle card/list est masque sous `md:` : + +```tsx +
    {/* card/list toggle — desktop only */}
    +``` + +Sur mobile, toujours afficher en mode carte. + +### 5.19 DashboardPage + +Le dashboard fonctionne deja bien grace aux grids responsive. Corrections : + +- En-tetes de section : stacker titre + bouton sur mobile si debordement +- Le bouton "See all (N)" est petit mais acceptable + +### 5.20 NotificationsPage + +Fonctionne bien sur mobile. Les filtres par categorie (`flex flex-wrap gap-2`) wrappent correctement. + +Corrections : + +- Les boutons categorie sont `btn-sm` → augmenter la zone tactile +- Le toggle "Non lues uniquement" avec `ml-auto` peut se retrouver hors champ. Sur mobile, le placer en dessous des categories. + +### 5.21 ProfilePage + +Fonctionne bien (`max-w-2xl`). Les formulaires s'adaptent. + +**Ajouts mobile** (voir 5.2) : + +- Section "Navigation" en haut avec liens vers Invitations (+ badge) +- Theme toggle (lune/soleil) +- Bouton Deconnexion visible en rouge + +Note : le theme toggle est AUSSI dans le footer du drawer Sidebar (voir 4.4). + +### 5.22 CommunityDetailPage (en-tete) + +L'en-tete de communaute (`flex justify-between items-start gap-4`) peut deborder sur mobile avec l'image + nom + badge + 5 boutons icones. + +**Mobile layout** : + +``` +┌──────────────────────────────────────┐ +│ [img] Community Name [MODERATOR]│ +│ Description text... │ +│ 3 members · 12 recipes │ +│ │ +│ [👥 Members] [📊 Activity] [✉️ Inv.] │ +│ [🏷️ Tags] [✏️ Edit] │ +└──────────────────────────────────────┘ +``` + +- Boutons : `flex flex-wrap gap-2` avec labels texte au lieu de tooltips icone-only +- Zone tactile minimum 44px par bouton +- L'image communaute reste en `w-16 h-16` (correcte) + +--- + +## 6. Nouveau composant : BottomSheet + +Composant reutilisable pour remplacer les dropdowns et les panels sur mobile. + +### Interface + +```ts +interface BottomSheetProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + height?: "auto" | "half" | "full"; // 'auto' = fit content, 'half' = 50vh, 'full' = 90vh +} +``` + +### Structure + +``` +┌──────────────── Overlay (bg-black/50, tap to close) ────────────┐ +│ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ ───── (poignee de swipe, 32x4px, bg-base-300, rounded) │ │ +│ │ │ │ +│ │ Title (optional) [X] │ │ +│ │ ──────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Contenu scrollable │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### Comportement + +- Animation : slide-up de 300ms (`transition-transform`) +- Overlay : fade-in 200ms +- Fermeture : tap overlay, bouton X (swipe down en v2) +- Scroll interne : `-webkit-overflow-scrolling: touch`, overscroll-behavior: contain +- Z-index : `z-50` +- Padding bottom : `env(safe-area-inset-bottom)` + +### Usages + +- CommunityDetailPage : SidePanel content (Members, Activity, Invitations, Tags, Edit) +- RecipeDetailPage : menu d'actions "..." +- ProfilePage/mobile : pourrait etre utilise pour des sous-sections (futur) + +--- + +## 7. Action sheet pour RecipeDetailPage + +Pattern specifique pour les boutons d'action sur mobile. + +### Composant `ActionSheet` + +Basé sur `BottomSheet` avec un style specifique pour les listes d'actions : + +``` +┌───────────────────────────────────────┐ +│ ───── │ +│ │ +│ [🔀] Voir les variantes (3) │ +│ ───────────────────────────────── │ +│ [↗️] Partager │ +│ ───────────────────────────────── │ +│ [🏷️] Suggerer un tag │ +│ ───────────────────────────────── │ +│ [🗑️] Supprimer (en rouge) │ +│ │ +│ [ Annuler ] │ +│ │ +└───────────────────────────────────────┘ +``` + +Chaque item : 56px de haut, icone a gauche, label, separation entre items. +Item destructif (Delete) : texte en rouge (`text-error`). +Bouton "Annuler" en bas, separe par plus d'espace. + +--- + +## 8. Safe areas + +### 8.1 Meta viewport + +Dans `index.html`, ajouter `viewport-fit=cover` : + +```html + +``` + +### 8.2 CSS + +```css +/* global.css */ +:root { + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); +} +``` + +### 8.3 Application + +- **BottomTabBar** : `padding-bottom: var(--safe-area-bottom)` +- **Main content** : `padding-bottom: calc(56px + var(--safe-area-bottom))` sur mobile +- **Bottom sheets** : `padding-bottom: var(--safe-area-bottom)` +- **Modals bottom** : DaisyUI gere deja le positionnement + +--- + +## 9. Recapitulatif des modifications par fichier + +### Fichiers a creer + +| Fichier | Description | +| ------------------------------------ | ------------------------------------------------------ | +| `components/mobile/BottomTabBar.tsx` | Navigation par onglets en bas | +| `components/mobile/BottomSheet.tsx` | Sheet reutilisable (overlay, slide-up) | +| `components/mobile/ActionSheet.tsx` | Variante du BottomSheet pour listes d'actions | +| `hooks/useIsMobile.ts` | Hook de detection viewport < 768px | +| `hooks/useKeyboardVisible.ts` | Hook de detection clavier virtuel (visualViewport API) | + +### Fichiers a modifier + +| Fichier | Changements | +| ------------------------------------- | ------------------------------------------------------------------------------------ | +| `index.html` | Ajouter `viewport-fit=cover` | +| `styles/global.css` | Variables safe-area, padding body mobile | +| `tailwind.config.js` | Garder les variantes pointer mais ne plus les utiliser | +| `App.tsx` | Conditionner NavBar (desktop) vs BottomTabBar (mobile + connecte), toast position | +| `MainLayout.tsx` | `md:drawer-open` au lieu de `pointer-fine:drawer-open`, padding bottom mobile | +| `Sidebar.tsx` | `md:flex`/`md:hidden` au lieu de `pointer-fine`/`pointer-coarse`, touch targets 48px | +| `NavBar.tsx` | Ajouter `hidden md:flex` (masquer sur mobile quand connecte) | +| `NotificationDropdown.tsx` | Sur mobile : navigate au lieu d'ouvrir dropdown | +| `NavBarLoggedInView.tsx` | N'est plus rendu sur mobile (NavBar masquee) | +| `SidePanel.tsx` | Desktop : inchange. Mobile : utilise BottomSheet | +| `CommunityDetailPage.tsx` | Boutons header : labels au lieu de tooltips sur mobile, BottomSheet | +| `RecipeDetailPage.tsx` | Action buttons : ActionSheet sur mobile, image hero plus courte | +| `RecipeFormPage.tsx` | Stacker titre + bouton import sur mobile | +| `RecipeFilters.tsx` | Supprimer `min-w-[200px]` mobile, layout vertical, filtres repliables | +| `IngredientList.tsx` | Layout en carte (stacke) sur mobile | +| `StepEditor.tsx` | Layout mobile : boutons sous le textarea, touch targets 44px | +| `SearchSelector.tsx` | Touch targets dropdown items, chips padding | +| `RecipesPageLoggedInView.tsx` | Masquer toggle card/list sur mobile, forcer card view | +| `CommunityRecipesList.tsx` | Idem (forcer card view mobile) | +| `MembersList.tsx` | Table desktop / Cartes mobile | +| `RecipeCard.tsx` | Boutons action : touch targets 44px | +| `NotificationsPage.tsx` | Filtres : layout adapte mobile, toggle unread en dessous | +| `ProfilePage.tsx` | Ajouter liens Invitations + theme + logout sur mobile | +| `ProposeModificationModal.tsx` | Plein ecran sur mobile (pas de Modal wrapper) | +| `ImageUpload.tsx` / `ImagePicker.tsx` | Texte adapte au tactile | + +### Fichiers inchanges + +| Fichier | Raison | +| ------------------------- | ---------------------------------------------- | +| `Modal.tsx` | `modal-bottom sm:modal-middle` deja correct | +| `LoginModal.tsx` | Fonctionne bien en modal-bottom | +| `ImportRecipeModal.tsx` | Fonctionne bien en modal-bottom | +| `SuggestTagModal.tsx` | Fonctionne bien (overflow-visible, max-w-lg) | +| `CommunityCard.tsx` | Layout responsive deja correct | +| `HomePage.tsx` | Page publique, layout simple et correct | +| `CommunitiesPage.tsx` | Grid responsive deja correct | +| `InvitationsPage.tsx` | Grid responsive deja correct | +| `CommunityCreatePage.tsx` | Formulaire simple, fonctionne bien | +| `DashboardPage.tsx` | Grids responsive corrects, ajustements mineurs | +| `TagSelector.tsx` | Delegue a SearchSelector | +| `UnitSelector.tsx` | ` + + )} + + {status === "idle" && preview && ( + + )} + + ); +}; + +export default ImagePicker; diff --git a/frontend/src/components/ImageUpload.tsx b/frontend/src/components/ImageUpload.tsx new file mode 100644 index 00000000..a94f7e54 --- /dev/null +++ b/frontend/src/components/ImageUpload.tsx @@ -0,0 +1,208 @@ +import { useCallback, useRef, useState } from "react"; +import { FaCloudUploadAlt, FaTrash, FaTimes } from "react-icons/fa"; +import { processImage, ALLOWED_TYPES } from "../utils/imageUtils"; +import { useIsMobile } from "../hooks/useIsMobile"; + +interface ImageUploadProps { + currentImageUrl: string | null; + onUploadComplete: (imageUrl: string) => void; + onDeleteComplete: () => void; + getUploadUrl: () => Promise<{ uploadUrl: string; imageKey: string }>; + confirmUpload: () => Promise<{ imageKey: string; imageUrl: string }>; + deleteImage: () => Promise; +} + +type UploadStatus = "idle" | "processing" | "uploading" | "confirming"; + +function toUserMessage(err: unknown): string { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("File not found")) + return "Le fichier n'a pas ete recu par le serveur. Veuillez reessayer."; + if (msg.includes("Invalid file type")) return "Format non supporte. Utilisez JPEG, PNG ou WebP."; + if (msg.includes("File too large")) return "Image trop volumineuse. Maximum : 2 Mo."; + if (msg.includes("Network error")) return "Erreur reseau. Verifiez votre connexion."; + if (msg.includes("RECIPE_005") || msg.includes("COMMUNITY_006")) + return "L'image n'est pas valide. Verifiez le format et la taille (max 2 Mo)."; + if (msg.includes("RECIPE_002")) return "Vous n'avez pas la permission de modifier cette image."; + if (msg.includes("COMMUNITY_002")) return "Communaute introuvable."; + if (msg.includes("RECIPE_001")) return "Recette introuvable."; + return msg || "Une erreur est survenue."; +} + +const statusLabels: Record = { + idle: "", + processing: "Conversion en cours...", + uploading: "Envoi en cours...", + confirming: "Finalisation...", +}; + +const ImageUpload = ({ + currentImageUrl, + onUploadComplete, + onDeleteComplete, + getUploadUrl, + confirmUpload, + deleteImage, +}: ImageUploadProps) => { + const [preview, setPreview] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const inputRef = useRef(null); + + const isMobile = useIsMobile(); + const busy = status !== "idle" || isDeleting; + + const handleFile = useCallback( + async (file: File) => { + setError(null); + setPreview(URL.createObjectURL(file)); + + try { + // 1. Process (validate + convert + resize) + setStatus("processing"); + const blob = await processImage(file); + + // 2. Get presigned URL + setStatus("uploading"); + const { uploadUrl } = await getUploadUrl(); + + // 3. Upload directly to MinIO + const uploadRes = await fetch(uploadUrl, { + method: "PUT", + body: blob, + headers: { "Content-Type": "image/webp" }, + }); + if (!uploadRes.ok) throw new Error("L'envoi de l'image a echoue. Veuillez reessayer."); + + // 4. Confirm upload + setStatus("confirming"); + const { imageUrl } = await confirmUpload(); + + setPreview(null); + setStatus("idle"); + onUploadComplete(imageUrl); + } catch (err) { + setStatus("idle"); + setError(toUserMessage(err)); + } + }, + [getUploadUrl, confirmUpload, onUploadComplete] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (busy) return; + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile, busy] + ); + + const handleSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + // Reset so the same file can be re-selected + e.target.value = ""; + }; + + const handleDelete = async () => { + setError(null); + setIsDeleting(true); + try { + await deleteImage(); + onDeleteComplete(); + } catch (err) { + setError(toUserMessage(err)); + } finally { + setIsDeleting(false); + } + }; + + const cancelPreview = () => { + setPreview(null); + setError(null); + }; + + // Display image: preview during upload, or current image + const displayUrl = preview || currentImageUrl; + + return ( +
    + {/* Current / preview image */} + {displayUrl && ( +
    + Recipe + {/* Delete button (only for persisted image, not during upload) */} + {currentImageUrl && !preview && status === "idle" && ( + + )} + {/* Cancel preview if error during upload */} + {preview && status === "idle" && ( + + )} +
    + )} + + {/* Status indicator */} + {status !== "idle" && ( +
    + + {statusLabels[status]} +
    + )} + + {/* Error */} + {error && ( +
    + {error} +
    + )} + + {/* Drop zone / file picker */} + {!busy && ( +
    inputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + > + +

    + {isMobile ? "Appuyez pour ajouter une image" : "Cliquez ou glissez une image ici"} +

    +

    JPEG, PNG ou WebP — max 2 Mo

    + +
    + )} +
    + ); +}; + +export default ImageUpload; diff --git a/frontend/src/components/ImportRecipeModal.tsx b/frontend/src/components/ImportRecipeModal.tsx new file mode 100644 index 00000000..f32a9616 --- /dev/null +++ b/frontend/src/components/ImportRecipeModal.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { FaTimes } from "react-icons/fa"; +import Modal from "./Modal"; +import { parseRecipeText, ParsedRecipe } from "../services/recipeParser"; +import APIManager from "../network/api"; + +interface ImportRecipeModalProps { + onImport: (parsed: ParsedRecipe) => void; + onClose: () => void; +} + +const ImportRecipeModal = ({ onImport, onClose }: ImportRecipeModalProps) => { + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isUrl = /^https?:\/\//i.test(input.trim()); + const canAnalyze = input.trim().length > 0 && !isLoading; + + const handleAnalyze = async () => { + setError(null); + const trimmed = input.trim(); + + if (isUrl) { + // Import URL via backend + try { + setIsLoading(true); + const parsed = await APIManager.importRecipeFromUrl(trimmed); + onImport(parsed); + } catch (err) { + const msg = err instanceof Error ? err.message : "Erreur inconnue"; + if (msg.includes("IMPORT_001")) { + setError("URL invalide"); + } else if (msg.includes("IMPORT_002")) { + setError("Impossible d'acceder a cette URL"); + } else if (msg.includes("IMPORT_003")) { + setError("Aucune recette detectee sur cette page"); + } else { + setError("Erreur de connexion"); + } + } finally { + setIsLoading(false); + } + } else { + // Import texte via parser local + const parsed = parseRecipeText(trimmed); + + const hasContent = parsed.title || parsed.ingredients.length > 0 || parsed.steps.length > 0; + + if (!hasContent) { + setError("Aucune recette detectee dans le texte. Verifiez le format."); + return; + } + + onImport(parsed); + } + }; + + return ( + +
    +

    Importer une recette

    + +
    + +