diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4e9dbe6..c5fd500 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -42,9 +42,9 @@ MVP complet (phases 0-17). Voir `.claude/context/PROGRESS.md` pour les features ## Codes erreur -**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 +**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 | MEAL_001-013 -**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 +**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 | CHANGELOG_001-004 ## Regle: maintenir `.claude/` a jour @@ -87,9 +87,19 @@ docs/ mobile-rework/ # EN COURS SPEC_MOBILE_REWORK.md ROADMAP.md - e2e-testing/ # PLANIFIE + changelog/ # SPEC DONE + SPEC_CHANGELOG.md + e2e-testing/ # PLANIFIE SPEC_E2E_TESTING.md ROADMAP.md + meal-plan/ # SPEC DONE + SPEC_MEAL_PLAN.md + ROADMAP.md + SPEC_MEAL_GENERATION.md + ROADMAP_GENERATION.md + deps-cleanup/ # PLANIFIE + SPEC_DEPS_CLEANUP.md + ROADMAP.md ``` Chaque nouvelle feature a son dossier dans `docs/features/` avec au minimum une spec et une roadmap. @@ -110,9 +120,20 @@ Chaque nouvelle feature a son dossier dans `docs/features/` avec au minimum une | **Feature : Mobile Rework** | | | Spec Mobile Rework | `docs/features/mobile-rework/SPEC_MOBILE_REWORK.md` | | Roadmap Mobile Rework | `docs/features/mobile-rework/ROADMAP.md` | +| **Feature : Changelog** | | +| Spec Changelog | `docs/features/changelog/SPEC_CHANGELOG.md` | +| Roadmap Changelog | `docs/features/changelog/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` | +| **Feature : Meal Plan** | | +| Spec Meal Plan | `docs/features/meal-plan/SPEC_MEAL_PLAN.md` | +| Roadmap Meal Plan | `docs/features/meal-plan/ROADMAP.md` | +| Spec Meal Generation | `docs/features/meal-plan/SPEC_MEAL_GENERATION.md` | +| Roadmap Meal Generation | `docs/features/meal-plan/ROADMAP_GENERATION.md` | +| **Feature : Deps Cleanup** | | +| Spec Deps Cleanup | `docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md` | +| Roadmap Deps Cleanup | `docs/features/deps-cleanup/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` | diff --git a/.claude/context/API_MAP.md b/.claude/context/API_MAP.md index 081c883..dd3d1a6 100644 --- a/.claude/context/API_MAP.md +++ b/.claude/context/API_MAP.md @@ -155,6 +155,15 @@ PUT /api/notifications/preferences # update preference (category, enabl Controller: `controllers/notifications.ts` | Route: `routes/notifications.ts` +## Changelog (/api/changelog) - requireAuth + +``` +GET /api/changelog/ # list paginated (publishedAt desc, deletedAt: null) +GET /api/changelog/:id # detail (deletedAt: null) +``` + +Controller: `controllers/changelog.ts` | Route: `routes/changelog.ts` + ## User Invitations ``` @@ -201,6 +210,58 @@ POST /api/recipes/:recipeId/tag-suggestions # suggerer un tag (membre, pas own Controller: `controllers/tagSuggestions.ts` | Route: `routes/recipes.ts` +## Meal Plan (/api/communities/:communityId/meal-plan) - memberOf + requireFeature('MEAL_PLAN') + +``` +GET /api/communities/:communityId/meal-plan # plan ACTIVE + slots (memberOf) +POST /api/communities/:communityId/meal-plan # creer plan + slots (MODERATOR) +DELETE /api/communities/:communityId/meal-plan # supprimer plan ACTIVE (MODERATOR) +PATCH /api/communities/:communityId/meal-plan # update settings (MODERATOR) +POST /api/communities/:communityId/meal-plan/generate # generer le planning (MODERATOR) +PATCH /api/communities/:communityId/meal-plan/slots/:slotId # update slot (permission dynamique) +POST /api/communities/:communityId/meal-plan/slots/:slotId/replace # re-generer 1 slot (MODERATOR) +POST /api/communities/:communityId/meal-plan/slots/swap # swap 2 slots (permission dynamique) +GET /api/communities/:communityId/meal-plan/archives # liste archives paginee (memberOf) +GET /api/communities/:communityId/meal-plan/archives/:planId # detail archive + slots (memberOf) +DELETE /api/communities/:communityId/meal-plan/archives/:planId # supprimer archive (MODERATOR) +``` + +Controller: `controllers/mealPlan.ts` | Route: `routes/mealPlan.ts` +Middleware: `middleware/requireFeature.ts` +Error codes: MEAL_001-013, MEAL_GEN_001-013 + +## Meal Ideas (/api/communities/:communityId/meal-ideas) - memberOf + requireFeature('MEAL_PLAN') + +``` +GET /api/communities/:communityId/meal-ideas # liste paginee, ?search= (memberOf) +POST /api/communities/:communityId/meal-ideas # creer idee (memberOf) +PATCH /api/communities/:communityId/meal-ideas/:ideaId # modifier (createur ou MODERATOR) +DELETE /api/communities/:communityId/meal-ideas/:ideaId # soft delete (createur ou MODERATOR) +``` + +Controller: `controllers/mealIdeas.ts` | Route: `routes/mealIdeas.ts` + +--- + +## Meal Generation Params (/api/communities/:communityId/meal-generation-params) - memberOf + requireFeature('MEAL_PLAN') + +``` +GET /api/communities/:communityId/meal-generation-params # liste (memberOf) +POST /api/communities/:communityId/meal-generation-params # creer (MODERATOR) +GET /api/communities/:communityId/meal-generation-params/:paramsId # detail + exclusions + regles + pins (memberOf) +PATCH /api/communities/:communityId/meal-generation-params/:paramsId # modifier (MODERATOR) +DELETE /api/communities/:communityId/meal-generation-params/:paramsId # soft delete (MODERATOR) +PUT .../:paramsId/exclusions # set complet exclusions (MODERATOR) +GET .../:paramsId/rules # liste regles (memberOf) +POST .../:paramsId/rules # ajouter regle (MODERATOR) +PATCH .../:paramsId/rules/:ruleId # modifier regle (MODERATOR) +DELETE .../:paramsId/rules/:ruleId # supprimer regle (MODERATOR, hard delete) +PUT .../:paramsId/pins # set complet pins (MODERATOR) +``` + +Controller: `controllers/mealGenerationParams.ts` | Route: `routes/mealGenerationParams.ts` +Error codes: MEAL_GEN_001, MEAL_GEN_003-006, MEAL_GEN_009-012 + --- ## Admin Auth (/api/admin/auth) - adminSession, rate limited 5/15min @@ -286,6 +347,18 @@ PATCH /api/admin/features/:id # update Controller: `admin/controllers/featuresController.ts` | Route: `admin/routes/featuresRoutes.ts` +## Admin Changelog (/api/admin/changelog) - requireSuperAdmin + +``` +GET /api/admin/changelog/ # list paginated (?includeDeleted=true) +POST /api/admin/changelog/ # create (version, title, content, publishedAt?) +PATCH /api/admin/changelog/:id # update (version?, title?, content?, publishedAt?) +DELETE /api/admin/changelog/:id # soft delete +``` + +Controller: `admin/controllers/changelogController.ts` | Route: `admin/routes/changelogRoutes.ts` +Error codes: CHANGELOG_001-004 + ## Admin Dashboard & Activity - requireSuperAdmin ``` @@ -299,15 +372,16 @@ Controllers: `admin/controllers/dashboardController.ts`, `admin/controllers/acti ## 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: 99 endpoints (65 user + 33 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 | +| requireFeature | middleware/requireFeature.ts | Verifie feature activee pour communaute | +| adminRateLimiter | middleware/security.ts | 30 req/min global admin | +| authRateLimiter | routes config | 5/15min sur auth endpoints | + +## Total: 131 endpoints (93 user + 37 admin + 1 health) diff --git a/.claude/context/DB_MODELS.md b/.claude/context/DB_MODELS.md index fff5925..7bbc98c 100644 --- a/.claude/context/DB_MODELS.md +++ b/.claude/context/DB_MODELS.md @@ -3,7 +3,7 @@ Source: `backend/prisma/schema.prisma` DB: PostgreSQL | ORM: Prisma -## Models (30 total) +## Models (38 total) ### Sessions (isolees) @@ -59,6 +59,23 @@ DB: PostgreSQL | ORM: Prisma | 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 | +### Meal Plan (3 models) + +| Model | Champs cles | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| MealPlan | id, communityId, startDate(@db.Date), endDate(@db.Date), status(MealPlanStatus), defaultServings, editableByMembers | 1 ACTIVE par communaute (applicatif). @@index(communityId,status) | +| MealSlot | id, planId, date(@db.Date), mealTime(MealTime), servings, type(MealSlotType), disabled, locked, recipeId?, freeText?, comment?, updatedById? | Cascade on plan delete. @@unique(planId,date,mealTime) | +| MealIdea | id, communityId, name, comment?, recipeId?, createdById?, deletedAt? | Soft delete. @@index(communityId,deletedAt) | + +### Meal Generation (4 models) + +| Model | Champs cles | Notes | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| MealGenerationParams | id, communityId, name, description?, cooldownDays(default 3), useIdeas(default true), isDefault, deletedAt? | Soft delete. 1 isDefault par communaute (applicatif). @@index(communityId,deletedAt) | +| MealSlotExclusion | id, paramsId, day(DayOfWeek), mealTime(MealTime) | Pivot, Cascade on params delete. @@unique(paramsId,day,mealTime) | +| MealGenerationRule | id, paramsId, tagId?, recipeId?, weight(default 1.0), mealTimeConstraint?, frequencyMin?, frequencyMax?, frequencyPer?, tagCooldownDays? | Cascade on params delete. SetNull on tag/recipe delete. @@index(paramsId) | +| MealSlotPin | id, paramsId, day(DayOfWeek), mealTime(MealTime), tagId | Cascade on params+tag delete. @@unique(paramsId,day,mealTime) | + ### Analytics (2 models - futur) | Model | Champs cles | Notes | @@ -66,6 +83,12 @@ DB: PostgreSQL | ORM: Prisma | RecipeAnalytics | recipeId(unique), views, shares, forks | Cascade delete | | RecipeView | recipeId, userId?, viewedAt | Cascade delete | +### Changelog (1 model) + +| Model | Champs cles | Notes | +| -------------- | ------------------------------------------------------------------ | ---------------------------------------- | +| ChangelogEntry | id, version(unique), title, content(Json), publishedAt, deletedAt? | Soft delete, index publishedAt+deletedAt | + ### Activity (1 model) | Model | Champs cles | Notes | @@ -89,6 +112,12 @@ IngredientStatus: APPROVED | PENDING NotificationCategory: INVITATION | RECIPE_PROPOSAL | TAG | INGREDIENT | MODERATION +DayOfWeek: MON | TUE | WED | THU | FRI | SAT | SUN +MealTime: LUNCH | DINNER +MealSlotType: EMPTY | RECIPE | FREE_TEXT +MealPlanStatus: ACTIVE | ARCHIVED +FrequencyPer: PER_WEEK | PER_PLANNING + AdminActionType: TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_MERGED | INGREDIENT_CREATED | INGREDIENT_UPDATED | INGREDIENT_DELETED | INGREDIENT_MERGED | INGREDIENT_APPROVED | INGREDIENT_REJECTED | @@ -96,6 +125,7 @@ AdminActionType: TAG_CREATED | TAG_UPDATED | TAG_DELETED | TAG_MERGED | COMMUNITY_RENAMED | COMMUNITY_DELETED | RECIPE_UPDATED | RECIPE_DELETED | FEATURE_CREATED | FEATURE_UPDATED | FEATURE_GRANTED | FEATURE_REVOKED | + CHANGELOG_CREATED | CHANGELOG_UPDATED | CHANGELOG_DELETED | ADMIN_LOGIN | ADMIN_LOGOUT | ADMIN_TOTP_SETUP ActivityType: RECIPE_CREATED | RECIPE_UPDATED | RECIPE_DELETED | RECIPE_SHARED | @@ -135,13 +165,30 @@ 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) +Community <-1:N-> MealPlan +Community <-1:N-> MealIdea +MealPlan <-1:N-> MealSlot (cascade on delete) +MealSlot -> Recipe? (recipeId) +MealSlot -> User? (updatedById, SetNull) +MealIdea -> Recipe? (recipeId) +MealIdea -> User? (createdById, SetNull) +Community <-1:N-> MealGenerationParams +MealGenerationParams <-1:N-> MealSlotExclusion (cascade on delete) +MealGenerationParams <-1:N-> MealGenerationRule (cascade on delete) +MealGenerationParams <-1:N-> MealSlotPin (cascade on delete) +MealGenerationRule -> Tag? (SetNull on delete) +MealGenerationRule -> Recipe? (SetNull on delete) +MealSlotPin -> Tag (cascade on delete) +Tag <-1:N-> MealGenerationRule +Tag <-1:N-> MealSlotPin +Recipe <-1:N-> MealGenerationRule 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, RecipeStep, ProposalIngredient, ProposalStep, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, Notification (via User/Community), NotificationPreference | DB cascade | -| Soft revoke | CommunityFeature | revokedAt timestamp | +| Type | Modeles | Methode | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| Soft delete (deletedAt) | User, Community, UserCommunity, Recipe, RecipeUpdateProposal, CommunityInvite, ChangelogEntry, MealIdea, MealGenerationParams | Applicatif (where deletedAt: null) | +| Hard delete (Cascade) | RecipeTag, RecipeIngredient, RecipeStep, ProposalIngredient, ProposalStep, RecipeAnalytics, RecipeView, TagSuggestion (via Recipe), UserCommunityTagPreference, Notification (via User/Community), NotificationPreference, MealSlot (via MealPlan), MealSlotExclusion, MealGenerationRule, MealSlotPin (via MealGenerationParams) | DB cascade | +| Soft revoke | CommunityFeature | revokedAt timestamp | diff --git a/.claude/context/FILE_MAP.md b/.claude/context/FILE_MAP.md index a8c2e9b..e6dbd8b 100644 --- a/.claude/context/FILE_MAP.md +++ b/.claude/context/FILE_MAP.md @@ -24,6 +24,11 @@ controllers/ ├── tagSuggestions.ts # create, accept, reject tag suggestions ├── tags.ts # autocomplete tags (scope-aware) ├── ingredients.ts # autocomplete ingredients + suggested-unit +├── changelog.ts # getAll, getById (user-facing) +├── mealPlan.ts # getActivePlan, createPlan, deletePlan, updatePlan, updateSlot, swapSlots, getArchives, getArchiveDetail, deleteArchive +├── mealGeneration.ts # generatePlan, replaceSlot +├── mealIdeas.ts # listIdeas, createIdea, updateIdea, deleteIdea +├── mealGenerationParams.ts # listParams, createParams, getParamsDetail, updateParams, deleteParams ├── units.ts # list units grouped by category └── users.ts # search users, update profile ``` @@ -38,6 +43,10 @@ routes/ ├── proposals.ts # /api/proposals/:id, /api/proposals/:id/accept|reject ├── recipes.ts # /api/recipes/* (incl. /api/recipes/:id/proposals) ├── tagSuggestions.ts # /api/tag-suggestions/* +├── changelog.ts # /api/changelog +├── mealPlan.ts # /api/communities/:id/meal-plan (feature-gated) +├── mealIdeas.ts # /api/communities/:id/meal-ideas (feature-gated) +├── mealGenerationParams.ts # /api/communities/:id/meal-generation-params (feature-gated) ├── tags.ts # /api/tags ├── ingredients.ts # /api/ingredients ├── units.ts # /api/units @@ -50,6 +59,7 @@ routes/ middleware/ ├── auth.ts # requireAuth (verifie session.userId) ├── community.ts # memberOf, requireCommunityRole +├── requireFeature.ts # requireFeature(code) — verifie feature activee pour communaute ├── httpLogger.ts # pino-http middleware (remplace morgan) ├── security.ts # helmet, CORS, rate limiting ├── csrf.ts # CSRF protection middleware @@ -69,6 +79,7 @@ admin/ │ ├── ingredientsController.ts # CRUD + merge + approve/reject ingredients │ ├── unitsController.ts # CRUD units (+ usage protection) │ ├── featuresController.ts # CRUD features +│ ├── changelogController.ts # CRUD changelog entries │ ├── dashboardController.ts # stats globales │ └── activityController.ts # logs activite admin ├── routes/ @@ -79,6 +90,7 @@ admin/ │ ├── ingredientsRoutes.ts │ ├── unitsRoutes.ts │ ├── featuresRoutes.ts +│ ├── changelogRoutes.ts │ ├── dashboardRoutes.ts │ └── activityRoutes.ts └── middleware/ @@ -100,6 +112,8 @@ services/ ├── recipeImportService.ts # importFromUrl, parseIngredientLine, parseIsoDuration (JSON-LD extraction) ├── tagSuggestionService.ts # create, accept, reject tag suggestions ├── storageService.ts # MinIO/S3 : presigned URL, headObject, deleteObject, validateUploadedFile +├── mealGeneration.ts # Algorithme generation planning (passe principale, rattrapage, rapport) +├── mealGenerationService.ts # DB helpers (loadGenerationParams, buildPool, buildPreviousSlots, slotsToSlotInfo) ├── eventEmitter.ts # AppEventEmitter singleton (emit activity events) └── socketServer.ts # Socket.IO server init, auth, rooms, notification persistence ``` @@ -118,7 +132,7 @@ util/ ├── logger.ts # Logger Pino central (silent test, pretty dev, JSON prod) ├── pagination.ts # parsePagination, buildPaginationMeta ├── validation.ts # normalizeNames, isValidHttpUrl, regex constants, validateServings, validateTime, validateSteps -├── responseFormatters.ts # formatTags, formatIngredients, formatSteps +├── responseFormatters.ts # formatTags, formatIngredients, formatSteps, formatDeletedRelation ├── prismaSelects.ts # RECIPE_TAGS_SELECT, RECIPE_STEPS_SELECT, PROPOSAL_STEPS_SELECT, PROPOSAL_INGREDIENTS_SELECT ├── db.ts # Prisma client singleton └── validateEnv.ts # envalid env vars @@ -126,7 +140,8 @@ 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 +├── createAdmin.ts # CLI creation SuperAdmin +└── insertChangelog.ts # CLI insert changelog entry (Portainer exec from CI) ``` ### Tests backend @@ -135,7 +150,7 @@ scripts/ __tests__/ ├── setup/ │ ├── globalSetup.ts # Setup DB test -│ └── testHelpers.ts # createTestUser, cleanupTestData, etc. +│ └── testHelpers.ts # createTestUser, cleanupTestData, createMealTestContext, etc. ├── unit/ │ ├── eventEmitter.test.ts # Event emitter unit tests │ ├── pagination.test.ts # Pagination utils @@ -181,6 +196,14 @@ __tests__/ ├── recipeImage.test.ts # Recipe image upload endpoints ├── communityImage.test.ts # Community image upload endpoints ├── imageCleanup.test.ts # Image cleanup cron job + ├── adminChangelog.test.ts # Admin changelog CRUD (17 tests) + ├── changelog.test.ts # User changelog endpoints (7 tests) + ├── requireFeature.test.ts # requireFeature middleware (3 tests) + ├── mealPlan.test.ts # Meal plan CRUD, slots, swap, archives, permissions (39 tests) + ├── mealIdeas.test.ts # Meal ideas CRUD, permissions (25 tests) + ├── mealGenerationParams.test.ts # Generation params CRUD, isDefault, permissions (26 tests) + ├── mealGenerationRules.test.ts # Exclusions, rules, pins CRUD + validations (35 tests) + ├── mealGenerate.test.ts # Generate plan, replace slot, hasDefaultGenerationParams (17 tests) └── users.test.ts # User profile update ``` @@ -206,6 +229,8 @@ pages/ ├── ProfilePage.tsx # Profil utilisateur (edit username/email/password) ├── SignUpPage.tsx # Inscription ├── PrivacyPage.tsx # Politique confidentialite +├── ChangelogPage.tsx # Page changelog user (cartes, pagination) +├── MealPlanPage.tsx # Page planning repas (creation, grille, archives, idees) ├── NotFoundPage.tsx # 404 └── admin/ ├── AdminLoginPage.tsx # Login admin 2FA @@ -215,6 +240,7 @@ pages/ ├── AdminUnitsPage.tsx # CRUD units (category filter, sortOrder) ├── AdminFeaturesPage.tsx # CRUD features (code, name, isDefault) ├── AdminCommunitiesPage.tsx # Liste, detail, delete, grant/revoke features + ├── AdminChangelogPage.tsx # CRUD changelog admin (table, modals) └── AdminActivityPage.tsx # Logs activite admin paginee ``` @@ -283,6 +309,19 @@ components/ ├── ImagePicker.tsx # Selection image pour creation (preview, processImage) ├── ImportRecipeModal.tsx # Modal import recette (texte brut ou URL) ├── AddEditRecipeDialog.tsx # Dialog creation/edition +├── mealPlan/ +│ ├── CreatePlanModal.tsx # Modal creation plan (dates, servings, preview) +│ ├── MealPlanGrid.tsx # Grille planning (desktop + mobile, lock toggle) +│ ├── MealPlanSettings.tsx # Modal parametres plan +│ ├── SlotEditModal.tsx # Modal edition slot +│ ├── MealPlanArchives.tsx # Onglet archives +│ ├── MealIdeasPanel.tsx # Onglet idees de repas +│ ├── GenerationParamsPanel.tsx # Onglet parametres generation (list + detail) +│ ├── ParamsFormModal.tsx # Modal creation/edition jeu de params +│ ├── ExclusionPinGrid.tsx # Grilles 7x2 exclusions + pins tag (mobile: cards verticales) +│ ├── RulesEditor.tsx # Edition inline regles tag + recette (CRUD, slider poids, frequence) +│ ├── GenerateModal.tsx # Modal generation (selecteur params, fillEmptyOnly, confirmation) +│ └── GenerationReportPanel.tsx # Affichage rapport post-generation (stats, warnings) ├── ErrorBoundary.tsx # Error boundary React (crash → fallback UI) ├── LoginModal.tsx # Modal login ├── Modal.tsx # Composant modal generique @@ -317,7 +356,9 @@ models/ ├── community.ts # Community, Member, Invite types ├── preferences.ts # TagPreference types ├── notification.ts # Notification, NotificationCategory, preferences types -└── admin.ts # AdminUser types +├── changelog.ts # ChangelogEntry, ChangelogContent, ChangelogResponse types +├── mealPlan.ts # MealPlan, MealSlot, MealIdea, API input/response types +└── admin.ts # AdminUser types (incl. AdminChangelogEntry) ``` ### Autres frontend @@ -394,6 +435,7 @@ __tests__/ │ ├── RecipesPage.test.tsx │ ├── RecipeFormPage.test.tsx │ ├── RecipeDetailPage.mobile.test.tsx # Mobile rework Phase 3 (4 tests) + │ ├── ChangelogPage.test.tsx │ ├── SignUpPage.test.tsx │ └── admin/ │ ├── AdminLoginPage.test.tsx @@ -403,6 +445,7 @@ __tests__/ │ ├── AdminUnitsPage.test.tsx │ ├── AdminFeaturesPage.test.tsx │ ├── AdminCommunitiesPage.test.tsx + │ ├── AdminChangelogPage.test.tsx │ └── AdminActivityPage.test.tsx ├── services/ │ └── recipeParser.test.ts # Parsing texte brut recette (65 tests) @@ -464,7 +507,8 @@ docker-compose.yml # Dev (postgres, backend:3001, frontend:3000) docker-compose.test.yml # DB test (postgres:5433, tmpfs) docker-compose.prod.yml # Production docker-compose.preprod.yml # Pre-production -.github/workflows/deploy.yml # CI/CD (test → build → deploy) +scripts/generate-changelog.js # Parse conventional commits → JSON changelog (CI) +.github/workflows/deploy.yml # CI/CD (test → build → deploy → changelog) .env.example # Variables d'environnement package.json # Scripts racine (docker, test) ``` diff --git a/.claude/context/PROGRESS.md b/.claude/context/PROGRESS.md index 52cd9ee..923827e 100644 --- a/.claude/context/PROGRESS.md +++ b/.claude/context/PROGRESS.md @@ -14,11 +14,35 @@ Phases 0 a 17 terminees (tags rework, ingredients rework, notifications, recipe - **Spec** : `docs/features/recipe-import/SPEC_RECIPE_IMPORT.md` - **Roadmap** : `docs/features/recipe-import/ROADMAP.md` +## Feature terminee : Changelog Automatique (Phases 1-8) + +- **Spec** : `docs/features/changelog/SPEC_CHANGELOG.md` +- **Roadmap** : `docs/features/changelog/ROADMAP.md` + ## Feature planifiee : E2E Testing - **Spec** : `docs/features/e2e-testing/SPEC_E2E_TESTING.md` - **Roadmap** : `docs/features/e2e-testing/ROADMAP.md` +## Feature terminee : Meal Plan (Planning Manuel) + +- **Spec** : `docs/features/meal-plan/SPEC_MEAL_PLAN.md` +- **Roadmap** : `docs/features/meal-plan/ROADMAP.md` +- Phases 1-13 terminees (modeles, API backend, frontend complet) + +## Feature terminee : Meal Generation (Automatique) + +- **Spec** : `docs/features/meal-plan/SPEC_MEAL_GENERATION.md` +- **Roadmap** : `docs/features/meal-plan/ROADMAP_GENERATION.md` +- Phases 1-12 terminees + +## Feature terminee : Nettoyage dependances (Phases 1-7) + +- **Spec** : `docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md` +- **Roadmap** : `docs/features/deps-cleanup/ROADMAP.md` +- Phases 1-6 terminees : classnames, usehooks-ts, @types/helmet, read, envalid supprimes ; axios remplace par fetch natif +- Reste : tests manuels (login/logout, recettes, upload, CSRF, erreurs 401/409) + ## Idees futures Voir `docs/0 - brainstorming futur.md` diff --git a/.claude/context/RESUME.md b/.claude/context/RESUME.md new file mode 100644 index 0000000..19897b3 --- /dev/null +++ b/.claude/context/RESUME.md @@ -0,0 +1,25 @@ +# Resume de reprise — Meal Generation Phase 12 + +## Tache en cours + +Phase 12 du ROADMAP_GENERATION : Mise a jour docs & contexte — en cours + +## Ce qui est fait (Phases 1-11) + +- Backend complet : modeles, API CRUD params/exclusions/rules/pins, algorithme generation, endpoints generate + replace +- Frontend complet : verrouillage slots, page params, exclusions/pins, regles inline, generation (bouton + modal + rapport), replace sur cartes slots, adaptation mobile + +## Rapport qualite (post Phase 11, 2026-03-19) + +| Check | Status | +| -------------- | --------------------------------------------------------------------- | +| Frontend TS | 0 errors | +| Frontend tests | 546/546 pass | +| Backend TS | 5 errors pre-existants (imageKey dans proposalService, recipeService) | +| Backend tests | 1010/1010 pass | + +### A corriger avant merge + +- **Backend lint** : 3 unused vars dans `backend/src/services/mealGeneration.ts` +- **npm audit** : `npm audit fix` dans frontend/ et backend/ pour socket.io-parser +- **Backend TS** : 5 erreurs imageKey pre-existantes (hors scope meal-generation) diff --git a/.claude/context/TESTS.md b/.claude/context/TESTS.md index 405475f..3c4f74c 100644 --- a/.claude/context/TESTS.md +++ b/.claude/context/TESTS.md @@ -42,52 +42,60 @@ 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 (~802 backend + ~469 frontend = ~1271 tests) +## Inventaire des tests (~1010 backend + ~546 frontend = ~1556 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) +### Backend Integration (39 fichiers, ~790 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 | +| adminChangelog.test.ts | Admin changelog CRUD (list, create, update, delete, audit) | 17 | +| changelog.test.ts | User changelog endpoints (list, detail, auth, soft-delete filter) | 7 | +| requireFeature.test.ts | requireFeature middleware (generic feature guard) | 3 | +| mealPlan.test.ts | Meal plan CRUD, slots, swap, archives, permissions, feature guard | 39 | +| mealIdeas.test.ts | Meal ideas CRUD, permissions, feature guard | 25 | +| mealGenerationParams.test.ts | Generation params CRUD, isDefault, permissions | 26 | +| mealGenerationRules.test.ts | Exclusions, rules, pins CRUD + validations | 35 | +| mealGenerate.test.ts | Generate plan, replace slot, hasDefaultGenerationParams, permissions | 17 | +| users.test.ts | User profile update (username, email, password) | 4 | + +### Backend Unit (11 fichiers, ~168 tests) | Fichier | Module | Tests | | ------------------------------------ | ---------------------------------------------------------------------------------------- | ----- | @@ -101,8 +109,9 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | 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 | +| mealGeneration.test.ts | Meal generation algorithm (weighted random, cooldowns, frequency, pins) | 31 | -### Frontend Unit (63 fichiers, ~469 tests) +### Frontend Unit (76 fichiers, ~546 tests) | Fichier | Composant | Tests | | ----------------------------------------------- | --------------------------------------------------------------------- | ----- | @@ -125,7 +134,9 @@ npx vitest run src/__tests__/unit/NomFichier.test.tsx # Un seul fichier | 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/AdminChangelogPage.test.tsx | Page changelog admin (CRUD, modals, filters) | 10 | | pages/admin/AdminActivityPage.test.tsx | Page activity admin | 5 | +| pages/ChangelogPage.test.tsx | Page changelog user (list, categories, badges) | 6 | | RecipeCard.test.tsx | Carte recette (+ image) | 12 | | RecipeFilters.test.tsx | Filtres recettes | 9 | | TagSelector.test.tsx | Selecteur tags | 9 | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72218ca..a0d1645 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -312,6 +312,138 @@ jobs: SESSION_SECRET: ${{ secrets.SESSION_SECRET }} ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} + # ======================================== + # Generate Changelog (after prod deploy) + # ======================================== + generate-changelog: + runs-on: ubuntu-latest + needs: [deploy-prod] + if: needs.deploy-prod.result == 'success' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Determine last version tag + id: last-tag + run: | + LAST_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + echo "No previous tag found, using initial commit" + echo "tag=" >> $GITHUB_OUTPUT + echo "version=0.0.0" >> $GITHUB_OUTPUT + else + echo "Last tag: $LAST_TAG" + echo "tag=$LAST_TAG" >> $GITHUB_OUTPUT + echo "version=${LAST_TAG#v}" >> $GITHUB_OUTPUT + fi + + - name: Parse commits and generate changelog + id: changelog + run: | + LAST_TAG="${{ steps.last-tag.outputs.tag }}" + LAST_VERSION="${{ steps.last-tag.outputs.version }}" + + if [ -z "$LAST_TAG" ]; then + COMMITS=$(git log --oneline) + else + COMMITS=$(git log --oneline "${LAST_TAG}..HEAD") + fi + + if [ -z "$COMMITS" ]; then + echo "No new commits" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # Generate changelog JSON + RESULT=$(echo "$COMMITS" | node scripts/generate-changelog.js "$LAST_VERSION" 2>/dev/null) || { + echo "No user-facing changes, skipping changelog" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + } + + echo "Generated changelog: $RESULT" + echo "json=$RESULT" >> $GITHUB_OUTPUT + NEW_VERSION=$(echo "$RESULT" | jq -r '.version') + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "skip=false" >> $GITHUB_OUTPUT + + - name: Insert changelog via Portainer exec + if: steps.changelog.outputs.skip != 'true' + run: | + CHANGELOG_JSON='${{ steps.changelog.outputs.json }}' + + # 1. Find the backend container + CONTAINERS=$(curl -sf -H "X-API-Key: ${PORTAINER_API}" \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json" \ + | jq '[.[] | select(.Names[] | contains("forestmanager-backend"))]') + + CONTAINER_ID=$(echo "$CONTAINERS" | jq -r '.[0].Id') + if [ -z "$CONTAINER_ID" ] || [ "$CONTAINER_ID" = "null" ]; then + echo "ERROR: Backend container not found" + exit 1 + fi + echo "Found backend container: ${CONTAINER_ID:0:12}" + + # 2. Create exec (escape JSON for nested embedding) + ESCAPED_JSON=$(echo "$CHANGELOG_JSON" | jq -c '.' | jq -Rs '.') + EXEC_PAYLOAD=$(jq -n \ + --argjson cmd "[\"node\",\"dist/scripts/insertChangelog.js\",${ESCAPED_JSON}]" \ + '{"Cmd":$cmd,"AttachStdout":true,"AttachStderr":true}') + + EXEC_RESPONSE=$(curl -sf -H "X-API-Key: ${PORTAINER_API}" \ + -H "Content-Type: application/json" \ + -d "$EXEC_PAYLOAD" \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/exec") + + EXEC_ID=$(echo "$EXEC_RESPONSE" | jq -r '.Id') + if [ -z "$EXEC_ID" ] || [ "$EXEC_ID" = "null" ]; then + echo "ERROR: Failed to create exec" + echo "Response: $EXEC_RESPONSE" + exit 1 + fi + echo "Created exec: ${EXEC_ID:0:12}" + + # 3. Start exec + EXEC_OUTPUT=$(curl -sf -H "X-API-Key: ${PORTAINER_API}" \ + -H "Content-Type: application/json" \ + -d '{"Detach":false}' \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/exec/${EXEC_ID}/start") + + echo "Exec output: $EXEC_OUTPUT" + + # 4. Check exec exit code + EXEC_INSPECT=$(curl -sf -H "X-API-Key: ${PORTAINER_API}" \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/exec/${EXEC_ID}/json") + EXIT_CODE=$(echo "$EXEC_INSPECT" | jq -r '.ExitCode') + + if [ "$EXIT_CODE" != "0" ]; then + echo "ERROR: Insert script failed with exit code $EXIT_CODE" + exit 1 + fi + + echo "Changelog inserted successfully" + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API: ${{ secrets.PORTAINER_API }} + ENDPOINT_ID: ${{ secrets.ENDPOINT_ID }} + + - name: Create and push git tag + if: steps.changelog.outputs.skip != 'true' + run: | + NEW_VERSION="${{ steps.changelog.outputs.version }}" + git tag "v${NEW_VERSION}" + git push origin "v${NEW_VERSION}" + echo "Tagged and pushed v${NEW_VERSION}" + # ======================================== # Cleanup old images # ======================================== diff --git a/README.md b/README.md index 4c9e1cb..e993801 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Application de gestion de communautes pour le partage de recettes. Communautes p - **Ingredients normalises** : base d'ingredients avec unites, autocomplete intelligent - **Feed d'activite** : activite communautaire et personnelle - **Notifications temps reel** : 5 categories, preferences par communaute, WebSocket, groupement intelligent +- **Planning de repas** : planning communautaire avec grille, verrouillage de slots, archives, idees de repas +- **Generation automatique** : algorithme de generation avec parametres personnalisables, regles par tag/recette, exclusions, epinglages, frequences, cooldowns - **SuperAdmin** : dashboard admin isole, authentification 2FA (TOTP), gestion tags/ingredients/unites/communautes/features ## Stack technique diff --git a/backend/package-lock.json b/backend/package-lock.json index 4f43cfb..2b264fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,11 +13,9 @@ "@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", "express-rate-limit": "^8.2.1", "express-session": "^1.18.1", @@ -28,7 +26,6 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "qrcode": "^1.5.4", - "read": "^5.0.1", "socket.io": "^4.8.3", "zod": "^4.3.6" }, @@ -354,22 +351,22 @@ } }, "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==", + "version": "3.973.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz", + "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==", "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", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.11", + "@smithy/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -378,12 +375,12 @@ } }, "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==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -391,15 +388,15 @@ } }, "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==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz", + "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -407,20 +404,20 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz", + "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.19", "tslib": "^2.6.2" }, "engines": { @@ -428,24 +425,24 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz", + "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-login": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -453,18 +450,18 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz", + "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -472,22 +469,22 @@ } }, "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==", + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz", + "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==", "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", + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-ini": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -495,16 +492,16 @@ } }, "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==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz", + "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -512,18 +509,18 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz", + "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/token-providers": "3.1009.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -531,17 +528,17 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz", + "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -582,23 +579,23 @@ } }, "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==", + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.0.tgz", + "integrity": "sha512-BmdDjqvnuYaC4SY7ypHLXfCSsGYGUZkjCLSZyUAAYn1YT28vbNMJNDwhlfkvvE+hQHG5RJDlEmYuvBxcB9jX1g==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", "@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/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -607,14 +604,14 @@ } }, "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==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -636,13 +633,13 @@ } }, "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==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -650,15 +647,15 @@ } }, "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==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -666,23 +663,23 @@ } }, "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==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.20.tgz", + "integrity": "sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", "@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/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -705,18 +702,18 @@ } }, "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==", + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz", + "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.11", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -724,47 +721,47 @@ } }, "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==", + "version": "3.996.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz", + "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.7", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.11", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-retry": "^4.4.42", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@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-defaults-mode-browser": "^4.3.41", + "@smithy/util-defaults-mode-node": "^4.2.44", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -773,15 +770,15 @@ } }, "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==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", + "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", "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", + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -808,16 +805,16 @@ } }, "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==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.8.tgz", + "integrity": "sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==", "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", + "@aws-sdk/middleware-sdk-s3": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -825,17 +822,17 @@ } }, "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==", + "version": "3.1009.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz", + "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==", "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", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -843,12 +840,12 @@ } }, "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==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -868,15 +865,15 @@ } }, "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==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-endpoints": "^3.3.2", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" }, "engines": { @@ -911,27 +908,28 @@ } }, "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==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "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==", + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz", + "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==", "license": "Apache-2.0", "dependencies": { - "@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", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -947,13 +945,14 @@ } }, "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==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -1043,9 +1042,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -1060,9 +1059,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -1077,9 +1076,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -1094,9 +1093,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -1111,9 +1110,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -1128,9 +1127,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -1145,9 +1144,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -1162,9 +1161,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -1179,9 +1178,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -1196,9 +1195,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -1213,9 +1212,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -1230,9 +1229,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -1247,9 +1246,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -1264,9 +1263,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -1281,9 +1280,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -1298,9 +1297,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -1315,9 +1314,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -1332,9 +1331,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1349,9 +1348,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1366,9 +1365,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1383,9 +1382,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1400,9 +1399,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1417,9 +1416,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1434,9 +1433,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1451,9 +1450,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -1468,9 +1467,9 @@ } }, "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -1529,9 +1528,9 @@ } }, "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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1603,9 +1602,9 @@ } }, "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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1911,6 +1910,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@otplib/core": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz", @@ -2024,54 +2035,66 @@ } }, "node_modules/@prisma/config": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.18.4", + "effect": "3.21.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2", + "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.2", - "@prisma/get-platform": "6.19.2" + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/engines-version": { "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2", + "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.2" + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/get-platform": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2" + "@prisma/debug": "6.19.3" } }, "node_modules/@quixo3/prisma-session-store": { @@ -2450,12 +2473,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2488,16 +2511,16 @@ } }, "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==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz", + "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -2505,18 +2528,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.9", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", - "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@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-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -2526,15 +2549,15 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", "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", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -2612,14 +2635,14 @@ } }, "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==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -2643,12 +2666,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -2672,12 +2695,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2711,13 +2734,13 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2725,18 +2748,18 @@ } }, "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==", + "version": "4.4.26", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.26.tgz", + "integrity": "sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==", "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", + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -2744,18 +2767,18 @@ } }, "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==", + "version": "4.4.43", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.43.tgz", + "integrity": "sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==", "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/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -2764,13 +2787,14 @@ } }, "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==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2778,12 +2802,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2791,14 +2815,14 @@ } }, "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==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2806,15 +2830,15 @@ } }, "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==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", "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", + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2822,12 +2846,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2835,12 +2859,12 @@ } }, "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==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2848,12 +2872,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -2862,12 +2886,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2875,24 +2899,24 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0" + "@smithy/types": "^4.13.1" }, "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==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -2900,16 +2924,16 @@ } }, "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==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -2919,17 +2943,17 @@ } }, "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==", + "version": "4.12.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.6.tgz", + "integrity": "sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==", "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", + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" }, "engines": { @@ -2937,9 +2961,9 @@ } }, "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==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2949,13 +2973,13 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3026,14 +3050,14 @@ } }, "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==", + "version": "4.3.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.42.tgz", + "integrity": "sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3041,17 +3065,17 @@ } }, "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==", + "version": "4.2.45", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.45.tgz", + "integrity": "sha512-q5dOqqfTgUcLe38TAGiFn9srToKj2YCHJ34QGOLzM+xYLLA+qRZv7N+33kl1MERVusue36ZHnlNaNEvY/PzSrw==", "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", + "@smithy/config-resolver": "^4.4.11", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3059,13 +3083,13 @@ } }, "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==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3085,12 +3109,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3098,13 +3122,13 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3112,14 +3136,14 @@ } }, "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==", + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", "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/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -3189,6 +3213,8 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true, "license": "MIT" }, @@ -3234,6 +3260,7 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -3255,6 +3282,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3294,6 +3322,7 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -3306,6 +3335,7 @@ "version": "4.19.8", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3324,19 +3354,11 @@ "@types/express": "*" } }, - "node_modules/@types/helmet": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz", - "integrity": "sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3357,6 +3379,7 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -3389,18 +3412,21 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3410,6 +3436,7 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -3421,6 +3448,7 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -4094,9 +4122,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4125,6 +4153,8 @@ }, "node_modules/c12": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4152,6 +4182,8 @@ }, "node_modules/c12/node_modules/chokidar": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4166,6 +4198,8 @@ }, "node_modules/c12/node_modules/readdirp": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "devOptional": true, "license": "MIT", "engines": { @@ -4356,6 +4390,8 @@ }, "node_modules/citty": { "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4427,12 +4463,16 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.2", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "devOptional": true, "license": "MIT", "engines": { @@ -4592,6 +4632,8 @@ }, "node_modules/deepmerge-ts": { "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "devOptional": true, "license": "BSD-3-Clause", "engines": { @@ -4599,7 +4641,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "devOptional": true, "license": "MIT" }, @@ -4622,6 +4666,8 @@ }, "node_modules/destr": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "devOptional": true, "license": "MIT" }, @@ -4717,6 +4763,8 @@ }, "node_modules/dotenv": { "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "devOptional": true, "license": "BSD-2-Clause", "engines": { @@ -4750,7 +4798,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.18.4", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4764,6 +4814,8 @@ }, "node_modules/empathic": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "devOptional": true, "license": "MIT", "engines": { @@ -4867,16 +4919,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/envalid": { - "version": "8.1.1", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -4925,9 +4967,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4938,32 +4980,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escape-html": { @@ -5090,9 +5132,9 @@ } }, "node_modules/eslint/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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5285,12 +5327,12 @@ } }, "node_modules/express-rate-limit": { - "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==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5347,11 +5389,15 @@ }, "node_modules/exsolve": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "devOptional": true, "funding": [ { @@ -5407,9 +5453,9 @@ "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==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -5418,13 +5464,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.2" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "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==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", "funding": [ { "type": "github", @@ -5433,8 +5480,11 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5525,9 +5575,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5676,6 +5726,8 @@ }, "node_modules/giget": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5891,9 +5943,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -6305,15 +6357,6 @@ "version": "2.1.3", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6367,6 +6410,8 @@ }, "node_modules/node-fetch-native": { "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "devOptional": true, "license": "MIT" }, @@ -6411,9 +6456,9 @@ } }, "node_modules/nodemon/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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -6480,13 +6525,15 @@ } }, "node_modules/nypm": { - "version": "0.6.4", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", "devOptional": true, "license": "MIT", "dependencies": { - "citty": "^0.2.0", + "citty": "^0.2.2", "pathe": "^2.0.3", - "tinyexec": "^1.0.2" + "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" @@ -6496,7 +6543,9 @@ } }, "node_modules/nypm/node_modules/citty": { - "version": "0.2.0", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "devOptional": true, "license": "MIT" }, @@ -6519,6 +6568,8 @@ }, "node_modules/ohash": { "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "devOptional": true, "license": "MIT" }, @@ -6715,9 +6766,9 @@ } }, "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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -6757,7 +6808,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { @@ -6777,6 +6830,8 @@ }, "node_modules/perfect-debounce": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "devOptional": true, "license": "MIT" }, @@ -6788,9 +6843,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6888,12 +6943,14 @@ "license": "MIT" }, "node_modules/pkg-types": { - "version": "2.3.0", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "devOptional": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -6907,9 +6964,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -6946,13 +7003,15 @@ } }, "node_modules/prisma": { - "version": "6.19.2", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.19.2", - "@prisma/engines": "6.19.2" + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" }, "bin": { "prisma": "build/index.js" @@ -7026,6 +7085,8 @@ }, "node_modules/pure-rand": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "devOptional": true, "funding": [ { @@ -7106,6 +7167,8 @@ }, "node_modules/rc9": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -7113,18 +7176,6 @@ "destr": "^2.0.3" } }, - "node_modules/read": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/read/-/read-5.0.1.tgz", - "integrity": "sha512-+nsqpqYkkpet2UVPG8ZiuE8d113DK4vHYEoEhcrXBAlPiq6di7QRTuNiKQAbaRYegobuX2BpZ6QjanKOXnJdTA==", - "license": "ISC", - "dependencies": { - "mute-stream": "^3.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7482,9 +7533,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -7629,9 +7680,9 @@ "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==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -7788,7 +7839,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "devOptional": true, "license": "MIT", "engines": { @@ -7831,9 +7884,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8115,24 +8168,24 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -8141,14 +8194,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -8231,9 +8284,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8317,9 +8370,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8478,6 +8531,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 585c999..540ae7c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,16 +51,17 @@ "typescript-eslint": "^8.55.0", "vitest": "^3.2.4" }, + "overrides": { + "fast-xml-parser": "^5.7.0" + }, "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", "express-rate-limit": "^8.2.1", "express-session": "^1.18.1", @@ -71,7 +72,6 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "qrcode": "^1.5.4", - "read": "^5.0.1", "socket.io": "^4.8.3", "zod": "^4.3.6" } diff --git a/backend/prisma/migrations/20260318075818_add_changelog_entry/migration.sql b/backend/prisma/migrations/20260318075818_add_changelog_entry/migration.sql new file mode 100644 index 0000000..c99cac7 --- /dev/null +++ b/backend/prisma/migrations/20260318075818_add_changelog_entry/migration.sql @@ -0,0 +1,34 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AdminActionType" ADD VALUE 'CHANGELOG_CREATED'; +ALTER TYPE "AdminActionType" ADD VALUE 'CHANGELOG_UPDATED'; +ALTER TYPE "AdminActionType" ADD VALUE 'CHANGELOG_DELETED'; + +-- CreateTable +CREATE TABLE "ChangelogEntry" ( + "id" TEXT NOT NULL, + "version" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" JSONB NOT NULL, + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ChangelogEntry_version_key" ON "ChangelogEntry"("version"); + +-- CreateIndex +CREATE INDEX "ChangelogEntry_publishedAt_idx" ON "ChangelogEntry"("publishedAt"); + +-- CreateIndex +CREATE INDEX "ChangelogEntry_deletedAt_idx" ON "ChangelogEntry"("deletedAt"); diff --git a/backend/prisma/migrations/20260318135646_add_meal_plan_models/migration.sql b/backend/prisma/migrations/20260318135646_add_meal_plan_models/migration.sql new file mode 100644 index 0000000..78d8bf3 --- /dev/null +++ b/backend/prisma/migrations/20260318135646_add_meal_plan_models/migration.sql @@ -0,0 +1,93 @@ +-- CreateEnum +CREATE TYPE "DayOfWeek" AS ENUM ('MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'); + +-- CreateEnum +CREATE TYPE "MealTime" AS ENUM ('LUNCH', 'DINNER'); + +-- CreateEnum +CREATE TYPE "MealSlotType" AS ENUM ('EMPTY', 'RECIPE', 'FREE_TEXT'); + +-- CreateEnum +CREATE TYPE "MealPlanStatus" AS ENUM ('ACTIVE', 'ARCHIVED'); + +-- CreateTable +CREATE TABLE "MealPlan" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "status" "MealPlanStatus" NOT NULL DEFAULT 'ACTIVE', + "defaultServings" INTEGER NOT NULL DEFAULT 4, + "editableByMembers" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MealPlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MealSlot" ( + "id" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "date" DATE NOT NULL, + "mealTime" "MealTime" NOT NULL, + "servings" INTEGER NOT NULL, + "type" "MealSlotType" NOT NULL DEFAULT 'EMPTY', + "disabled" BOOLEAN NOT NULL DEFAULT false, + "locked" BOOLEAN NOT NULL DEFAULT false, + "recipeId" TEXT, + "freeText" TEXT, + "comment" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedById" TEXT, + + CONSTRAINT "MealSlot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MealIdea" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "comment" TEXT, + "recipeId" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "MealIdea_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MealPlan_communityId_status_idx" ON "MealPlan"("communityId", "status"); + +-- CreateIndex +CREATE INDEX "MealSlot_planId_date_idx" ON "MealSlot"("planId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "MealSlot_planId_date_mealTime_key" ON "MealSlot"("planId", "date", "mealTime"); + +-- CreateIndex +CREATE INDEX "MealIdea_communityId_deletedAt_idx" ON "MealIdea"("communityId", "deletedAt"); + +-- AddForeignKey +ALTER TABLE "MealPlan" ADD CONSTRAINT "MealPlan_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlot" ADD CONSTRAINT "MealSlot_planId_fkey" FOREIGN KEY ("planId") REFERENCES "MealPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlot" ADD CONSTRAINT "MealSlot_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlot" ADD CONSTRAINT "MealSlot_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealIdea" ADD CONSTRAINT "MealIdea_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealIdea" ADD CONSTRAINT "MealIdea_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealIdea" ADD CONSTRAINT "MealIdea_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260319073334_add_meal_generation_models/migration.sql b/backend/prisma/migrations/20260319073334_add_meal_generation_models/migration.sql new file mode 100644 index 0000000..f873b9f --- /dev/null +++ b/backend/prisma/migrations/20260319073334_add_meal_generation_models/migration.sql @@ -0,0 +1,88 @@ +-- CreateEnum +CREATE TYPE "FrequencyPer" AS ENUM ('PER_WEEK', 'PER_PLANNING'); + +-- CreateTable +CREATE TABLE "MealGenerationParams" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "cooldownDays" INTEGER NOT NULL DEFAULT 3, + "useIdeas" BOOLEAN NOT NULL DEFAULT true, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "MealGenerationParams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MealSlotExclusion" ( + "id" TEXT NOT NULL, + "paramsId" TEXT NOT NULL, + "day" "DayOfWeek" NOT NULL, + "mealTime" "MealTime" NOT NULL, + + CONSTRAINT "MealSlotExclusion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MealGenerationRule" ( + "id" TEXT NOT NULL, + "paramsId" TEXT NOT NULL, + "tagId" TEXT, + "recipeId" TEXT, + "weight" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "mealTimeConstraint" "MealTime", + "frequencyMin" INTEGER, + "frequencyMax" INTEGER, + "frequencyPer" "FrequencyPer", + "tagCooldownDays" INTEGER, + + CONSTRAINT "MealGenerationRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MealSlotPin" ( + "id" TEXT NOT NULL, + "paramsId" TEXT NOT NULL, + "day" "DayOfWeek" NOT NULL, + "mealTime" "MealTime" NOT NULL, + "tagId" TEXT NOT NULL, + + CONSTRAINT "MealSlotPin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MealGenerationParams_communityId_deletedAt_idx" ON "MealGenerationParams"("communityId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "MealSlotExclusion_paramsId_day_mealTime_key" ON "MealSlotExclusion"("paramsId", "day", "mealTime"); + +-- CreateIndex +CREATE INDEX "MealGenerationRule_paramsId_idx" ON "MealGenerationRule"("paramsId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MealSlotPin_paramsId_day_mealTime_key" ON "MealSlotPin"("paramsId", "day", "mealTime"); + +-- AddForeignKey +ALTER TABLE "MealGenerationParams" ADD CONSTRAINT "MealGenerationParams_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlotExclusion" ADD CONSTRAINT "MealSlotExclusion_paramsId_fkey" FOREIGN KEY ("paramsId") REFERENCES "MealGenerationParams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealGenerationRule" ADD CONSTRAINT "MealGenerationRule_paramsId_fkey" FOREIGN KEY ("paramsId") REFERENCES "MealGenerationParams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealGenerationRule" ADD CONSTRAINT "MealGenerationRule_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealGenerationRule" ADD CONSTRAINT "MealGenerationRule_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlotPin" ADD CONSTRAINT "MealSlotPin_paramsId_fkey" FOREIGN KEY ("paramsId") REFERENCES "MealGenerationParams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MealSlotPin" ADD CONSTRAINT "MealSlotPin_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 08c7761..5a6a11f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -154,12 +154,35 @@ enum AdminActionType { RECIPE_UPDATED RECIPE_DELETED + // Changelog + CHANGELOG_CREATED + CHANGELOG_UPDATED + CHANGELOG_DELETED + // Admin auth ADMIN_LOGIN ADMIN_LOGOUT ADMIN_TOTP_SETUP } +// ============================================================================= +// CHANGELOG ENTRY (Entrees du changelog automatique) +// ============================================================================= + +model ChangelogEntry { + id String @id @default(uuid()) + version String @unique // semver "1.2.0" + title String // titre libre + content Json // { features: [{text}], improvements: [{text}], fixes: [{text}] } + publishedAt DateTime @default(now()) // date de publication (= date deploy) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // soft delete admin + + @@index([publishedAt]) + @@index([deletedAt]) +} + // ============================================================================= // USER // ============================================================================= @@ -188,6 +211,8 @@ model User { notifications Notification[] @relation("NotificationRecipient") notificationsActed Notification[] @relation("NotificationActor") notificationPrefs NotificationPreference[] + mealSlotsUpdated MealSlot[] + mealIdeasCreated MealIdea[] @@index([email]) @@index([username]) @@ -219,6 +244,9 @@ model Community { tagPreferences UserCommunityTagPreference[] notifications Notification[] notificationPrefs NotificationPreference[] + mealPlans MealPlan[] + mealIdeas MealIdea[] + mealGenerationParams MealGenerationParams[] @@index([deletedAt]) } @@ -336,6 +364,9 @@ model Recipe { activities ActivityLog[] tagSuggestions TagSuggestion[] notifications Notification[] + mealSlots MealSlot[] + mealIdeas MealIdea[] + generationRules MealGenerationRule[] @@index([creatorId]) @@index([communityId]) @@ -450,6 +481,8 @@ model Tag { createdBy User? @relation("TagCreator", fields: [createdById], references: [id]) recipes RecipeTag[] + generationRules MealGenerationRule[] + slotPins MealSlotPin[] @@unique([name, communityId]) @@index([name]) @@ -724,6 +757,186 @@ enum ActivityType { TAG_SUGGESTION_REJECTED } +// ============================================================================= +// MEAL PLAN (Planning de repas communautaire) +// ============================================================================= + +enum DayOfWeek { + MON + TUE + WED + THU + FRI + SAT + SUN +} + +enum MealTime { + LUNCH + DINNER +} + +enum MealSlotType { + EMPTY + RECIPE + FREE_TEXT +} + +enum MealPlanStatus { + ACTIVE + ARCHIVED +} + +enum FrequencyPer { + PER_WEEK + PER_PLANNING +} + +model MealPlan { + id String @id @default(uuid()) + communityId String + startDate DateTime @db.Date + endDate DateTime @db.Date + status MealPlanStatus @default(ACTIVE) + defaultServings Int @default(4) + editableByMembers Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + community Community @relation(fields: [communityId], references: [id]) + slots MealSlot[] + + @@index([communityId, status]) +} + +model MealSlot { + id String @id @default(uuid()) + planId String + date DateTime @db.Date + mealTime MealTime + servings Int + type MealSlotType @default(EMPTY) + disabled Boolean @default(false) + locked Boolean @default(false) + recipeId String? + freeText String? + comment String? + updatedAt DateTime @updatedAt + updatedById String? + + plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + recipe Recipe? @relation(fields: [recipeId], references: [id]) + updatedBy User? @relation(fields: [updatedById], references: [id], onDelete: SetNull) + + @@unique([planId, date, mealTime]) + @@index([planId, date]) +} + +model MealIdea { + id String @id @default(uuid()) + communityId String + name String + comment String? + recipeId String? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + community Community @relation(fields: [communityId], references: [id]) + recipe Recipe? @relation(fields: [recipeId], references: [id]) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([communityId, deletedAt]) +} + +// ============================================================================= +// MEAL GENERATION PARAMS (Jeux de parametres de generation) +// ============================================================================= + +model MealGenerationParams { + id String @id @default(uuid()) + communityId String + name String // max 100 chars + description String? // max 500 chars + cooldownDays Int @default(3) + useIdeas Boolean @default(true) + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + community Community @relation(fields: [communityId], references: [id]) + exclusions MealSlotExclusion[] + rules MealGenerationRule[] + slotPins MealSlotPin[] + + @@index([communityId, deletedAt]) +} + +// ============================================================================= +// MEAL SLOT EXCLUSION (Slots a ne pas generer - pivot, cascade) +// ============================================================================= + +model MealSlotExclusion { + id String @id @default(uuid()) + paramsId String + day DayOfWeek + mealTime MealTime + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + + @@unique([paramsId, day, mealTime]) +} + +// ============================================================================= +// MEAL GENERATION RULE (Regles de poids et contraintes - N par jeu) +// ============================================================================= + +model MealGenerationRule { + id String @id @default(uuid()) + paramsId String + + // Cible : tag OU recette (jamais les deux) + tagId String? + recipeId String? + + // Poids (tous types de regles) + weight Float @default(1.0) // 0.0-2.0 + mealTimeConstraint MealTime? // null = les deux + + // Contraintes de frequence (tag rules uniquement) + frequencyMin Int? + frequencyMax Int? + frequencyPer FrequencyPer? + + // Cooldown par tag (tag rules uniquement) + tagCooldownDays Int? + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull) + recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: SetNull) + + @@index([paramsId]) +} + +// ============================================================================= +// MEAL SLOT PIN (Epinglage de tag sur un slot - N par jeu) +// ============================================================================= + +model MealSlotPin { + id String @id @default(uuid()) + paramsId String + day DayOfWeek + mealTime MealTime + tagId String + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([paramsId, day, mealTime]) +} + // ============================================================================= // ANALYTICS (Optionnel - prepare pour le futur) // Tables techniques - Cascade OK diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index f3445e1..74b73a2 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -59,6 +59,18 @@ async function seed() { }); console.log("Feature MVP:", featureMvp.code); + const featureMealPlan = await prisma.feature.upsert({ + where: { code: "MEAL_PLAN" }, + update: {}, + create: { + code: "MEAL_PLAN", + name: "Planning de repas", + description: "Planning de repas communautaire avec idees et generation automatique", + isDefault: false, + }, + }); + console.log("Feature MEAL_PLAN:", featureMealPlan.code); + // =========================================== // Tags (always upsert - idempotent) // =========================================== @@ -144,6 +156,29 @@ async function seed() { } console.log("Ingredients seeded:", ingredientNames.length); + // =========================================== + // Changelog (always upsert - idempotent) + // =========================================== + await prisma.changelogEntry.upsert({ + where: { version: "1.0.0" }, + update: {}, + create: { + version: "1.0.0", + title: "Lancement de Forest Manager", + content: { + features: [ + { text: "Gestion de recettes personnelles et communautaires" }, + { text: "Systeme de communautes privees avec invitations" }, + { text: "Propositions de modifications collaboratives" }, + { text: "Import de recettes depuis des URLs externes" }, + ], + improvements: [], + fixes: [], + }, + }, + }); + console.log("Changelog v1.0.0 seeded"); + // =========================================== // Production mode stops here (reference data only) // =========================================== @@ -1249,6 +1284,174 @@ async function seed() { } console.log("Activity logs created"); + // =========================================== + // Meal Plan (test data for Cuisine Italienne) + // =========================================== + // Grant MEAL_PLAN feature to Cuisine Italienne + await prisma.communityFeature.create({ + data: { communityId: cuisineItalienne.id, featureId: featureMealPlan.id }, + }); + + // Create an ACTIVE meal plan (7 days starting next Monday) + const nextMonday = new Date(); + nextMonday.setDate(nextMonday.getDate() + ((1 + 7 - nextMonday.getDay()) % 7 || 7)); + nextMonday.setHours(0, 0, 0, 0); + const nextSunday = new Date(nextMonday); + nextSunday.setDate(nextSunday.getDate() + 6); + + const mealPlan = await prisma.mealPlan.create({ + data: { + communityId: cuisineItalienne.id, + startDate: nextMonday, + endDate: nextSunday, + defaultServings: 4, + editableByMembers: true, + }, + }); + + // Create 14 slots (7 days x 2 meals) + const mealTimes = ["LUNCH", "DINNER"]; + const slotsData = []; + for (let d = 0; d < 7; d++) { + const slotDate = new Date(nextMonday); + slotDate.setDate(slotDate.getDate() + d); + for (const mt of mealTimes) { + slotsData.push({ + planId: mealPlan.id, + date: slotDate, + mealTime: mt, + servings: 4, + type: "EMPTY", + }); + } + } + await prisma.mealSlot.createMany({ data: slotsData }); + + // Fill a few slots with recipes and free text + const slots = await prisma.mealSlot.findMany({ + where: { planId: mealPlan.id }, + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + }); + + // Monday LUNCH -> Pizza Margherita + await prisma.mealSlot.update({ + where: { id: slots[0].id }, + data: { type: "RECIPE", recipeId: pizzaMargherita.id, updatedById: alice.id }, + }); + // Monday DINNER -> Free text + await prisma.mealSlot.update({ + where: { id: slots[1].id }, + data: { + type: "FREE_TEXT", + freeText: "Resto japonais", + comment: "Reserver a l'avance", + updatedById: bob.id, + }, + }); + // Tuesday LUNCH -> Risotto + await prisma.mealSlot.update({ + where: { id: slots[2].id }, + data: { type: "RECIPE", recipeId: risottoChampignons.id, updatedById: alice.id }, + }); + // Wednesday LUNCH -> disabled + await prisma.mealSlot.update({ + where: { id: slots[4].id }, + data: { disabled: true }, + }); + // Wednesday DINNER -> disabled + await prisma.mealSlot.update({ + where: { id: slots[5].id }, + data: { disabled: true }, + }); + + // Create a couple of meal ideas + await prisma.mealIdea.createMany({ + data: [ + { + communityId: cuisineItalienne.id, + name: "Lasagnes bolognaise", + comment: "Recette de grand-mere", + createdById: alice.id, + }, + { communityId: cuisineItalienne.id, name: "Tiramisu", createdById: bob.id }, + { + communityId: cuisineItalienne.id, + name: "Poke bowl saumon", + recipeId: bowlSaumon.id, + createdById: eve.id, + }, + ], + }); + + console.log("Meal plan seeded (1 plan, 14 slots, 3 ideas)"); + + // =========================================== + // Meal Generation Params (test data for Cuisine Italienne) + // =========================================== + const genParams = await prisma.mealGenerationParams.create({ + data: { + communityId: cuisineItalienne.id, + name: "Standard", + description: "Parametres par defaut pour la generation", + cooldownDays: 3, + useIdeas: true, + isDefault: true, + }, + }); + + // Exclusions : mercredi midi et soir (communaute ne mange pas ensemble le mercredi) + await prisma.mealSlotExclusion.createMany({ + data: [ + { paramsId: genParams.id, day: "WED", mealTime: "LUNCH" }, + { paramsId: genParams.id, day: "WED", mealTime: "DINNER" }, + ], + }); + + // Rules : quelques regles de poids et frequence + await prisma.mealGenerationRule.createMany({ + data: [ + // Tag "italien" favorise (150%) + { + paramsId: genParams.id, + tagId: tags["italien"].id, + weight: 1.5, + }, + // Tag "vegetarien" avec frequence min 2 par semaine + { + paramsId: genParams.id, + tagId: tags["vegetarien"].id, + weight: 1.2, + frequencyMin: 2, + frequencyPer: "PER_WEEK", + }, + // Tag "dessert" uniquement le soir, defavorise + { + paramsId: genParams.id, + tagId: tags["dessert"].id, + weight: 0.5, + mealTimeConstraint: "DINNER", + }, + // Recette Pizza Margherita favorisee + { + paramsId: genParams.id, + recipeId: pizzaMargherita.id, + weight: 1.8, + }, + ], + }); + + // Pins : vendredi soir = italien + await prisma.mealSlotPin.create({ + data: { + paramsId: genParams.id, + day: "FRI", + mealTime: "DINNER", + tagId: tags["italien"].id, + }, + }); + + console.log("Meal generation params seeded (1 params set, 2 exclusions, 4 rules, 1 pin)"); + console.log("\nSeeding complete!"); console.log("Login credentials for all users: password123"); console.log("Users: alice_chef, bob_boulanger, charlie_cook, diana_patissiere, eve_gourmet"); diff --git a/backend/src/__tests__/integration/adminChangelog.test.ts b/backend/src/__tests__/integration/adminChangelog.test.ts new file mode 100644 index 0000000..928fcc4 --- /dev/null +++ b/backend/src/__tests__/integration/adminChangelog.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createTestAdmin, createTestChangelogEntry, loginAsAdmin } from "../setup/testHelpers"; + +const VALID_CONTENT = { + features: [{ text: "New feature" }], + improvements: [{ text: "Improved something" }], + fixes: [], +}; + +const NOT_FOUND_UUID = "00000000-0000-4000-8000-000000000000"; + +describe("Admin Changelog API", () => { + let adminCookie: string; + + beforeEach(async () => { + const admin = await createTestAdmin(); + adminCookie = await loginAsAdmin(admin); + }); + + // ===================================== + // GET /api/admin/changelog + // ===================================== + describe("GET /api/admin/changelog", () => { + it("should return paginated changelog entries", async () => { + await createTestChangelogEntry({ version: `1.0.${Date.now() % 10000}` }); + await createTestChangelogEntry({ version: `1.1.${Date.now() % 10000}` }); + + const res = await request(app).get("/api/admin/changelog").set("Cookie", adminCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.pagination).toBeDefined(); + expect(res.body.pagination.total).toBeGreaterThanOrEqual(2); + }); + + it("should exclude soft-deleted entries by default", async () => { + const deleted = await createTestChangelogEntry({ + version: `9.9.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app).get("/api/admin/changelog").set("Cookie", adminCookie); + + expect(res.status).toBe(200); + const ids = res.body.data.map((e: { id: string }) => e.id); + expect(ids).not.toContain(deleted.id); + }); + + it("should include soft-deleted entries when includeDeleted=true", async () => { + const deleted = await createTestChangelogEntry({ + version: `8.8.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app) + .get("/api/admin/changelog?includeDeleted=true") + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + const ids = res.body.data.map((e: { id: string }) => e.id); + expect(ids).toContain(deleted.id); + }); + + it("should return 401 without admin authentication", async () => { + const res = await request(app).get("/api/admin/changelog"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // POST /api/admin/changelog + // ===================================== + describe("POST /api/admin/changelog", () => { + it("should create a changelog entry", async () => { + const version = `2.0.${Date.now() % 10000}`; + + const res = await request(app).post("/api/admin/changelog").set("Cookie", adminCookie).send({ + version, + title: "Test release", + content: VALID_CONTENT, + }); + + expect(res.status).toBe(201); + expect(res.body.data.version).toBe(version); + expect(res.body.data.title).toBe("Test release"); + expect(res.body.data.content).toEqual(VALID_CONTENT); + }); + + it("should reject duplicate version (409)", async () => { + const version = `3.0.${Date.now() % 10000}`; + await createTestChangelogEntry({ version }); + + const res = await request(app).post("/api/admin/changelog").set("Cookie", adminCookie).send({ + version, + title: "Duplicate", + content: VALID_CONTENT, + }); + + expect(res.status).toBe(409); + }); + + it("should reject invalid semver version", async () => { + const res = await request(app).post("/api/admin/changelog").set("Cookie", adminCookie).send({ + version: "not-semver", + title: "Bad version", + content: VALID_CONTENT, + }); + + expect(res.status).toBe(400); + }); + + it("should reject empty content (no items in any category)", async () => { + const res = await request(app) + .post("/api/admin/changelog") + .set("Cookie", adminCookie) + .send({ + version: `4.0.${Date.now() % 10000}`, + title: "Empty", + content: { features: [], improvements: [], fixes: [] }, + }); + + expect(res.status).toBe(400); + }); + + it("should reject missing title", async () => { + const res = await request(app) + .post("/api/admin/changelog") + .set("Cookie", adminCookie) + .send({ + version: `5.0.${Date.now() % 10000}`, + content: VALID_CONTENT, + }); + + expect(res.status).toBe(400); + }); + }); + + // ===================================== + // PATCH /api/admin/changelog/:id + // ===================================== + describe("PATCH /api/admin/changelog/:id", () => { + it("should update title", async () => { + const entry = await createTestChangelogEntry({ version: `6.0.${Date.now() % 10000}` }); + + const res = await request(app) + .patch(`/api/admin/changelog/${entry.id}`) + .set("Cookie", adminCookie) + .send({ title: "Updated title" }); + + expect(res.status).toBe(200); + expect(res.body.data.title).toBe("Updated title"); + }); + + it("should update version if not duplicate", async () => { + const entry = await createTestChangelogEntry({ version: `7.0.${Date.now() % 10000}` }); + const newVersion = `7.1.${Date.now() % 10000}`; + + const res = await request(app) + .patch(`/api/admin/changelog/${entry.id}`) + .set("Cookie", adminCookie) + .send({ version: newVersion }); + + expect(res.status).toBe(200); + expect(res.body.data.version).toBe(newVersion); + }); + + it("should reject version update if version already exists", async () => { + const entry1 = await createTestChangelogEntry({ version: `10.0.${Date.now() % 10000}` }); + const entry2 = await createTestChangelogEntry({ version: `10.1.${Date.now() % 10000}` }); + + const res = await request(app) + .patch(`/api/admin/changelog/${entry2.id}`) + .set("Cookie", adminCookie) + .send({ version: entry1.version }); + + expect(res.status).toBe(409); + }); + + it("should return 404 for non-existent entry", async () => { + const res = await request(app) + .patch(`/api/admin/changelog/${NOT_FOUND_UUID}`) + .set("Cookie", adminCookie) + .send({ title: "Nope" }); + + expect(res.status).toBe(404); + }); + + it("should return 404 for soft-deleted entry", async () => { + const deleted = await createTestChangelogEntry({ + version: `11.0.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app) + .patch(`/api/admin/changelog/${deleted.id}`) + .set("Cookie", adminCookie) + .send({ title: "Nope" }); + + expect(res.status).toBe(404); + }); + }); + + // ===================================== + // DELETE /api/admin/changelog/:id + // ===================================== + describe("DELETE /api/admin/changelog/:id", () => { + it("should soft-delete a changelog entry", async () => { + const entry = await createTestChangelogEntry({ version: `12.0.${Date.now() % 10000}` }); + + const res = await request(app) + .delete(`/api/admin/changelog/${entry.id}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(200); + + // Verify it's excluded from normal list + const listRes = await request(app).get("/api/admin/changelog").set("Cookie", adminCookie); + const ids = listRes.body.data.map((e: { id: string }) => e.id); + expect(ids).not.toContain(entry.id); + }); + + it("should return 404 for non-existent entry", async () => { + const res = await request(app) + .delete(`/api/admin/changelog/${NOT_FOUND_UUID}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(404); + }); + + it("should return 404 for already soft-deleted entry", async () => { + const deleted = await createTestChangelogEntry({ + version: `13.0.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app) + .delete(`/api/admin/changelog/${deleted.id}`) + .set("Cookie", adminCookie); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/__tests__/integration/changelog.test.ts b/backend/src/__tests__/integration/changelog.test.ts new file mode 100644 index 0000000..4f31a76 --- /dev/null +++ b/backend/src/__tests__/integration/changelog.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { + createTestUser, + createTestChangelogEntry, + extractSessionCookie, +} from "../setup/testHelpers"; + +const NOT_FOUND_UUID = "00000000-0000-4000-8000-000000000000"; + +describe("Changelog API (User)", () => { + 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 = extractSessionCookie(loginRes)!; + }); + + // ===================================== + // GET /api/changelog + // ===================================== + describe("GET /api/changelog", () => { + it("should return paginated changelog entries", async () => { + await createTestChangelogEntry({ version: `1.0.${Date.now() % 10000}` }); + await createTestChangelogEntry({ version: `1.1.${Date.now() % 10000}` }); + + const res = await request(app).get("/api/changelog").set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.pagination).toBeDefined(); + }); + + it("should not return soft-deleted entries", async () => { + const deleted = await createTestChangelogEntry({ + version: `9.0.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app).get("/api/changelog").set("Cookie", userCookie); + + expect(res.status).toBe(200); + const ids = res.body.data.map((e: { id: string }) => e.id); + expect(ids).not.toContain(deleted.id); + }); + + it("should order by publishedAt descending", async () => { + const old = await createTestChangelogEntry({ + version: `0.1.${Date.now() % 10000}`, + publishedAt: new Date("2025-01-01"), + }); + const recent = await createTestChangelogEntry({ + version: `0.2.${Date.now() % 10000}`, + publishedAt: new Date("2026-01-01"), + }); + + const res = await request(app).get("/api/changelog").set("Cookie", userCookie); + + expect(res.status).toBe(200); + const ids = res.body.data.map((e: { id: string }) => e.id); + const recentIdx = ids.indexOf(recent.id); + const oldIdx = ids.indexOf(old.id); + if (recentIdx !== -1 && oldIdx !== -1) { + expect(recentIdx).toBeLessThan(oldIdx); + } + }); + + it("should return 401 without authentication", async () => { + const res = await request(app).get("/api/changelog"); + expect(res.status).toBe(401); + }); + }); + + // ===================================== + // GET /api/changelog/:id + // ===================================== + describe("GET /api/changelog/:id", () => { + it("should return a single changelog entry", async () => { + const entry = await createTestChangelogEntry({ version: `2.0.${Date.now() % 10000}` }); + + const res = await request(app).get(`/api/changelog/${entry.id}`).set("Cookie", userCookie); + + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(entry.id); + expect(res.body.data.version).toBe(entry.version); + }); + + it("should return 404 for non-existent entry", async () => { + const res = await request(app) + .get(`/api/changelog/${NOT_FOUND_UUID}`) + .set("Cookie", userCookie); + + expect(res.status).toBe(404); + }); + + it("should return 404 for soft-deleted entry", async () => { + const deleted = await createTestChangelogEntry({ + version: `3.0.${Date.now() % 10000}`, + deletedAt: new Date(), + }); + + const res = await request(app).get(`/api/changelog/${deleted.id}`).set("Cookie", userCookie); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/__tests__/integration/mealGenerate.test.ts b/backend/src/__tests__/integration/mealGenerate.test.ts new file mode 100644 index 0000000..0b5bae3 --- /dev/null +++ b/backend/src/__tests__/integration/mealGenerate.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { testPrisma } from "../setup/globalSetup"; +import { createMealTestContext, MealTestContext } from "../setup/testHelpers"; + +describe("Meal Generation API", () => { + let ctx: MealTestContext; + let moderatorCookie: string; + let memberCookie: string; + let communityId: string; + let _recipe1Id: string; + let _recipe2Id: string; + let recipe3Id: string; + let tag1Id: string; + let tag2Id: string; + let paramsId: string; + let _planId: string; + + beforeEach(async () => { + const suffix = Date.now(); + ctx = await createMealTestContext("mg"); + moderatorCookie = ctx.moderatorCookie; + memberCookie = ctx.memberCookie; + communityId = ctx.communityId; + + // Donnees specifiques a ce test + const t1 = await testPrisma.tag.create({ + data: { name: `italien_${suffix}`, status: "APPROVED" }, + }); + tag1Id = t1.id; + const t2 = await testPrisma.tag.create({ + data: { name: `dessert_${suffix}`, status: "APPROVED" }, + }); + tag2Id = t2.id; + + const r1 = await testPrisma.recipe.create({ + data: { + title: `Pasta ${suffix}`, + creatorId: ctx.moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Cook pasta" }] }, + tags: { create: [{ tagId: tag1Id }] }, + }, + }); + _recipe1Id = r1.id; + + const r2 = await testPrisma.recipe.create({ + data: { + title: `Pizza ${suffix}`, + creatorId: ctx.moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Make pizza" }] }, + tags: { create: [{ tagId: tag1Id }] }, + }, + }); + _recipe2Id = r2.id; + + const r3 = await testPrisma.recipe.create({ + data: { + title: `Cake ${suffix}`, + creatorId: ctx.moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Bake cake" }] }, + tags: { create: [{ tagId: tag2Id }] }, + }, + }); + recipe3Id = r3.id; + + const params = await testPrisma.mealGenerationParams.create({ + data: { + communityId, + name: "Standard", + cooldownDays: 0, + useIdeas: false, + isDefault: true, + }, + }); + paramsId = params.id; + + const planRes = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08", defaultServings: 4 }); + _planId = planRes.body.plan.id; + }); + + // =================================== + // POST /meal-plan/generate + // =================================== + describe("POST /meal-plan/generate", () => { + it("should generate a full plan", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(200); + expect(res.body.plan).toBeDefined(); + expect(res.body.report).toBeDefined(); + expect(res.body.report.slotsGenerated).toBe(6); + expect(res.body.report.slotsSkipped.excluded).toBe(0); + + // Tous les slots doivent etre remplis + const filledSlots = res.body.plan.slots.filter((s: { type: string }) => s.type === "RECIPE"); + expect(filledSlots.length).toBe(6); + }); + + it("should respect fillEmptyOnly", async () => { + // D'abord generer + await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + // Vider un slot manuellement + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}`) + .set("Cookie", moderatorCookie) + .send({ type: "EMPTY" }); + + // Re-generer avec fillEmptyOnly + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: true }); + + expect(res.status).toBe(200); + expect(res.body.report.slotsGenerated).toBe(1); + expect(res.body.report.slotsSkipped.alreadyFilled).toBe(5); + }); + + it("should respect locked slots", async () => { + // Verrouiller le premier slot + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}`) + .set("Cookie", moderatorCookie) + .send({ locked: true }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(200); + expect(res.body.report.slotsGenerated).toBe(5); + expect(res.body.report.slotsSkipped.locked).toBe(1); + }); + + it("should respect exclusions", async () => { + // Exclure le lundi midi (2026-04-06 = Monday) + await testPrisma.mealSlotExclusion.create({ + data: { paramsId, day: "MON", mealTime: "LUNCH" }, + }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(200); + expect(res.body.report.slotsGenerated).toBe(5); + expect(res.body.report.slotsSkipped.excluded).toBe(1); + }); + + it("should return 404 if no active plan", async () => { + // Supprimer le plan + await request(app) + .delete(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_GEN_002"); + }); + + it("should return 404 if params not found", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId: "00000000-0000-4000-8000-000000000000", fillEmptyOnly: false }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_GEN_001"); + }); + + it("should return 403 for non-moderator", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", memberCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(403); + }); + + it("should include report with warnings for pool exhausted", async () => { + // Supprimer toutes les recettes (soft delete) + await testPrisma.recipe.updateMany({ + where: { communityId }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(200); + expect(res.body.report.slotsEmpty).toBe(6); + expect(res.body.report.warnings.length).toBeGreaterThan(0); + expect(res.body.report.warnings[0].type).toBe("POOL_EXHAUSTED"); + }); + + it("should use generation rules (pin)", async () => { + // Epingler lundi midi sur tag dessert → seule recipe3 (Cake) doit etre choisie + await testPrisma.mealSlotPin.create({ + data: { paramsId, day: "MON", mealTime: "LUNCH", tagId: tag2Id }, + }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + expect(res.status).toBe(200); + // Le premier slot (lundi midi) doit avoir le Cake (tag2 = dessert) + const mondayLunch = res.body.plan.slots.find( + (s: { mealTime: string; date: string }) => + s.mealTime === "LUNCH" && new Date(s.date).toISOString().startsWith("2026-04-06") + ); + expect(mondayLunch.recipeId).toBe(recipe3Id); + }); + }); + + // =================================== + // POST /meal-plan/slots/:slotId/replace + // =================================== + describe("POST /meal-plan/slots/:slotId/replace", () => { + it("should replace a slot with a different recipe", async () => { + // D'abord generer + await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + const originalRecipeId = firstSlot.recipeId; + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}/replace`) + .set("Cookie", moderatorCookie) + .send({ paramsId }); + + expect(res.status).toBe(200); + expect(res.body.slot).toBeDefined(); + expect(res.body.report).toBeDefined(); + // La recette doit avoir change (elle etait exclue du pool) + expect(res.body.slot.recipeId).not.toBe(originalRecipeId); + }); + + it("should refuse to replace a locked slot", async () => { + // Generer puis verrouiller + await request(app) + .post(`/api/communities/${communityId}/meal-plan/generate`) + .set("Cookie", moderatorCookie) + .send({ paramsId, fillEmptyOnly: false }); + + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}`) + .set("Cookie", moderatorCookie) + .send({ locked: true }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}/replace`) + .set("Cookie", moderatorCookie) + .send({ paramsId }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_008"); + }); + + it("should refuse to replace an excluded slot", async () => { + // Exclure lundi midi + await testPrisma.mealSlotExclusion.create({ + data: { paramsId, day: "MON", mealTime: "LUNCH" }, + }); + + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + // Premier slot = lundi midi + const mondayLunch = planRes.body.plan.slots.find( + (s: { mealTime: string; date: string }) => + s.mealTime === "LUNCH" && new Date(s.date).toISOString().startsWith("2026-04-06") + ); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/${mondayLunch.id}/replace`) + .set("Cookie", moderatorCookie) + .send({ paramsId }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_007"); + }); + + it("should return 403 for non-moderator", async () => { + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}/replace`) + .set("Cookie", memberCookie) + .send({ paramsId }); + + expect(res.status).toBe(403); + }); + + it("should return 404 for invalid params", async () => { + const planRes = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + const firstSlot = planRes.body.plan.slots[0]; + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/${firstSlot.id}/replace`) + .set("Cookie", moderatorCookie) + .send({ paramsId: "00000000-0000-4000-8000-000000000000" }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_GEN_001"); + }); + }); + + // =================================== + // GET /meal-plan — hasDefaultGenerationParams + // =================================== + describe("hasDefaultGenerationParams flag", () => { + it("should return true when default params exist", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.hasDefaultGenerationParams).toBe(true); + }); + + it("should return false when no default params", async () => { + // Supprimer le jeu par defaut + await testPrisma.mealGenerationParams.update({ + where: { id: paramsId }, + data: { isDefault: false }, + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.hasDefaultGenerationParams).toBe(false); + }); + + it("should return false when default params are soft-deleted", async () => { + await testPrisma.mealGenerationParams.update({ + where: { id: paramsId }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.hasDefaultGenerationParams).toBe(false); + }); + }); +}); diff --git a/backend/src/__tests__/integration/mealGenerationParams.test.ts b/backend/src/__tests__/integration/mealGenerationParams.test.ts new file mode 100644 index 0000000..196add1 --- /dev/null +++ b/backend/src/__tests__/integration/mealGenerationParams.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { createMealTestContext, extractSessionCookie, MealTestContext } from "../setup/testHelpers"; + +describe("Meal Generation Params API", () => { + let ctx: MealTestContext; + let nonMemberCookie: string; + let moderatorCookie: string; + let memberCookie: string; + let communityId: string; + + beforeEach(async () => { + ctx = await createMealTestContext("mgp"); + moderatorCookie = ctx.moderatorCookie; + memberCookie = ctx.memberCookie; + communityId = ctx.communityId; + + // Non-membre (specifique a ce test) + const nmSuffix = Date.now() + 2; + const nmSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mgp_nm_${nmSuffix}`, + email: `mgp_nm_${nmSuffix}@example.com`, + password: "Test123!Password", + }); + nonMemberCookie = extractSessionCookie(nmSignup)!; + }); + + // ============================================= + // POST - Create params + // ============================================= + describe("POST /api/communities/:communityId/meal-generation-params", () => { + it("should create params as moderator", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ + name: "Standard", + description: "Jeu par defaut", + cooldownDays: 3, + useIdeas: true, + isDefault: true, + }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("Standard"); + expect(res.body.description).toBe("Jeu par defaut"); + expect(res.body.cooldownDays).toBe(3); + expect(res.body.useIdeas).toBe(true); + expect(res.body.isDefault).toBe(true); + expect(res.body.exclusions).toEqual([]); + expect(res.body.rules).toEqual([]); + expect(res.body.slotPins).toEqual([]); + }); + + it("should create params with defaults when optional fields omitted", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Minimal" }); + + expect(res.status).toBe(201); + expect(res.body.cooldownDays).toBe(3); + expect(res.body.useIdeas).toBe(true); + expect(res.body.isDefault).toBe(false); + }); + + it("should return 403 for member (not moderator)", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", memberCookie) + .send({ name: "Test" }); + + expect(res.status).toBe(403); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", nonMemberCookie) + .send({ name: "Test" }); + + expect(res.status).toBe(403); + }); + + it("should return 401 when not authenticated", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .send({ name: "Test" }); + + expect(res.status).toBe(401); + }); + + it("should return 400 when name is missing", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({}); + + expect(res.status).toBe(400); + }); + + it("should return 400 when name exceeds 100 chars", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "a".repeat(101) }); + + expect(res.status).toBe(400); + }); + + it("should return 400 when description exceeds 500 chars", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test", description: "a".repeat(501) }); + + expect(res.status).toBe(400); + }); + + it("should return 400 when cooldownDays is negative", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test", cooldownDays: -1 }); + + expect(res.status).toBe(400); + }); + + it("should unset previous default when creating a new default", async () => { + // Create first default + await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "First", isDefault: true }); + + // Create second default + const res = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Second", isDefault: true }); + + expect(res.status).toBe(201); + expect(res.body.isDefault).toBe(true); + + // Check the list - only one should be default + const listRes = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie); + + const defaults = listRes.body.data.filter((p: { isDefault: boolean }) => p.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].name).toBe("Second"); + }); + }); + + // ============================================= + // GET - List params + // ============================================= + describe("GET /api/communities/:communityId/meal-generation-params", () => { + it("should list params for member", async () => { + // Create some params + await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Standard" }); + + await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Ete" }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + + it("should not list soft-deleted params", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "ToDelete" }); + + await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", nonMemberCookie); + + expect(res.status).toBe(403); + }); + }); + + // ============================================= + // GET - Detail params + // ============================================= + describe("GET /api/communities/:communityId/meal-generation-params/:paramsId", () => { + it("should return params detail with exclusions, rules, pins", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Standard" }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(createRes.body.id); + expect(res.body.name).toBe("Standard"); + expect(res.body).toHaveProperty("exclusions"); + expect(res.body).toHaveProperty("rules"); + expect(res.body).toHaveProperty("slotPins"); + }); + + it("should return 404 for non-existent params", async () => { + const res = await request(app) + .get( + `/api/communities/${communityId}/meal-generation-params/00000000-0000-4000-8000-000000000000` + ) + .set("Cookie", memberCookie); + + expect(res.status).toBe(404); + }); + + it("should return 404 for soft-deleted params", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Deleted" }); + + await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(404); + }); + }); + + // ============================================= + // PATCH - Update params + // ============================================= + describe("PATCH /api/communities/:communityId/meal-generation-params/:paramsId", () => { + it("should update params as moderator", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Original" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ name: "Updated", cooldownDays: 5, useIdeas: false }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Updated"); + expect(res.body.cooldownDays).toBe(5); + expect(res.body.useIdeas).toBe(false); + }); + + it("should unset previous default when setting isDefault to true", async () => { + const first = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "First", isDefault: true }); + + const second = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Second" }); + + await request(app) + .patch(`/api/communities/${communityId}/meal-generation-params/${second.body.id}`) + .set("Cookie", moderatorCookie) + .send({ isDefault: true }); + + // Verify first is no longer default + const firstDetail = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params/${first.body.id}`) + .set("Cookie", moderatorCookie); + + expect(firstDetail.body.isDefault).toBe(false); + }); + + it("should return 403 for member (not moderator)", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", memberCookie) + .send({ name: "Nope" }); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent params", async () => { + const res = await request(app) + .patch( + `/api/communities/${communityId}/meal-generation-params/00000000-0000-4000-8000-000000000000` + ) + .set("Cookie", moderatorCookie) + .send({ name: "Nope" }); + + expect(res.status).toBe(404); + }); + + it("should return 400 when no fields provided", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({}); + + expect(res.status).toBe(400); + }); + + it("should allow setting description to null", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test", description: "Some description" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ description: null }); + + expect(res.status).toBe(200); + expect(res.body.description).toBeNull(); + }); + }); + + // ============================================= + // DELETE - Soft delete params + // ============================================= + describe("DELETE /api/communities/:communityId/meal-generation-params/:paramsId", () => { + it("should soft delete params as moderator", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "ToDelete" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(204); + + // Verify it's not in the list + const listRes = await request(app) + .get(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie); + expect(listRes.body.data).toHaveLength(0); + }); + + it("should return 403 for member (not moderator)", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent params", async () => { + const res = await request(app) + .delete( + `/api/communities/${communityId}/meal-generation-params/00000000-0000-4000-8000-000000000000` + ) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + }); + + it("should return 404 for already deleted params", async () => { + const createRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Test" }); + + await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-generation-params/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/__tests__/integration/mealGenerationRules.test.ts b/backend/src/__tests__/integration/mealGenerationRules.test.ts new file mode 100644 index 0000000..f3b0427 --- /dev/null +++ b/backend/src/__tests__/integration/mealGenerationRules.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { testPrisma } from "../setup/globalSetup"; +import { createMealTestContext, MealTestContext } from "../setup/testHelpers"; + +describe("Meal Generation Exclusions, Rules & Pins API", () => { + let ctx: MealTestContext; + let moderatorCookie: string; + let memberCookie: string; + let communityId: string; + let paramsId: string; + let tagId1: string; + let tagId2: string; + let recipeId: string; + + beforeEach(async () => { + const suffix = Date.now(); + ctx = await createMealTestContext("mgr"); + moderatorCookie = ctx.moderatorCookie; + memberCookie = ctx.memberCookie; + communityId = ctx.communityId; + + // Donnees specifiques a ce test + const tag1 = await testPrisma.tag.create({ + data: { name: `tag_a_${suffix}`, scope: "GLOBAL" }, + }); + const tag2 = await testPrisma.tag.create({ + data: { name: `tag_b_${suffix}`, scope: "GLOBAL" }, + }); + tagId1 = tag1.id; + tagId2 = tag2.id; + + const recipe = await testPrisma.recipe.create({ + data: { + title: `Test Recipe ${suffix}`, + communityId, + creatorId: ctx.moderator.id, + servings: 4, + }, + }); + recipeId = recipe.id; + + const paramsRes = await request(app) + .post(`/api/communities/${communityId}/meal-generation-params`) + .set("Cookie", moderatorCookie) + .send({ name: "Standard" }); + paramsId = paramsRes.body.id; + }); + + const base = () => `/api/communities/${communityId}/meal-generation-params/${paramsId}`; + + // ============================================= + // PUT Exclusions + // ============================================= + describe("PUT .../exclusions", () => { + it("should set exclusions", async () => { + const res = await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ + exclusions: [ + { day: "WED", mealTime: "LUNCH" }, + { day: "WED", mealTime: "DINNER" }, + ], + }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].day).toBe("WED"); + }); + + it("should replace all exclusions on second call", async () => { + await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "WED", mealTime: "LUNCH" }] }); + + const res = await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "FRI", mealTime: "DINNER" }] }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].day).toBe("FRI"); + }); + + it("should clear all exclusions with empty array", async () => { + await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "MON", mealTime: "LUNCH" }] }); + + const res = await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [] }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + + it("should return 403 for member", async () => { + const res = await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", memberCookie) + .send({ exclusions: [] }); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent params", async () => { + const res = await request(app) + .put( + `/api/communities/${communityId}/meal-generation-params/00000000-0000-4000-8000-000000000000/exclusions` + ) + .set("Cookie", moderatorCookie) + .send({ exclusions: [] }); + + expect(res.status).toBe(404); + }); + }); + + // ============================================= + // Rules CRUD + // ============================================= + describe("POST .../rules (create)", () => { + it("should create a tag rule", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.5 }); + + expect(res.status).toBe(201); + expect(res.body.tagId).toBe(tagId1); + expect(res.body.weight).toBe(1.5); + expect(res.body.tag).toBeTruthy(); + expect(res.body.recipe).toBeNull(); + }); + + it("should create a recipe rule", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ recipeId, weight: 1.8 }); + + expect(res.status).toBe(201); + expect(res.body.recipeId).toBe(recipeId); + expect(res.body.recipe).toBeTruthy(); + }); + + it("should create a tag rule with frequency constraints", async () => { + const res = await request(app).post(`${base()}/rules`).set("Cookie", moderatorCookie).send({ + tagId: tagId1, + weight: 1.0, + frequencyMin: 2, + frequencyMax: 4, + frequencyPer: "PER_WEEK", + tagCooldownDays: 2, + }); + + expect(res.status).toBe(201); + expect(res.body.frequencyMin).toBe(2); + expect(res.body.frequencyMax).toBe(4); + expect(res.body.frequencyPer).toBe("PER_WEEK"); + expect(res.body.tagCooldownDays).toBe(2); + }); + + it("should create a tag rule with mealTimeConstraint", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0, mealTimeConstraint: "DINNER" }); + + expect(res.status).toBe(201); + expect(res.body.mealTimeConstraint).toBe("DINNER"); + }); + + it("should return 400 when neither tagId nor recipeId", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ weight: 1.0 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_003"); + }); + + it("should return 400 when both tagId and recipeId", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, recipeId, weight: 1.0 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_003"); + }); + + it("should return 400 when weight > 2.0", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 2.5 }); + + expect(res.status).toBe(400); + }); + + it("should return 400 when weight < 0", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: -0.5 }); + + expect(res.status).toBe(400); + }); + + it("should return 400 when frequency constraints on recipe rule (MEAL_GEN_009)", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ recipeId, weight: 1.0, frequencyMin: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_009"); + }); + + it("should return 400 when tagCooldownDays on recipe rule (MEAL_GEN_011)", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ recipeId, weight: 1.0, tagCooldownDays: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_011"); + }); + + it("should return 400 when frequencyMin > frequencyMax (MEAL_GEN_010)", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0, frequencyMin: 5, frequencyMax: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_010"); + }); + + it("should return 403 for member", async () => { + const res = await request(app) + .post(`${base()}/rules`) + .set("Cookie", memberCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + expect(res.status).toBe(403); + }); + }); + + describe("GET .../rules (list)", () => { + it("should list rules for member", async () => { + await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.5 }); + + await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ recipeId, weight: 0.5 }); + + const res = await request(app).get(`${base()}/rules`).set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + }); + + describe("PATCH .../rules/:ruleId (update)", () => { + it("should update rule weight", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + const res = await request(app) + .patch(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ weight: 0.5 }); + + expect(res.status).toBe(200); + expect(res.body.weight).toBe(0.5); + }); + + it("should update frequency constraints on tag rule", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + const res = await request(app) + .patch(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ frequencyMin: 1, frequencyMax: 3, frequencyPer: "PER_PLANNING" }); + + expect(res.status).toBe(200); + expect(res.body.frequencyMin).toBe(1); + expect(res.body.frequencyMax).toBe(3); + expect(res.body.frequencyPer).toBe("PER_PLANNING"); + }); + + it("should return 400 when adding frequency to recipe rule", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ recipeId, weight: 1.0 }); + + const res = await request(app) + .patch(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ frequencyMin: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_009"); + }); + + it("should return 400 when frequencyMin > frequencyMax after merge", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0, frequencyMax: 2 }); + + const res = await request(app) + .patch(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", moderatorCookie) + .send({ frequencyMin: 5 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_010"); + }); + + it("should return 404 for non-existent rule", async () => { + const res = await request(app) + .patch(`${base()}/rules/00000000-0000-4000-8000-000000000000`) + .set("Cookie", moderatorCookie) + .send({ weight: 0.5 }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_GEN_006"); + }); + + it("should return 403 for member", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + const res = await request(app) + .patch(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", memberCookie) + .send({ weight: 0.5 }); + + expect(res.status).toBe(403); + }); + }); + + describe("DELETE .../rules/:ruleId (hard delete)", () => { + it("should delete rule", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + const res = await request(app) + .delete(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(204); + + // Verify it's gone + const listRes = await request(app).get(`${base()}/rules`).set("Cookie", moderatorCookie); + expect(listRes.body.data).toHaveLength(0); + }); + + it("should return 404 for non-existent rule", async () => { + const res = await request(app) + .delete(`${base()}/rules/00000000-0000-4000-8000-000000000000`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + }); + + it("should return 403 for member", async () => { + const createRes = await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.0 }); + + const res = await request(app) + .delete(`${base()}/rules/${createRes.body.id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); + + // ============================================= + // PUT Pins + // ============================================= + describe("PUT .../pins", () => { + it("should set pins", async () => { + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ + pins: [{ day: "FRI", mealTime: "DINNER", tagId: tagId1 }], + }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].day).toBe("FRI"); + expect(res.body.data[0].tag.id).toBe(tagId1); + }); + + it("should replace all pins on second call", async () => { + await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "FRI", mealTime: "DINNER", tagId: tagId1 }] }); + + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "SAT", mealTime: "LUNCH", tagId: tagId2 }] }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].day).toBe("SAT"); + }); + + it("should clear pins with empty array", async () => { + await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "FRI", mealTime: "DINNER", tagId: tagId1 }] }); + + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [] }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + + it("should return 400 when pin conflicts with exclusion (MEAL_GEN_012)", async () => { + // Set exclusion on WED LUNCH + await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "WED", mealTime: "LUNCH" }] }); + + // Try to pin WED LUNCH + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "WED", mealTime: "LUNCH", tagId: tagId1 }] }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_GEN_012"); + }); + + it("should allow pin on non-excluded slot even if others are excluded", async () => { + await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "WED", mealTime: "LUNCH" }] }); + + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "FRI", mealTime: "DINNER", tagId: tagId1 }] }); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); + + it("should return 403 for member", async () => { + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", memberCookie) + .send({ pins: [] }); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent tag in pin", async () => { + const res = await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ + pins: [{ day: "FRI", mealTime: "DINNER", tagId: "00000000-0000-4000-8000-000000000000" }], + }); + + expect(res.status).toBe(404); + }); + }); + + // ============================================= + // Integration: detail includes all sub-resources + // ============================================= + describe("GET .../detail includes exclusions, rules, pins", () => { + it("should return all sub-resources in detail", async () => { + // Set exclusions + await request(app) + .put(`${base()}/exclusions`) + .set("Cookie", moderatorCookie) + .send({ exclusions: [{ day: "WED", mealTime: "LUNCH" }] }); + + // Add rule + await request(app) + .post(`${base()}/rules`) + .set("Cookie", moderatorCookie) + .send({ tagId: tagId1, weight: 1.5 }); + + // Set pins + await request(app) + .put(`${base()}/pins`) + .set("Cookie", moderatorCookie) + .send({ pins: [{ day: "FRI", mealTime: "DINNER", tagId: tagId2 }] }); + + const res = await request(app).get(base()).set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.exclusions).toHaveLength(1); + expect(res.body.rules).toHaveLength(1); + expect(res.body.slotPins).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/__tests__/integration/mealIdeas.test.ts b/backend/src/__tests__/integration/mealIdeas.test.ts new file mode 100644 index 0000000..2d717c8 --- /dev/null +++ b/backend/src/__tests__/integration/mealIdeas.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { testPrisma } from "../setup/globalSetup"; +import { extractSessionCookie } from "../setup/testHelpers"; + +describe("Meal Ideas API", () => { + let moderator: { id: string }; + let moderatorCookie: string; + let member: { id: string }; + let memberCookie: string; + let otherMember: { id: string }; + let otherMemberCookie: string; + let _nonMember: { id: string }; + let nonMemberCookie: string; + let communityId: string; + let recipeId: string; + + beforeEach(async () => { + const suffix = Date.now(); + + // Moderateur + const modSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mi_mod_${suffix}`, + email: `mi_mod_${suffix}@example.com`, + password: "Test123!Password", + }); + moderatorCookie = extractSessionCookie(modSignup)!; + moderator = (await testPrisma.user.findFirst({ + where: { email: `mi_mod_${suffix}@example.com` }, + }))!; + + // Communaute + const comRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `MealIdeas Community ${suffix}` }); + communityId = comRes.body.id; + + // Feature MEAL_PLAN + let mealPlanFeature = await testPrisma.feature.findFirst({ where: { code: "MEAL_PLAN" } }); + if (!mealPlanFeature) { + mealPlanFeature = await testPrisma.feature.create({ + data: { code: "MEAL_PLAN", name: "Planning de repas", isDefault: false }, + }); + } + await testPrisma.communityFeature.create({ + data: { communityId, featureId: mealPlanFeature.id }, + }); + + // Membre + const memSuffix = suffix + 1; + const memSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mi_mem_${memSuffix}`, + email: `mi_mem_${memSuffix}@example.com`, + password: "Test123!Password", + }); + memberCookie = extractSessionCookie(memSignup)!; + member = (await testPrisma.user.findFirst({ + where: { email: `mi_mem_${memSuffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId, role: "MEMBER" }, + }); + + // Autre membre + const otherMemSuffix = suffix + 2; + const otherMemSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mi_om_${otherMemSuffix}`, + email: `mi_om_${otherMemSuffix}@example.com`, + password: "Test123!Password", + }); + otherMemberCookie = extractSessionCookie(otherMemSignup)!; + otherMember = (await testPrisma.user.findFirst({ + where: { email: `mi_om_${otherMemSuffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: otherMember.id, communityId, role: "MEMBER" }, + }); + + // Non-membre + const nmSuffix = suffix + 3; + const nmSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mi_nm_${nmSuffix}`, + email: `mi_nm_${nmSuffix}@example.com`, + password: "Test123!Password", + }); + nonMemberCookie = extractSessionCookie(nmSignup)!; + _nonMember = (await testPrisma.user.findFirst({ + where: { email: `mi_nm_${nmSuffix}@example.com` }, + }))!; + + // Recette communautaire + const recipe = await testPrisma.recipe.create({ + data: { + title: `Recipe for Ideas ${suffix}`, + creatorId: moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Step 1" }] }, + }, + }); + recipeId = recipe.id; + }); + + // =================================== + // GET /meal-ideas + // =================================== + describe("GET /meal-ideas", () => { + it("should return empty list when no ideas", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + expect(res.body.pagination.total).toBe(0); + }); + + it("should return paginated list of ideas", async () => { + // Create some ideas + await testPrisma.mealIdea.createMany({ + data: [ + { communityId, name: "Pizza maison", createdById: member.id }, + { communityId, name: "Salade cesar", createdById: moderator.id }, + { communityId, name: "Pates carbo", createdById: member.id }, + ], + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(3); + expect(res.body.pagination.total).toBe(3); + }); + + it("should search by name", async () => { + await testPrisma.mealIdea.createMany({ + data: [ + { communityId, name: "Pizza maison", createdById: member.id }, + { communityId, name: "Pizza margherita", createdById: member.id }, + { communityId, name: "Salade cesar", createdById: moderator.id }, + ], + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-ideas?search=pizza`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + + it("should not return deleted ideas", async () => { + await testPrisma.mealIdea.create({ + data: { + communityId, + name: "Deleted idea", + createdById: member.id, + deletedAt: new Date(), + }, + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", nonMemberCookie); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // POST /meal-ideas + // =================================== + describe("POST /meal-ideas", () => { + it("should create an idea with name only", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie) + .send({ name: "Nouvelle idee" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("Nouvelle idee"); + expect(res.body.comment).toBeNull(); + expect(res.body.recipe).toBeNull(); + expect(res.body.createdBy.id).toBe(member.id); + }); + + it("should create an idea with comment", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie) + .send({ name: "Idee avec commentaire", comment: "A essayer ce weekend" }); + + expect(res.status).toBe(201); + expect(res.body.name).toBe("Idee avec commentaire"); + expect(res.body.comment).toBe("A essayer ce weekend"); + }); + + it("should create an idea linked to a recipe", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie) + .send({ name: "Idee recette", recipeId }); + + expect(res.status).toBe(201); + expect(res.body.recipe).not.toBeNull(); + expect(res.body.recipe.id).toBe(recipeId); + }); + + it("should return 404 for recipe not in community", async () => { + const personalRecipe = await testPrisma.recipe.create({ + data: { + title: "Personal Recipe", + creatorId: member.id, + steps: { create: [{ order: 0, instruction: "Step" }] }, + }, + }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie) + .send({ name: "Bad idea", recipeId: personalRecipe.id }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_004"); + }); + + it("should return 400 for empty name", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", memberCookie) + .send({ name: "" }); + + expect(res.status).toBe(400); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-ideas`) + .set("Cookie", nonMemberCookie) + .send({ name: "Test" }); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // PATCH /meal-ideas/:ideaId + // =================================== + describe("PATCH /meal-ideas/:ideaId", () => { + let ideaId: string; + + beforeEach(async () => { + const idea = await testPrisma.mealIdea.create({ + data: { communityId, name: "Original name", createdById: member.id }, + }); + ideaId = idea.id; + }); + + it("should update name (creator)", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie) + .send({ name: "Updated name" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Updated name"); + }); + + it("should update comment (creator)", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie) + .send({ comment: "New comment" }); + + expect(res.status).toBe(200); + expect(res.body.comment).toBe("New comment"); + }); + + it("should set recipeId (creator)", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie) + .send({ recipeId }); + + expect(res.status).toBe(200); + expect(res.body.recipe.id).toBe(recipeId); + }); + + it("should clear recipeId with null", async () => { + // First set recipeId + await testPrisma.mealIdea.update({ + where: { id: ideaId }, + data: { recipeId }, + }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie) + .send({ recipeId: null }); + + expect(res.status).toBe(200); + expect(res.body.recipe).toBeNull(); + }); + + it("should allow moderator to update any idea", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", moderatorCookie) + .send({ name: "Mod updated" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Mod updated"); + }); + + it("should return 403 for other member", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", otherMemberCookie) + .send({ name: "Hacked!" }); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent idea", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/00000000-0000-4000-8000-000000000000`) + .set("Cookie", memberCookie) + .send({ name: "Test" }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_006"); + }); + + it("should return 404 for deleted idea", async () => { + await testPrisma.mealIdea.update({ + where: { id: ideaId }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie) + .send({ name: "Test" }); + + expect(res.status).toBe(404); + }); + }); + + // =================================== + // DELETE /meal-ideas/:ideaId + // =================================== + describe("DELETE /meal-ideas/:ideaId", () => { + let ideaId: string; + + beforeEach(async () => { + const idea = await testPrisma.mealIdea.create({ + data: { communityId, name: "Idea to delete", createdById: member.id }, + }); + ideaId = idea.id; + }); + + it("should soft delete (creator)", async () => { + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(204); + + const idea = await testPrisma.mealIdea.findUnique({ where: { id: ideaId } }); + expect(idea!.deletedAt).not.toBeNull(); + }); + + it("should allow moderator to delete any idea", async () => { + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(204); + }); + + it("should return 403 for other member", async () => { + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", otherMemberCookie); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent idea", async () => { + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-ideas/00000000-0000-4000-8000-000000000000`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_006"); + }); + + it("should return 404 for already deleted idea", async () => { + await testPrisma.mealIdea.update({ + where: { id: ideaId }, + data: { deletedAt: new Date() }, + }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-ideas/${ideaId}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(404); + }); + }); + + // =================================== + // Feature guard + // =================================== + describe("Feature guard", () => { + it("should return 403 when MEAL_PLAN feature is disabled", async () => { + // Create community without feature + const comRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `No Feature Community ${Date.now()}` }); + + const res = await request(app) + .get(`/api/communities/${comRes.body.id}/meal-ideas`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("MEAL_005"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/mealPlan.test.ts b/backend/src/__tests__/integration/mealPlan.test.ts new file mode 100644 index 0000000..cf8b442 --- /dev/null +++ b/backend/src/__tests__/integration/mealPlan.test.ts @@ -0,0 +1,708 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { testPrisma } from "../setup/globalSetup"; +import { createMealTestContext, extractSessionCookie, MealTestContext } from "../setup/testHelpers"; + +describe("Meal Plan API", () => { + let ctx: MealTestContext; + let moderatorCookie: string; + let memberCookie: string; + let nonMemberCookie: string; + let communityId: string; + let recipe1Id: string; + let _recipe2Id: string; + + beforeEach(async () => { + const suffix = Date.now(); + ctx = await createMealTestContext("mp"); + moderatorCookie = ctx.moderatorCookie; + memberCookie = ctx.memberCookie; + communityId = ctx.communityId; + + // Non-membre (specifique a ce test) + const nmSuffix = suffix + 2; + const nmSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `mp_nm_${nmSuffix}`, + email: `mp_nm_${nmSuffix}@example.com`, + password: "Test123!Password", + }); + nonMemberCookie = extractSessionCookie(nmSignup)!; + + // Recettes communautaires + const r1 = await testPrisma.recipe.create({ + data: { + title: `Recipe 1 ${suffix}`, + creatorId: ctx.moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Step 1" }] }, + }, + }); + recipe1Id = r1.id; + + const r2 = await testPrisma.recipe.create({ + data: { + title: `Recipe 2 ${suffix}`, + creatorId: ctx.moderator.id, + communityId, + steps: { create: [{ order: 0, instruction: "Step 1" }] }, + }, + }); + _recipe2Id = r2.id; + }); + + // =================================== + // GET /meal-plan + // =================================== + describe("GET /meal-plan", () => { + it("should return null when no active plan", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.plan).toBeNull(); + }); + + it("should return the active plan with slots", async () => { + // Create plan first + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08", defaultServings: 3 }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.plan).not.toBeNull(); + expect(res.body.plan.slots).toHaveLength(6); // 3 days x 2 meals + expect(res.body.plan.defaultServings).toBe(3); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", nonMemberCookie); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // POST /meal-plan + // =================================== + describe("POST /meal-plan", () => { + it("should create a plan with slots", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-12", defaultServings: 4 }); + + expect(res.status).toBe(201); + expect(res.body.plan.slots).toHaveLength(14); // 7 days x 2 + expect(res.body.plan.status).toBe("ACTIVE"); + expect(res.body.plan.defaultServings).toBe(4); + }); + + it("should create slots with disabled from disabledSlots", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ + startDate: "2026-04-06", + endDate: "2026-04-08", + disabledSlots: [ + { date: "2026-04-06", mealTime: "LUNCH" }, + { date: "2026-04-07", mealTime: "DINNER" }, + ], + }); + + expect(res.status).toBe(201); + const disabledSlots = res.body.plan.slots.filter((s: { disabled: boolean }) => s.disabled); + expect(disabledSlots).toHaveLength(2); + }); + + it("should auto-archive existing active plan", async () => { + // Create first plan + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-07" }); + + // Create second plan (different dates) + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-12" }); + + expect(res.status).toBe(201); + + // Check the first plan is now archived + const plans = await testPrisma.mealPlan.findMany({ + where: { communityId }, + orderBy: { createdAt: "asc" }, + }); + expect(plans).toHaveLength(2); + expect(plans[0].status).toBe("ARCHIVED"); + expect(plans[1].status).toBe("ACTIVE"); + }); + + it("should copy disabled pattern from previous plan", async () => { + // Create and archive first plan with disabled Wednesday slots + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ + startDate: "2026-03-02", // Monday + endDate: "2026-03-08", // Sunday + disabledSlots: [ + { date: "2026-03-04", mealTime: "LUNCH" }, // Wednesday + { date: "2026-03-04", mealTime: "DINNER" }, // Wednesday + ], + }); + + // Create second plan with copyDisabledFromPrevious + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ + startDate: "2026-04-06", // Monday + endDate: "2026-04-12", // Sunday + copyDisabledFromPrevious: true, + }); + + expect(res.status).toBe(201); + const disabledSlots = res.body.plan.slots.filter((s: { disabled: boolean }) => s.disabled); + // Wednesday 2026-04-08 LUNCH + DINNER should be disabled + expect(disabledSlots).toHaveLength(2); + expect(disabledSlots.every((s: { date: string }) => s.date.includes("2026-04-08"))).toBe( + true + ); + }); + + it("should return 400 when startDate > endDate", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-12", endDate: "2026-04-06" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_009"); + }); + + it("should return 400 when duration exceeds 31 days", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-01", endDate: "2026-05-15" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_008"); + }); + + it("should return 403 for member (not moderator)", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", memberCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-12" }); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // DELETE /meal-plan + // =================================== + describe("DELETE /meal-plan", () => { + it("should delete active plan and cascade slots", async () => { + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(204); + + // Verify plan is gone + const plans = await testPrisma.mealPlan.findMany({ where: { communityId } }); + expect(plans).toHaveLength(0); + }); + + it("should return 404 when no active plan", async () => { + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_001"); + }); + + it("should return 403 for member", async () => { + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // PATCH /meal-plan + // =================================== + describe("PATCH /meal-plan", () => { + it("should update defaultServings", async () => { + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ defaultServings: 6 }); + + expect(res.status).toBe(200); + expect(res.body.plan.defaultServings).toBe(6); + }); + + it("should update editableByMembers", async () => { + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ editableByMembers: true }); + + expect(res.status).toBe(200); + expect(res.body.plan.editableByMembers).toBe(true); + }); + + it("should return 404 when no active plan", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ defaultServings: 6 }); + + expect(res.status).toBe(404); + }); + }); + + // =================================== + // PATCH /meal-plan/slots/:slotId + // =================================== + describe("PATCH /meal-plan/slots/:slotId", () => { + let slotId: string; + + beforeEach(async () => { + const planRes = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + slotId = planRes.body.plan.slots[0].id; + }); + + it("should set slot to RECIPE type", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "RECIPE", recipeId: recipe1Id }); + + expect(res.status).toBe(200); + expect(res.body.type).toBe("RECIPE"); + expect(res.body.recipe.id).toBe(recipe1Id); + }); + + it("should set slot to FREE_TEXT type", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "FREE_TEXT", freeText: "Resto japonais", comment: "Reserver" }); + + expect(res.status).toBe(200); + expect(res.body.type).toBe("FREE_TEXT"); + expect(res.body.freeText).toBe("Resto japonais"); + expect(res.body.comment).toBe("Reserver"); + }); + + it("should reset slot to EMPTY", async () => { + // First set to FREE_TEXT + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "FREE_TEXT", freeText: "Test" }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "EMPTY" }); + + expect(res.status).toBe(200); + expect(res.body.type).toBe("EMPTY"); + expect(res.body.freeText).toBeNull(); + expect(res.body.comment).toBeNull(); + expect(res.body.recipe).toBeNull(); + }); + + it("should auto-enable disabled slot when setting content", async () => { + // Disable the slot first + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ disabled: true }); + + // Set recipe on disabled slot + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "RECIPE", recipeId: recipe1Id }); + + expect(res.status).toBe(200); + expect(res.body.disabled).toBe(false); + expect(res.body.type).toBe("RECIPE"); + }); + + it("should update servings independently", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ servings: 8 }); + + expect(res.status).toBe(200); + expect(res.body.servings).toBe(8); + }); + + it("should toggle locked", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ locked: true }); + + expect(res.status).toBe(200); + expect(res.body.locked).toBe(true); + }); + + it("should return 404 for recipe not in community", async () => { + // Create a personal recipe (not in community) + const personalRecipe = await testPrisma.recipe.create({ + data: { + title: "Personal Recipe", + creatorId: ctx.moderator.id, + steps: { create: [{ order: 0, instruction: "Step" }] }, + }, + }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", moderatorCookie) + .send({ type: "RECIPE", recipeId: personalRecipe.id }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_004"); + }); + + it("should return 403 for member when editableByMembers is false", async () => { + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", memberCookie) + .send({ servings: 6 }); + + expect(res.status).toBe(403); + }); + + it("should allow member when editableByMembers is true", async () => { + // Enable editableByMembers + await request(app) + .patch(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ editableByMembers: true }); + + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotId}`) + .set("Cookie", memberCookie) + .send({ servings: 6 }); + + expect(res.status).toBe(200); + expect(res.body.servings).toBe(6); + }); + }); + + // =================================== + // POST /meal-plan/slots/swap + // =================================== + describe("POST /meal-plan/slots/swap", () => { + let slotAId: string; + let slotBId: string; + + beforeEach(async () => { + const planRes = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + slotAId = planRes.body.plan.slots[0].id; + slotBId = planRes.body.plan.slots[1].id; + + // Fill slotA with recipe + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotAId}`) + .set("Cookie", moderatorCookie) + .send({ type: "RECIPE", recipeId: recipe1Id }); + + // Fill slotB with free text + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotBId}`) + .set("Cookie", moderatorCookie) + .send({ type: "FREE_TEXT", freeText: "Test swap" }); + }); + + it("should swap content between two slots", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/swap`) + .set("Cookie", moderatorCookie) + .send({ slotIdA: slotAId, slotIdB: slotBId }); + + expect(res.status).toBe(200); + expect(res.body.slotA.type).toBe("FREE_TEXT"); + expect(res.body.slotA.freeText).toBe("Test swap"); + expect(res.body.slotB.type).toBe("RECIPE"); + expect(res.body.slotB.recipe.id).toBe(recipe1Id); + }); + + it("should return 400 when swapping slot with itself", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/swap`) + .set("Cookie", moderatorCookie) + .send({ slotIdA: slotAId, slotIdB: slotAId }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_007"); + }); + + it("should return 400 when swapping with disabled slot", async () => { + // Disable slotB + await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${slotBId}`) + .set("Cookie", moderatorCookie) + .send({ disabled: true }); + + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/swap`) + .set("Cookie", moderatorCookie) + .send({ slotIdA: slotAId, slotIdB: slotBId }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_013"); + }); + + it("should return 403 for member when editableByMembers is false", async () => { + const res = await request(app) + .post(`/api/communities/${communityId}/meal-plan/slots/swap`) + .set("Cookie", memberCookie) + .send({ slotIdA: slotAId, slotIdB: slotBId }); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // Archive non-editable + // =================================== + describe("Archived plan non-editable", () => { + it("should return 400 when updating slot on archived plan", async () => { + // Create plan 1 + const plan1Res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-03" }); + const archivedSlotId = plan1Res.body.plan.slots[0].id; + + // Create plan 2 (archives plan 1) + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + // Try to update slot from archived plan + const res = await request(app) + .patch(`/api/communities/${communityId}/meal-plan/slots/${archivedSlotId}`) + .set("Cookie", moderatorCookie) + .send({ servings: 10 }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("MEAL_011"); + }); + }); + + // =================================== + // GET /meal-plan/archives + // =================================== + describe("GET /meal-plan/archives", () => { + it("should return empty list when no archives", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan/archives`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + expect(res.body.pagination.total).toBe(0); + }); + + it("should return paginated archives with slot counts", async () => { + // Create plan 1 (will become archive) + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-03" }); + + // Create plan 2 (archives plan 1) + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan/archives`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].totalSlots).toBe(6); // 3 days x 2 + expect(res.body.pagination.total).toBe(1); + }); + + it("should return 403 for non-member", async () => { + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan/archives`) + .set("Cookie", nonMemberCookie); + + expect(res.status).toBe(403); + }); + }); + + // =================================== + // GET /meal-plan/archives/:planId + // =================================== + describe("GET /meal-plan/archives/:planId", () => { + it("should return archive detail with slots", async () => { + // Create plan 1 + const plan1Res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-03" }); + const plan1Id = plan1Res.body.plan.id; + + // Archive it by creating plan 2 + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan/archives/${plan1Id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(200); + expect(res.body.plan.id).toBe(plan1Id); + expect(res.body.plan.status).toBe("ARCHIVED"); + expect(res.body.plan.slots).toHaveLength(6); + }); + + it("should return 404 for active plan id", async () => { + const planRes = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan/archives/${planRes.body.plan.id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_012"); + }); + + it("should return 404 for non-existent plan", async () => { + const res = await request(app) + .get( + `/api/communities/${communityId}/meal-plan/archives/00000000-0000-4000-8000-000000000000` + ) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + }); + }); + + // =================================== + // DELETE /meal-plan/archives/:planId + // =================================== + describe("DELETE /meal-plan/archives/:planId", () => { + it("should delete an archive (MODERATOR)", async () => { + // Create plan 1 then archive it + const plan1Res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-03" }); + const plan1Id = plan1Res.body.plan.id; + + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-plan/archives/${plan1Id}`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(204); + + // Verify archive is gone + const plan = await testPrisma.mealPlan.findUnique({ where: { id: plan1Id } }); + expect(plan).toBeNull(); + }); + + it("should return 403 for member", async () => { + const plan1Res = await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-03-01", endDate: "2026-03-03" }); + const plan1Id = plan1Res.body.plan.id; + + await request(app) + .post(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie) + .send({ startDate: "2026-04-06", endDate: "2026-04-08" }); + + const res = await request(app) + .delete(`/api/communities/${communityId}/meal-plan/archives/${plan1Id}`) + .set("Cookie", memberCookie); + + expect(res.status).toBe(403); + }); + + it("should return 404 for non-existent archive", async () => { + const res = await request(app) + .delete( + `/api/communities/${communityId}/meal-plan/archives/00000000-0000-4000-8000-000000000000` + ) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("MEAL_012"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/requireFeature.test.ts b/backend/src/__tests__/integration/requireFeature.test.ts new file mode 100644 index 0000000..db944a9 --- /dev/null +++ b/backend/src/__tests__/integration/requireFeature.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../app"; +import { testPrisma } from "../setup/globalSetup"; +import { createTestFeature, extractSessionCookie } from "../setup/testHelpers"; + +describe("requireFeature middleware", () => { + let _moderator: { id: string }; + let moderatorCookie: string; + let member: { id: string }; + let _memberCookie: string; + let communityId: string; + let _feature: { id: string; code: string }; + + beforeEach(async () => { + // Creer moderateur via signup pour avoir une session + const modSuffix = Date.now(); + const modSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `feat_mod_${modSuffix}`, + email: `feat_mod_${modSuffix}@example.com`, + password: "Test123!Password", + }); + moderatorCookie = extractSessionCookie(modSignup)!; + _moderator = (await testPrisma.user.findFirst({ + where: { email: `feat_mod_${modSuffix}@example.com` }, + }))!; + + // Creer communaute + const comRes = await request(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `Feature Test Community ${modSuffix}` }); + communityId = comRes.body.id; + + // Creer membre + const memSuffix = Date.now() + 1; + const memSignup = await request(app) + .post("/api/auth/signup") + .send({ + username: `feat_mem_${memSuffix}`, + email: `feat_mem_${memSuffix}@example.com`, + password: "Test123!Password", + }); + _memberCookie = extractSessionCookie(memSignup)!; + member = (await testPrisma.user.findFirst({ + where: { email: `feat_mem_${memSuffix}@example.com` }, + }))!; + + // Ajouter membre a la communaute + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId, role: "MEMBER" }, + }); + + // Creer feature MEAL_PLAN + _feature = await createTestFeature({ code: `MEAL_PLAN_${modSuffix}` }); + }); + + it("should return 403 when feature is not granted to the community", async () => { + // La feature existe mais n'est pas attribuee a la communaute + // On teste via un endpoint meal-plan (qui utilise requireFeature) + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("MEAL_005"); + }); + + it("should allow access when feature is granted", async () => { + // Attribuer la feature MEAL_PLAN a la communaute + // On doit utiliser la vraie feature MEAL_PLAN, pas notre feature de test + const mealPlanFeature = await testPrisma.feature.findFirst({ + where: { code: "MEAL_PLAN" }, + }); + + // Si la feature n'existe pas en DB test, la creer + const featureId = mealPlanFeature + ? mealPlanFeature.id + : (await createTestFeature({ code: "MEAL_PLAN", name: "Planning de repas" })).id; + + await testPrisma.communityFeature.create({ + data: { communityId, featureId }, + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + // Devrait passer le middleware requireFeature (200 ou 404, pas 403) + expect(res.status).not.toBe(403); + }); + + it("should return 403 when feature is revoked", async () => { + const mealPlanFeature = await testPrisma.feature.findFirst({ + where: { code: "MEAL_PLAN" }, + }); + const featureId = mealPlanFeature + ? mealPlanFeature.id + : (await createTestFeature({ code: "MEAL_PLAN", name: "Planning de repas" })).id; + + // Attribuer puis revoquer + await testPrisma.communityFeature.create({ + data: { communityId, featureId, revokedAt: new Date() }, + }); + + const res = await request(app) + .get(`/api/communities/${communityId}/meal-plan`) + .set("Cookie", moderatorCookie); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("MEAL_005"); + }); +}); diff --git a/backend/src/__tests__/setup/globalSetup.ts b/backend/src/__tests__/setup/globalSetup.ts index fa519cc..ac3ba00 100644 --- a/backend/src/__tests__/setup/globalSetup.ts +++ b/backend/src/__tests__/setup/globalSetup.ts @@ -23,6 +23,13 @@ afterEach(async () => { testPrisma.recipeView.deleteMany(), testPrisma.recipeAnalytics.deleteMany(), testPrisma.tagSuggestion.deleteMany(), + testPrisma.mealSlotPin.deleteMany(), + testPrisma.mealGenerationRule.deleteMany(), + testPrisma.mealSlotExclusion.deleteMany(), + testPrisma.mealGenerationParams.deleteMany(), + testPrisma.mealSlot.deleteMany(), + testPrisma.mealPlan.deleteMany(), + testPrisma.mealIdea.deleteMany(), testPrisma.recipeUpdateProposal.deleteMany(), testPrisma.recipe.deleteMany(), testPrisma.tag.deleteMany(), diff --git a/backend/src/__tests__/setup/testHelpers.ts b/backend/src/__tests__/setup/testHelpers.ts index b5cc43e..99f659b 100644 --- a/backend/src/__tests__/setup/testHelpers.ts +++ b/backend/src/__tests__/setup/testHelpers.ts @@ -467,12 +467,128 @@ export async function createTestTagSuggestion( } // ===================================== -// Admin Login Helper +// Changelog Factory +// ===================================== + +interface TestChangelogEntry { + id: string; + version: string; + title: string; + content: Record; + publishedAt: Date; +} + +export async function createTestChangelogEntry( + data?: Partial<{ + version: string; + title: string; + content: Record; + publishedAt: Date; + deletedAt: Date; + }> +): Promise { + const suffix = uniqueSuffix(); + const entry = await testPrisma.changelogEntry.create({ + data: { + version: data?.version ?? `0.0.${Date.now() % 10000}`, + title: data?.title ?? `Test changelog ${suffix}`, + content: (data?.content ?? { + features: [{ text: "Test feature" }], + improvements: [], + fixes: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + publishedAt: data?.publishedAt, + deletedAt: data?.deletedAt, + }, + }); + + return { + id: entry.id, + version: entry.version, + title: entry.title, + content: entry.content as Record, + publishedAt: entry.publishedAt, + }; +} + +// ===================================== +// Meal Plan Test Factory // ===================================== import supertest from "supertest"; import app from "../../app"; +export interface MealTestContext { + moderator: { id: string }; + moderatorCookie: string; + member: { id: string }; + memberCookie: string; + communityId: string; +} + +/** + * Creer un contexte de test complet pour les endpoints meal-plan / meal-generation. + * Cree: moderateur, communaute, feature MEAL_PLAN, membre. + */ +export async function createMealTestContext(prefix: string): Promise { + const suffix = Date.now(); + + // Moderateur + const modSignup = await supertest(app) + .post("/api/auth/signup") + .send({ + username: `${prefix}_mod_${suffix}`, + email: `${prefix}_mod_${suffix}@example.com`, + password: "Test123!Password", + }); + const moderatorCookie = extractSessionCookie(modSignup)!; + const moderator = (await testPrisma.user.findFirst({ + where: { email: `${prefix}_mod_${suffix}@example.com` }, + }))!; + + // Communaute + const comRes = await supertest(app) + .post("/api/communities") + .set("Cookie", moderatorCookie) + .send({ name: `${prefix} Community ${suffix}` }); + const communityId = comRes.body.id; + + // Feature MEAL_PLAN (idempotent) + let mealPlanFeature = await testPrisma.feature.findFirst({ where: { code: "MEAL_PLAN" } }); + if (!mealPlanFeature) { + mealPlanFeature = await testPrisma.feature.create({ + data: { code: "MEAL_PLAN", name: "Planning de repas", isDefault: false }, + }); + } + await testPrisma.communityFeature.create({ + data: { communityId, featureId: mealPlanFeature.id }, + }); + + // Membre + const memSuffix = suffix + 1; + const memSignup = await supertest(app) + .post("/api/auth/signup") + .send({ + username: `${prefix}_mem_${memSuffix}`, + email: `${prefix}_mem_${memSuffix}@example.com`, + password: "Test123!Password", + }); + const memberCookie = extractSessionCookie(memSignup)!; + const member = (await testPrisma.user.findFirst({ + where: { email: `${prefix}_mem_${memSuffix}@example.com` }, + }))!; + await testPrisma.userCommunity.create({ + data: { userId: member.id, communityId, role: "MEMBER" }, + }); + + return { moderator, moderatorCookie, member, memberCookie, communityId }; +} + +// ===================================== +// Admin Login Helper +// ===================================== + /** * Effectuer un login complet admin (password + TOTP) et retourner le cookie de session */ diff --git a/backend/src/__tests__/unit/mealGeneration.test.ts b/backend/src/__tests__/unit/mealGeneration.test.ts new file mode 100644 index 0000000..5395449 --- /dev/null +++ b/backend/src/__tests__/unit/mealGeneration.test.ts @@ -0,0 +1,812 @@ +import { describe, it, expect } from "vitest"; +import { + generate, + GenerationInput, + PoolEntry, + SlotInfo, + dateToDayOfWeek, + weightedRandomPick, +} from "../../services/mealGeneration"; +import { + DayOfWeek, + MealTime, + MealGenerationParams, + MealSlotExclusion, + MealGenerationRule, + MealSlotPin, +} from "@prisma/client"; + +// ============================================= +// Helpers +// ============================================= + +function makeDate(dayOffset: number): Date { + // Start from a Monday (2026-03-23) + const base = new Date("2026-03-23T00:00:00Z"); + base.setUTCDate(base.getUTCDate() + dayOffset); + return base; +} + +function makeSlot( + dayOffset: number, + mealTime: "LUNCH" | "DINNER", + overrides: Partial = {} +): SlotInfo { + return { + id: `slot-${dayOffset}-${mealTime}`, + date: makeDate(dayOffset), + mealTime, + type: "EMPTY", + disabled: false, + locked: false, + recipeId: null, + ...overrides, + }; +} + +function makeParams(overrides: Partial = {}): MealGenerationParams { + return { + id: "params-1", + communityId: "comm-1", + name: "Test", + description: null, + cooldownDays: 3, + useIdeas: false, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function makeRecipe(id: string, tagIds: string[] = []): PoolEntry { + return { id, type: "RECIPE", recipeId: id, tagIds }; +} + +function makeIdea( + id: string, + name: string, + recipeId: string | null = null, + tagIds: string[] = [] +): PoolEntry { + return { + id, + type: "IDEA", + recipeId, + tagIds, + freeText: name, + comment: null, + }; +} + +function makeRule( + overrides: Partial & { tag?: { id: string; name: string } | null } +): MealGenerationRule & { tag?: { id: string; name: string } | null } { + return { + id: `rule-${Math.random().toString(36).slice(2, 8)}`, + paramsId: "params-1", + tagId: null, + recipeId: null, + weight: 1.0, + mealTimeConstraint: null, + frequencyMin: null, + frequencyMax: null, + frequencyPer: null, + tagCooldownDays: null, + tag: null, + ...overrides, + }; +} + +function makeExclusion(day: string, mealTime: string): MealSlotExclusion { + return { + id: `exc-${day}-${mealTime}`, + paramsId: "params-1", + day: day as DayOfWeek, + mealTime: mealTime as MealTime, + }; +} + +function makePin(day: string, mealTime: string, tagId: string): MealSlotPin { + return { + id: `pin-${day}-${mealTime}`, + paramsId: "params-1", + day: day as DayOfWeek, + mealTime: mealTime as MealTime, + tagId, + }; +} + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + params: makeParams(), + exclusions: [], + rules: [], + pins: [], + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + pool: [makeRecipe("r1"), makeRecipe("r2"), makeRecipe("r3")], + previousSlots: [], + fillEmptyOnly: false, + ...overrides, + }; +} + +// ============================================= +// Tests +// ============================================= + +describe("dateToDayOfWeek", () => { + it("should return correct day of week", () => { + // 2026-03-23 is a Monday + expect(dateToDayOfWeek(new Date("2026-03-23T00:00:00Z"))).toBe("MON"); + expect(dateToDayOfWeek(new Date("2026-03-24T00:00:00Z"))).toBe("TUE"); + expect(dateToDayOfWeek(new Date("2026-03-29T00:00:00Z"))).toBe("SUN"); + }); +}); + +describe("weightedRandomPick", () => { + it("should return null for empty array", () => { + expect(weightedRandomPick([], [])).toBeNull(); + }); + + it("should return the only item if single", () => { + expect(weightedRandomPick(["a"], [1])).toBe("a"); + }); + + it("should never pick zero-weight items", () => { + for (let i = 0; i < 50; i++) { + const result = weightedRandomPick(["a", "b"], [0, 1]); + expect(result).toBe("b"); + } + }); +}); + +describe("generate - basic", () => { + it("should fill all empty slots", () => { + const result = generate(makeInput()); + expect(result.assignments).toHaveLength(2); + expect(result.report.slotsGenerated).toBe(2); + expect(result.assignments.every((a) => a.type === "RECIPE")).toBe(true); + }); + + it("should handle single recipe in pool (no cooldown)", () => { + const result = generate( + makeInput({ + pool: [makeRecipe("r1")], + params: makeParams({ cooldownDays: 0 }), + }) + ); + expect(result.assignments).toHaveLength(2); + expect(result.assignments.every((a) => a.recipeId === "r1")).toBe(true); + }); +}); + +describe("generate - skip logic", () => { + it("should skip disabled slots", () => { + const result = generate( + makeInput({ + slots: [makeSlot(0, "LUNCH", { disabled: true }), makeSlot(0, "DINNER")], + }) + ); + expect(result.assignments).toHaveLength(1); + expect(result.report.slotsSkipped.disabled).toBe(1); + expect(result.report.slotsGenerated).toBe(1); + }); + + it("should skip excluded slots", () => { + const result = generate( + makeInput({ + exclusions: [makeExclusion("MON", "LUNCH")], + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + }) + ); + expect(result.assignments).toHaveLength(1); + expect(result.report.slotsSkipped.excluded).toBe(1); + }); + + it("should skip locked slots", () => { + const result = generate( + makeInput({ + slots: [ + makeSlot(0, "LUNCH", { locked: true, recipeId: "r1", type: "RECIPE" }), + makeSlot(0, "DINNER"), + ], + }) + ); + expect(result.assignments).toHaveLength(1); + expect(result.report.slotsSkipped.locked).toBe(1); + }); + + it("should skip already filled slots when fillEmptyOnly", () => { + const result = generate( + makeInput({ + fillEmptyOnly: true, + slots: [makeSlot(0, "LUNCH", { type: "RECIPE", recipeId: "r1" }), makeSlot(0, "DINNER")], + }) + ); + expect(result.assignments).toHaveLength(1); + expect(result.report.slotsSkipped.alreadyFilled).toBe(1); + }); + + it("should not skip filled slots when fillEmptyOnly is false", () => { + const result = generate( + makeInput({ + fillEmptyOnly: false, + slots: [makeSlot(0, "LUNCH", { type: "RECIPE", recipeId: "r1" }), makeSlot(0, "DINNER")], + }) + ); + expect(result.assignments).toHaveLength(2); + expect(result.report.slotsGenerated).toBe(2); + }); +}); + +describe("generate - pin", () => { + it("should only pick recipes with pinned tag", () => { + const pool = [ + makeRecipe("r1", ["tag-poisson"]), + makeRecipe("r2", []), + makeRecipe("r3", ["tag-poisson"]), + ]; + + const result = generate( + makeInput({ + pool, + pins: [makePin("MON", "LUNCH", "tag-poisson")], + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments).toHaveLength(1); + expect(["r1", "r3"]).toContain(result.assignments[0].recipeId); + }); + + it("should return POOL_EXHAUSTED when no recipe matches pin", () => { + const result = generate( + makeInput({ + pool: [makeRecipe("r1", []), makeRecipe("r2", [])], + pins: [makePin("MON", "LUNCH", "tag-missing")], + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments[0].type).toBe("EMPTY"); + expect(result.report.warnings.some((w) => w.type === "POOL_EXHAUSTED")).toBe(true); + }); +}); + +describe("generate - cooldown recette", () => { + it("should not pick the same recipe within cooldown days", () => { + // cooldownDays=3, 4 slots over 2 days, only 1 recipe + // Recipe can only be picked once every 3 days + const result = generate( + makeInput({ + params: makeParams({ cooldownDays: 3 }), + pool: [makeRecipe("r1"), makeRecipe("r2")], + slots: [ + makeSlot(0, "LUNCH"), + makeSlot(0, "DINNER"), + makeSlot(1, "LUNCH"), + makeSlot(1, "DINNER"), + ], + }) + ); + + // With cooldown=3 days, r1 and r2 can each appear once in the first day (day 0), + // and neither can appear again on day 1 (within 3 days) + // So day 1 should exhaust the pool + const day0 = result.assignments.filter((a) => a.slotId.startsWith("slot-0")); + const day1 = result.assignments.filter((a) => a.slotId.startsWith("slot-1")); + + // day 0 should have 2 filled slots with different recipes + expect(day0).toHaveLength(2); + // day 1: pool exhausted (both r1 and r2 used on day 0, cooldown=3 days) + expect(day1.every((a) => a.type === "EMPTY")).toBe(true); + }); + + it("should respect cooldown=0 (no cooldown)", () => { + const result = generate( + makeInput({ + params: makeParams({ cooldownDays: 0 }), + pool: [makeRecipe("r1")], + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + }) + ); + + expect(result.assignments.every((a) => a.recipeId === "r1")).toBe(true); + }); + + it("should respect cross-planning cooldown", () => { + const result = generate( + makeInput({ + params: makeParams({ cooldownDays: 2 }), + pool: [makeRecipe("r1"), makeRecipe("r2")], + previousSlots: [{ date: makeDate(-1), recipeId: "r1", tagIds: [] }], + slots: [makeSlot(0, "LUNCH")], + }) + ); + + // r1 was used yesterday (1 day ago), cooldown=2, so r1 should be excluded + expect(result.assignments[0].recipeId).toBe("r2"); + }); +}); + +describe("generate - cooldown tag", () => { + it("should exclude recipes by tag cooldown", () => { + const rules = [ + makeRule({ tagId: "tag-pates", tagCooldownDays: 2, tag: { id: "tag-pates", name: "pates" } }), + ]; + const pool = [makeRecipe("r1", ["tag-pates"]), makeRecipe("r2", ["tag-viande"])]; + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + }) + ); + + // First slot: either r1 or r2. If r1 picked, second slot must be r2 (tag cooldown) + const first = result.assignments[0]; + const second = result.assignments[1]; + if (first.recipeId === "r1") { + expect(second.recipeId).toBe("r2"); + } + // Both should be filled + expect(result.assignments.every((a) => a.type === "RECIPE")).toBe(true); + }); +}); + +describe("generate - frequencyMax", () => { + it("should stop picking tag when frequencyMax reached", () => { + const rules = [ + makeRule({ + tagId: "tag-viande", + frequencyMax: 1, + frequencyPer: "PER_PLANNING", + tag: { id: "tag-viande", name: "viande" }, + }), + ]; + const pool = [ + makeRecipe("r1", ["tag-viande"]), + makeRecipe("r2", ["tag-viande"]), + makeRecipe("r3", ["tag-legume"]), + ]; + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER"), makeSlot(1, "LUNCH")], + }) + ); + + // At most 1 slot should have tag-viande + const viandeCount = result.assignments.filter((a) => { + const entry = pool.find((p) => p.recipeId === a.recipeId); + return entry?.tagIds.includes("tag-viande"); + }).length; + expect(viandeCount).toBeLessThanOrEqual(1); + }); +}); + +describe("generate - weight computation", () => { + it("should favor higher-weight recipes", () => { + const rules = [ + makeRule({ tagId: "tag-fav", weight: 2.0, tag: { id: "tag-fav", name: "fav" } }), + ]; + const pool = [makeRecipe("r-fav", ["tag-fav"]), makeRecipe("r-normal", [])]; + + // Run many times and check that r-fav is picked more often + let favCount = 0; + const iterations = 200; + for (let i = 0; i < iterations; i++) { + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + if (result.assignments[0].recipeId === "r-fav") favCount++; + } + + // r-fav has weight 2.0, r-normal has weight 1.0 + // Expected ratio: ~66% for r-fav + expect(favCount).toBeGreaterThan(iterations * 0.45); + }); + + it("should exclude recipes with weight=0", () => { + const rules = [ + makeRule({ + tagId: "tag-excluded", + weight: 0.0, + tag: { id: "tag-excluded", name: "excluded" }, + }), + ]; + const pool = [makeRecipe("r1", ["tag-excluded"]), makeRecipe("r2", [])]; + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments[0].recipeId).toBe("r2"); + }); + + it("should apply mealTimeConstraint on rules", () => { + const rules = [ + makeRule({ + tagId: "tag-a", + weight: 0.0, + mealTimeConstraint: "DINNER", + tag: { id: "tag-a", name: "a" }, + }), + ]; + const pool = [makeRecipe("r1", ["tag-a"]), makeRecipe("r2", [])]; + + // LUNCH slot: rule doesn't apply (mealTimeConstraint=DINNER), r1 should still be available + const lunchResult = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + // r1 should be possible at lunch + // (we can't guarantee it's picked due to randomness, so just check no error) + expect(lunchResult.assignments[0].type).toBe("RECIPE"); + + // DINNER slot: rule applies, r1 excluded (weight=0) + const dinnerResult = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "DINNER")], + }) + ); + expect(dinnerResult.assignments[0].recipeId).toBe("r2"); + }); + + it("should multiply weights from multiple tag rules", () => { + const rules = [ + makeRule({ tagId: "tag-a", weight: 1.5, tag: { id: "tag-a", name: "a" } }), + makeRule({ tagId: "tag-b", weight: 1.8, tag: { id: "tag-b", name: "b" } }), + ]; + // Recipe with both tags: weight = 1.0 * 1.5 * 1.8 = 2.7 + // Recipe with no tags: weight = 1.0 + const pool = [makeRecipe("r-multi", ["tag-a", "tag-b"]), makeRecipe("r-plain", [])]; + + let multiCount = 0; + for (let i = 0; i < 200; i++) { + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + if (result.assignments[0].recipeId === "r-multi") multiCount++; + } + + // r-multi weight 2.7 vs r-plain weight 1.0 -> ~73% expected + expect(multiCount).toBeGreaterThan(100); + }); +}); + +describe("generate - MealIdea", () => { + it("should produce FREE_TEXT for idea without recipeId", () => { + const pool = [makeIdea("idea-1", "Resto japonais")]; + + const result = generate( + makeInput({ + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments[0].type).toBe("FREE_TEXT"); + expect(result.assignments[0].freeText).toBe("Resto japonais"); + expect(result.assignments[0].recipeId).toBeNull(); + }); + + it("should treat idea with recipeId as RECIPE", () => { + const pool = [makeIdea("idea-1", "Lasagnes", "recipe-linked", ["tag-italien"])]; + + const result = generate( + makeInput({ + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments[0].type).toBe("RECIPE"); + expect(result.assignments[0].recipeId).toBe("recipe-linked"); + }); +}); + +describe("generate - pool exhausted", () => { + it("should set EMPTY and warn when pool is empty", () => { + const result = generate( + makeInput({ + pool: [], + slots: [makeSlot(0, "LUNCH")], + }) + ); + + expect(result.assignments[0].type).toBe("EMPTY"); + expect(result.report.slotsEmpty).toBe(1); + expect(result.report.warnings).toHaveLength(1); + expect(result.report.warnings[0].type).toBe("POOL_EXHAUSTED"); + }); +}); + +// ============================================= +// Phase 5 Tests: frequencyMin catch-up + report +// ============================================= + +describe("generate - frequencyMin catch-up", () => { + it("should replace low-weight slots to meet frequencyMin (PER_PLANNING)", () => { + const rules = [ + makeRule({ + tagId: "tag-veg", + weight: 1.0, + frequencyMin: 2, + frequencyPer: "PER_PLANNING", + tag: { id: "tag-veg", name: "vegetarien" }, + }), + ]; + + // Pool: 3 veg recipes + 3 non-veg (lower weight due to tag rule boosting veg) + const pool = [ + makeRecipe("veg1", ["tag-veg"]), + makeRecipe("veg2", ["tag-veg"]), + makeRecipe("veg3", ["tag-veg"]), + makeRecipe("meat1", []), + makeRecipe("meat2", []), + makeRecipe("meat3", []), + ]; + + // Run many times to handle randomness + let minMetCount = 0; + const iterations = 50; + for (let i = 0; i < iterations; i++) { + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [ + makeSlot(0, "LUNCH"), + makeSlot(0, "DINNER"), + makeSlot(1, "LUNCH"), + makeSlot(1, "DINNER"), + ], + }) + ); + + const vegCount = result.assignments.filter((a) => { + const entry = pool.find((p) => p.recipeId === a.recipeId); + return entry?.tagIds.includes("tag-veg"); + }).length; + + if (vegCount >= 2) minMetCount++; + } + + // Should meet the min most of the time (catch-up should help) + expect(minMetCount).toBeGreaterThan(iterations * 0.7); + }); + + it("should warn FREQUENCY_MIN_NOT_MET when impossible to satisfy", () => { + const rules = [ + makeRule({ + tagId: "tag-rare", + weight: 1.0, + frequencyMin: 5, + frequencyPer: "PER_PLANNING", + tag: { id: "tag-rare", name: "rare" }, + }), + ]; + + // Only 1 recipe with the tag but need 5 occurrences in 2 slots + const pool = [makeRecipe("rare1", ["tag-rare"]), makeRecipe("normal1", [])]; + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + }) + ); + + const warning = result.report.warnings.find((w) => w.type === "FREQUENCY_MIN_NOT_MET"); + expect(warning).toBeDefined(); + expect(warning!.tagId).toBe("tag-rare"); + expect(warning!.required).toBe(5); + expect(warning!.actual).toBeLessThan(5); + }); + + it("should handle exact mode (frequencyMin == frequencyMax)", () => { + const rules = [ + makeRule({ + tagId: "tag-poisson", + weight: 1.0, + frequencyMin: 2, + frequencyMax: 2, + frequencyPer: "PER_PLANNING", + tag: { id: "tag-poisson", name: "poisson" }, + }), + ]; + + const pool = [ + makeRecipe("fish1", ["tag-poisson"]), + makeRecipe("fish2", ["tag-poisson"]), + makeRecipe("fish3", ["tag-poisson"]), + makeRecipe("meat1", []), + makeRecipe("meat2", []), + makeRecipe("meat3", []), + ]; + + let exactCount = 0; + const iterations = 50; + for (let i = 0; i < iterations; i++) { + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots: [ + makeSlot(0, "LUNCH"), + makeSlot(0, "DINNER"), + makeSlot(1, "LUNCH"), + makeSlot(1, "DINNER"), + ], + }) + ); + + const fishCount = result.assignments.filter((a) => { + const entry = pool.find((p) => p.recipeId === a.recipeId); + return entry?.tagIds.includes("tag-poisson"); + }).length; + + // frequencyMax=2 is enforced by main pass, frequencyMin=2 by catch-up + if (fishCount === 2) exactCount++; + } + + // Should hit exactly 2 most of the time + expect(exactCount).toBeGreaterThan(iterations * 0.6); + }); + + it("should handle PER_WEEK frequency windows", () => { + const rules = [ + makeRule({ + tagId: "tag-veg", + weight: 1.0, + frequencyMin: 1, + frequencyMax: 2, + frequencyPer: "PER_WEEK", + tag: { id: "tag-veg", name: "vegetarien" }, + }), + ]; + + const pool = [ + makeRecipe("veg1", ["tag-veg"]), + makeRecipe("veg2", ["tag-veg"]), + makeRecipe("meat1", []), + makeRecipe("meat2", []), + makeRecipe("meat3", []), + makeRecipe("meat4", []), + ]; + + // 14 days = 2 weeks -> each week should have 1-2 veg meals + let allWindowsMet = 0; + const iterations = 60; + for (let i = 0; i < iterations; i++) { + const slots = []; + for (let d = 0; d < 14; d++) { + slots.push(makeSlot(d, "LUNCH")); + slots.push(makeSlot(d, "DINNER")); + } + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + slots, + }) + ); + + // Count veg per week + const week1Veg = result.assignments + .filter((a) => a.slotId.match(/slot-[0-6]/)) + .filter((a) => { + const entry = pool.find((p) => p.recipeId === a.recipeId); + return entry?.tagIds.includes("tag-veg"); + }).length; + + const week2Veg = result.assignments + .filter((a) => a.slotId.match(/slot-(7|8|9|1[0-3])/)) + .filter((a) => { + const entry = pool.find((p) => p.recipeId === a.recipeId); + return entry?.tagIds.includes("tag-veg"); + }).length; + + if (week1Veg >= 1 && week1Veg <= 2 && week2Veg >= 1 && week2Veg <= 2) { + allWindowsMet++; + } + } + + expect(allWindowsMet).toBeGreaterThanOrEqual(iterations * 0.4); + }); +}); + +describe("generate - FREQUENCY_MAX_EXCEEDED warning (pin conflict)", () => { + it("should warn when pin forces tag beyond frequencyMax", () => { + const rules = [ + makeRule({ + tagId: "tag-a", + weight: 1.0, + frequencyMax: 1, + frequencyPer: "PER_PLANNING", + tag: { id: "tag-a", name: "a" }, + }), + ]; + + // All recipes have tag-a, and 2 slots pinned to tag-a + // frequencyMax=1 but 2 pinned slots -> exceed + const pool = [makeRecipe("r1", ["tag-a"]), makeRecipe("r2", ["tag-a"])]; + + const result = generate( + makeInput({ + rules, + pool, + params: makeParams({ cooldownDays: 0 }), + pins: [makePin("MON", "LUNCH", "tag-a"), makePin("MON", "DINNER", "tag-a")], + slots: [makeSlot(0, "LUNCH"), makeSlot(0, "DINNER")], + }) + ); + + // Both slots should be filled (pins are absolute) + expect(result.assignments.filter((a) => a.type === "RECIPE")).toHaveLength(2); + // Should have a FREQUENCY_MAX_EXCEEDED warning + const warning = result.report.warnings.find((w) => w.type === "FREQUENCY_MAX_EXCEEDED"); + expect(warning).toBeDefined(); + }); +}); + +describe("generate - full report", () => { + it("should produce a complete report", () => { + const result = generate( + makeInput({ + exclusions: [makeExclusion("MON", "DINNER")], + pool: [makeRecipe("r1"), makeRecipe("r2"), makeRecipe("r3")], + slots: [ + makeSlot(0, "LUNCH"), + makeSlot(0, "DINNER"), // excluded + makeSlot(1, "LUNCH", { disabled: true }), + makeSlot(1, "DINNER", { locked: true, type: "RECIPE", recipeId: "r1" }), + ], + }) + ); + + expect(result.report.slotsGenerated).toBe(1); // Only MON LUNCH + expect(result.report.slotsSkipped.excluded).toBe(1); + expect(result.report.slotsSkipped.disabled).toBe(1); + expect(result.report.slotsSkipped.locked).toBe(1); + expect(result.assignments).toHaveLength(1); + }); +}); diff --git a/backend/src/admin/controllers/changelogController.ts b/backend/src/admin/controllers/changelogController.ts new file mode 100644 index 0000000..86242e5 --- /dev/null +++ b/backend/src/admin/controllers/changelogController.ts @@ -0,0 +1,169 @@ +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 { CHANGELOG_001, CHANGELOG_002 } from "../../constants/errorCodes"; +import { AdminCreateChangelogInput, AdminUpdateChangelogInput } from "../schemas/changelog.schema"; + +/** + * GET /api/admin/changelog + * Liste paginee des entrees changelog (includeDeleted optionnel) + */ +export const getAll: RequestHandler = async (req, res, next) => { + try { + const { limit, offset } = parsePagination(req.query as Record); + const includeDeleted = req.query.includeDeleted === "true"; + + const where = includeDeleted ? {} : { deletedAt: null }; + + const [entries, total] = await Promise.all([ + prisma.changelogEntry.findMany({ + where, + orderBy: { publishedAt: "desc" }, + skip: offset, + take: limit, + }), + prisma.changelogEntry.count({ where }), + ]); + + res.status(200).json({ + data: entries, + pagination: buildPaginationMeta(total, limit, offset, entries.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/admin/changelog + * Creation manuelle d'une entree + */ +export const create: RequestHandler = async (req, res, next) => { + try { + const { version, title, content, publishedAt } = req.body as AdminCreateChangelogInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const existing = await prisma.changelogEntry.findUnique({ where: { version } }); + if (existing) { + throw createHttpError(409, CHANGELOG_002); + } + + const entry = await prisma.changelogEntry.create({ + data: { + version, + title, + content, + ...(publishedAt && { publishedAt }), + }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "CHANGELOG_CREATED", + targetType: "ChangelogEntry", + targetId: entry.id, + metadata: { version, title }, + }, + }); + + res.status(201).json({ data: entry }); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/admin/changelog/:id + * Modification d'une entree (title, content, version, publishedAt) + */ +export const update: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const { version, title, content, publishedAt } = req.body as AdminUpdateChangelogInput; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const entry = await prisma.changelogEntry.findUnique({ where: { id } }); + if (!entry || entry.deletedAt) { + throw createHttpError(404, CHANGELOG_001); + } + + if (version && version !== entry.version) { + const existing = await prisma.changelogEntry.findUnique({ where: { version } }); + if (existing) { + throw createHttpError(409, CHANGELOG_002); + } + } + + const updated = await prisma.changelogEntry.update({ + where: { id }, + data: { + ...(version && { version }), + ...(title && { title }), + ...(content && { content }), + ...(publishedAt && { publishedAt }), + }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "CHANGELOG_UPDATED", + targetType: "ChangelogEntry", + targetId: id, + metadata: { + oldVersion: entry.version, + ...(version && { newVersion: version }), + ...(title && { newTitle: title }), + }, + }, + }); + + res.status(200).json({ data: updated }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/admin/changelog/:id + * Soft delete + */ +export const remove: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const adminId = req.session.adminId; + assertIsDefine(adminId); + + const entry = await prisma.changelogEntry.findUnique({ where: { id } }); + if (!entry) { + throw createHttpError(404, CHANGELOG_001); + } + if (entry.deletedAt) { + throw createHttpError(404, CHANGELOG_001); + } + + await prisma.changelogEntry.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await prisma.adminActivityLog.create({ + data: { + adminId, + type: "CHANGELOG_DELETED", + targetType: "ChangelogEntry", + targetId: id, + metadata: { version: entry.version, title: entry.title }, + }, + }); + + res.status(200).json({ message: "Changelog entry deleted" }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/admin/routes/changelogRoutes.ts b/backend/src/admin/routes/changelogRoutes.ts new file mode 100644 index 0000000..74395a8 --- /dev/null +++ b/backend/src/admin/routes/changelogRoutes.ts @@ -0,0 +1,29 @@ +import express from "express"; +import * as changelogController from "../controllers/changelogController"; +import { validateUUID } from "../../middleware/validateUUID"; +import { validateBody } from "../../middleware/validateBody"; +import { + adminCreateChangelogSchema, + adminUpdateChangelogSchema, +} from "../schemas/changelog.schema"; + +const router = express.Router(); + +// GET /api/admin/changelog - Liste paginee +router.get("/", changelogController.getAll); + +// POST /api/admin/changelog - Creation manuelle +router.post("/", validateBody(adminCreateChangelogSchema), changelogController.create); + +// PATCH /api/admin/changelog/:id - Modification +router.patch( + "/:id", + validateUUID, + validateBody(adminUpdateChangelogSchema), + changelogController.update +); + +// DELETE /api/admin/changelog/:id - Soft delete +router.delete("/:id", validateUUID, changelogController.remove); + +export default router; diff --git a/backend/src/admin/schemas/changelog.schema.ts b/backend/src/admin/schemas/changelog.schema.ts new file mode 100644 index 0000000..0a4ac09 --- /dev/null +++ b/backend/src/admin/schemas/changelog.schema.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { CHANGELOG_003, CHANGELOG_004 } from "../../constants/errorCodes"; + +const semverRegex = /^\d+\.\d+\.\d+$/; + +const changelogItemSchema = z.object({ + text: z.string().min(1).max(500), +}); + +const changelogContentSchema = z + .object({ + features: z.array(changelogItemSchema).default([]), + improvements: z.array(changelogItemSchema).default([]), + fixes: z.array(changelogItemSchema).default([]), + }) + .refine((c) => c.features.length > 0 || c.improvements.length > 0 || c.fixes.length > 0, { + message: CHANGELOG_003, + }); + +export const adminCreateChangelogSchema = z.object({ + version: z.string().regex(semverRegex, CHANGELOG_004), + title: z.string().min(1).max(200), + content: changelogContentSchema, + publishedAt: z.coerce.date().optional(), +}); + +export const adminUpdateChangelogSchema = z.object({ + version: z.string().regex(semverRegex, CHANGELOG_004).optional(), + title: z.string().min(1).max(200).optional(), + content: changelogContentSchema.optional(), + publishedAt: z.coerce.date().optional(), +}); + +export type AdminCreateChangelogInput = z.infer; +export type AdminUpdateChangelogInput = z.infer; diff --git a/backend/src/app.ts b/backend/src/app.ts index 2e33204..54975f4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -22,6 +22,7 @@ import usersRoutes from "./routes/users"; import proposalsRoutes from "./routes/proposals"; import tagSuggestionsRoutes from "./routes/tagSuggestions"; import notificationsRoutes from "./routes/notifications"; +import changelogRoutes from "./routes/changelog"; // Admin routes import adminAuthRoutes from "./admin/routes/authRoutes"; @@ -33,6 +34,7 @@ import adminDashboardRoutes from "./admin/routes/dashboardRoutes"; import adminActivityRoutes from "./admin/routes/activityRoutes"; import adminUnitsRoutes from "./admin/routes/unitsRoutes"; import adminRecipesRoutes from "./admin/routes/recipesRoutes"; +import adminChangelogRoutes from "./admin/routes/changelogRoutes"; const app = express(); @@ -60,6 +62,7 @@ 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); +app.use("/api/changelog", userSession, requireAuth, changelogRoutes); // Admin routes app.use("/api/admin", adminRateLimiter); @@ -72,6 +75,7 @@ app.use("/api/admin/dashboard", adminSession, requireSuperAdmin, adminDashboardR 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("/api/admin/changelog", adminSession, requireSuperAdmin, adminChangelogRoutes); // 404 + error handler app.use((_req, _res, next) => next(createHttpError(404, "Endpoint not found"))); diff --git a/backend/src/constants/errorCodes.ts b/backend/src/constants/errorCodes.ts index 37a3ce0..5bcf576 100644 --- a/backend/src/constants/errorCodes.ts +++ b/backend/src/constants/errorCodes.ts @@ -134,6 +134,40 @@ export const VALIDATION_001_TYPE = "VALIDATION_001: must be a string"; // ===================================== export const CSRF_001 = "CSRF_001: Invalid or missing CSRF token"; +// ===================================== +// Meal Plan +// ===================================== +export const MEAL_001 = "MEAL_001: Plan not found"; +export const MEAL_002 = "MEAL_002: An active plan already exists"; +export const MEAL_003 = "MEAL_003: Slot not found"; +export const MEAL_004 = "MEAL_004: Recipe not found in this community"; +export const MEAL_005 = "MEAL_005: Feature not enabled for this community"; +export const MEAL_006 = "MEAL_006: Idea not found"; +export const MEAL_007 = "MEAL_007: Cannot swap a slot with itself"; +export const MEAL_008 = "MEAL_008: Plan duration exceeds 31 days"; +export const MEAL_009 = "MEAL_009: startDate must be before or equal to endDate"; +export const MEAL_010 = "MEAL_010: Slot already exists in another plan for this community"; +export const MEAL_011 = "MEAL_011: Cannot edit an archived plan"; +export const MEAL_012 = "MEAL_012: Archive not found"; +export const MEAL_013 = "MEAL_013: Cannot swap with a disabled slot"; + +// ===================================== +// Meal Generation +// ===================================== +export const MEAL_GEN_001 = "MEAL_GEN_001: Generation params not found"; +export const MEAL_GEN_002 = "MEAL_GEN_002: No meal plan exists"; +export const MEAL_GEN_007 = "MEAL_GEN_007: Slot is excluded in this params set"; +export const MEAL_GEN_008 = "MEAL_GEN_008: Slot is locked"; +export const MEAL_GEN_013 = "MEAL_GEN_013: Cannot generate on an archived plan"; +export const MEAL_GEN_003 = "MEAL_GEN_003: Invalid rule: must have tagId OR recipeId, not both"; +export const MEAL_GEN_004 = "MEAL_GEN_004: Weight must be between 0.0 and 2.0"; +export const MEAL_GEN_005 = "MEAL_GEN_005: Cannot have multiple default params for same community"; +export const MEAL_GEN_006 = "MEAL_GEN_006: Rule not found"; +export const MEAL_GEN_009 = "MEAL_GEN_009: Frequency constraints only apply to tag rules"; +export const MEAL_GEN_010 = "MEAL_GEN_010: frequencyMin must be <= frequencyMax"; +export const MEAL_GEN_011 = "MEAL_GEN_011: tagCooldownDays only applies to tag rules"; +export const MEAL_GEN_012 = "MEAL_GEN_012: Slot cannot be both excluded and pinned"; + // ===================================== // Admin Auth // ===================================== @@ -209,3 +243,11 @@ 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"; + +// ===================================== +// Admin Changelog +// ===================================== +export const CHANGELOG_001 = "CHANGELOG_001: Changelog entry not found"; +export const CHANGELOG_002 = "CHANGELOG_002: Version already exists"; +export const CHANGELOG_003 = "CHANGELOG_003: Invalid content format"; +export const CHANGELOG_004 = "CHANGELOG_004: Invalid version format (expected semver x.y.z)"; diff --git a/backend/src/controllers/changelog.ts b/backend/src/controllers/changelog.ts new file mode 100644 index 0000000..ab40043 --- /dev/null +++ b/backend/src/controllers/changelog.ts @@ -0,0 +1,53 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import prisma from "../util/db"; +import { parsePagination, buildPaginationMeta } from "../util/pagination"; +import { CHANGELOG_001 } from "../constants/errorCodes"; + +/** + * GET /api/changelog + * Liste paginee des entrees changelog (actives uniquement) + */ +export const getAll: RequestHandler = async (req, res, next) => { + try { + const { limit, offset } = parsePagination(req.query as Record); + + const where = { deletedAt: null }; + + const [entries, total] = await Promise.all([ + prisma.changelogEntry.findMany({ + where, + orderBy: { publishedAt: "desc" }, + skip: offset, + take: limit, + }), + prisma.changelogEntry.count({ where }), + ]); + + res.status(200).json({ + data: entries, + pagination: buildPaginationMeta(total, limit, offset, entries.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/changelog/:id + * Detail d'une entree (active uniquement) + */ +export const getById: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + + const entry = await prisma.changelogEntry.findUnique({ where: { id } }); + if (!entry || entry.deletedAt) { + throw createHttpError(404, CHANGELOG_001); + } + + res.status(200).json({ data: entry }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/mealGeneration.ts b/backend/src/controllers/mealGeneration.ts new file mode 100644 index 0000000..5bf6f37 --- /dev/null +++ b/backend/src/controllers/mealGeneration.ts @@ -0,0 +1,240 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { Prisma } from "@prisma/client"; +import prisma from "../util/db"; +import { + MEAL_003, + MEAL_GEN_002, + MEAL_GEN_007, + MEAL_GEN_008, + MEAL_GEN_013, +} from "../constants/errorCodes"; +import { GenerateInput, ReplaceSlotInput } from "../schemas/mealGeneration.schema"; +import { generate, GenerationInput, SlotInfo } from "../services/mealGeneration"; +import { + loadGenerationParams, + buildPool, + buildPreviousSlots, + slotsToSlotInfo, +} from "../services/mealGenerationService"; +import { formatDeletedRelation } from "../util/responseFormatters"; + +// Slot include for queries (partage avec mealPlan controller) +const slotInclude = { + recipe: { + select: { id: true, title: true, imageKey: true, deletedAt: true }, + }, + updatedBy: { + select: { id: true, username: true }, + }, +}; + +function formatSlot(slot: Prisma.MealSlotGetPayload<{ include: typeof slotInclude }>) { + return { + ...slot, + recipe: formatDeletedRelation(slot.recipe, ["id", "title", "imageKey"]), + }; +} + +/** + * POST /api/communities/:communityId/meal-plan/generate + * Generer le planning (MODERATOR) + */ +export const generatePlan = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const body = req.body as GenerateInput; + + // 1. Verifier que le plan ACTIVE existe + const plan = await prisma.mealPlan.findFirst({ + where: { communityId, status: "ACTIVE" }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }); + + if (!plan) { + throw createHttpError(404, MEAL_GEN_002); + } + + // 2. Charger les params + exclusions + rules + pins + const params = await loadGenerationParams(body.paramsId, communityId); + + // 3. Construire les inputs + const [pool, previousSlots] = await Promise.all([ + buildPool(communityId, params.useIdeas), + buildPreviousSlots(communityId, plan.id), + ]); + + const input: GenerationInput = { + params, + exclusions: params.exclusions, + rules: params.rules, + pins: params.slotPins, + slots: slotsToSlotInfo(plan.slots), + pool, + previousSlots, + fillEmptyOnly: body.fillEmptyOnly, + }; + + // 4. Generer + const result = generate(input); + + // 5. Appliquer les assignments en DB + if (result.assignments.length > 0) { + await prisma.$transaction( + result.assignments.map((a) => + prisma.mealSlot.update({ + where: { id: a.slotId }, + data: { + type: a.type, + recipeId: a.recipeId, + freeText: a.freeText ?? null, + comment: a.comment ?? null, + ...(a.type !== "EMPTY" ? { disabled: false } : {}), + }, + }) + ) + ); + } + + // 6. Recharger le plan complet + const updatedPlan = await prisma.mealPlan.findUnique({ + where: { id: plan.id }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }); + + res.json({ + plan: { ...updatedPlan!, slots: updatedPlan!.slots.map(formatSlot) }, + report: result.report, + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-plan/slots/:slotId/replace + * Re-generer un seul slot (MODERATOR) + */ +export const replaceSlot = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, slotId } = req.params; + const body = req.body as ReplaceSlotInput; + + // 1. Trouver le slot + const slot = await prisma.mealSlot.findUnique({ + where: { id: slotId }, + include: { plan: true }, + }); + + if (!slot || slot.plan.communityId !== communityId) { + throw createHttpError(404, MEAL_003); + } + + if (slot.plan.status !== "ACTIVE") { + throw createHttpError(400, MEAL_GEN_013); + } + + if (slot.locked) { + throw createHttpError(400, MEAL_GEN_008); + } + + // 2. Charger les params + const params = await loadGenerationParams(body.paramsId, communityId); + + // 3. Verifier que le slot n'est pas exclu dans ce jeu de params + const slotDayOfWeek = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"][ + new Date(slot.date).getUTCDay() + ]; + const isExcluded = params.exclusions.some( + (e) => e.day === slotDayOfWeek && e.mealTime === slot.mealTime + ); + if (isExcluded) { + throw createHttpError(400, MEAL_GEN_007); + } + + // 4. Charger le plan complet pour contexte (cooldowns, frequences) + const plan = await prisma.mealPlan.findUnique({ + where: { id: slot.planId }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: { + recipe: { select: { tags: { select: { tagId: true } } } }, + }, + }, + }, + }); + + // 5. Construire pool en excluant la recette actuelle + const [poolFull, previousSlots] = await Promise.all([ + buildPool(communityId, params.useIdeas), + buildPreviousSlots(communityId, plan!.id), + ]); + + const pool = slot.recipeId ? poolFull.filter((e) => e.recipeId !== slot.recipeId) : poolFull; + + // 6. Marquer tous les slots sauf le cible comme locked pour que generate() ne les touche pas + const slotInfos: SlotInfo[] = plan!.slots.map((s) => ({ + id: s.id, + date: s.date, + mealTime: s.mealTime, + type: s.type, + disabled: s.disabled, + locked: s.id !== slotId, + recipeId: s.recipeId, + })); + + const input: GenerationInput = { + params, + exclusions: params.exclusions, + rules: params.rules, + pins: params.slotPins, + slots: slotInfos, + pool, + previousSlots, + fillEmptyOnly: false, + }; + + // 7. Generer + const result = generate(input); + + // 8. Trouver l'assignment pour notre slot + const assignment = result.assignments.find((a) => a.slotId === slotId); + + if (assignment) { + await prisma.mealSlot.update({ + where: { id: slotId }, + data: { + type: assignment.type, + recipeId: assignment.recipeId, + freeText: assignment.freeText ?? null, + comment: assignment.comment ?? null, + ...(assignment.type !== "EMPTY" ? { disabled: false } : {}), + }, + }); + } + + // 9. Retourner le slot mis a jour + const updatedSlot = await prisma.mealSlot.findUnique({ + where: { id: slotId }, + include: slotInclude, + }); + + res.json({ + slot: formatSlot(updatedSlot!), + report: result.report, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/mealGenerationParams.ts b/backend/src/controllers/mealGenerationParams.ts new file mode 100644 index 0000000..956b89a --- /dev/null +++ b/backend/src/controllers/mealGenerationParams.ts @@ -0,0 +1,545 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { Prisma } from "@prisma/client"; +import prisma from "../util/db"; +import { + MEAL_GEN_001, + MEAL_GEN_003, + MEAL_GEN_004, + MEAL_GEN_006, + MEAL_GEN_009, + MEAL_GEN_010, + MEAL_GEN_011, + MEAL_GEN_012, +} from "../constants/errorCodes"; +import { + CreateMealGenerationParamsInput, + UpdateMealGenerationParamsInput, + SetExclusionsInput, + CreateRuleInput, + UpdateRuleInput, + SetPinsInput, +} from "../schemas/mealGeneration.schema"; +import { formatDeletedRelation } from "../util/responseFormatters"; + +const ruleSelect = { + id: true, + tagId: true, + recipeId: true, + weight: true, + mealTimeConstraint: true, + frequencyMin: true, + frequencyMax: true, + frequencyPer: true, + tagCooldownDays: true, + tag: { select: { id: true, name: true } }, + recipe: { select: { id: true, title: true, deletedAt: true } }, +} as const; + +type RuleResult = Prisma.MealGenerationRuleGetPayload<{ select: typeof ruleSelect }>; + +// Include pour les requetes detail +const paramsDetailInclude = { + exclusions: { + select: { id: true, day: true, mealTime: true }, + orderBy: [{ day: "asc" as const }, { mealTime: "asc" as const }], + }, + rules: { select: ruleSelect }, + slotPins: { + select: { + id: true, + day: true, + mealTime: true, + tagId: true, + tag: { select: { id: true, name: true } }, + }, + orderBy: [{ day: "asc" as const }, { mealTime: "asc" as const }], + }, +}; + +type ParamsDetail = Prisma.MealGenerationParamsGetPayload<{ include: typeof paramsDetailInclude }>; + +function formatRule(rule: RuleResult) { + return { + ...rule, + recipe: formatDeletedRelation(rule.recipe, ["id", "title"]), + }; +} + +function formatParamsDetail(params: ParamsDetail) { + return { + ...params, + rules: params.rules.map(formatRule), + }; +} + +// Helper: find params and validate ownership +async function findParams(paramsId: string, communityId: string) { + const params = await prisma.mealGenerationParams.findUnique({ + where: { id: paramsId }, + }); + if (!params || params.communityId !== communityId || params.deletedAt !== null) { + throw createHttpError(404, MEAL_GEN_001); + } + return params; +} + +// Helper: validate rule business logic +function validateRuleLogic(data: { + tagId?: string | null; + recipeId?: string | null; + weight?: number; + frequencyMin?: number | null; + frequencyMax?: number | null; + tagCooldownDays?: number | null; +}) { + // tagId XOR recipeId + const hasTag = !!data.tagId; + const hasRecipe = !!data.recipeId; + if ((!hasTag && !hasRecipe) || (hasTag && hasRecipe)) { + throw createHttpError(400, MEAL_GEN_003); + } + + // weight 0.0-2.0 + if (data.weight !== undefined && (data.weight < 0 || data.weight > 2)) { + throw createHttpError(400, MEAL_GEN_004); + } + + // frequency constraints only for tag rules + if (hasRecipe && (data.frequencyMin != null || data.frequencyMax != null)) { + throw createHttpError(400, MEAL_GEN_009); + } + + // tagCooldownDays only for tag rules + if (hasRecipe && data.tagCooldownDays != null) { + throw createHttpError(400, MEAL_GEN_011); + } + + // frequencyMin <= frequencyMax + if ( + data.frequencyMin != null && + data.frequencyMax != null && + data.frequencyMin > data.frequencyMax + ) { + throw createHttpError(400, MEAL_GEN_010); + } +} + +/** + * GET /api/communities/:communityId/meal-generation-params + * Liste des jeux de params (memberOf) + */ +export const listParams = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + + const paramsList = await prisma.mealGenerationParams.findMany({ + where: { communityId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + description: true, + cooldownDays: true, + useIdeas: true, + isDefault: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.json({ data: paramsList }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-generation-params + * Creer un jeu (MODERATOR) + */ +export const createParams = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const body = req.body as CreateMealGenerationParamsInput; + + // Si isDefault, desactiver l'ancien default + if (body.isDefault) { + await prisma.mealGenerationParams.updateMany({ + where: { communityId, isDefault: true, deletedAt: null }, + data: { isDefault: false }, + }); + } + + const params = await prisma.mealGenerationParams.create({ + data: { + communityId, + name: body.name, + description: body.description, + cooldownDays: body.cooldownDays, + useIdeas: body.useIdeas, + isDefault: body.isDefault, + }, + include: paramsDetailInclude, + }); + + res.status(201).json(formatParamsDetail(params)); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/communities/:communityId/meal-generation-params/:paramsId + * Detail avec exclusions + regles + pins (memberOf) + */ +export const getParamsDetail = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + await findParams(paramsId, communityId); + + const params = await prisma.mealGenerationParams.findUnique({ + where: { id: paramsId }, + include: paramsDetailInclude, + }); + + res.json(formatParamsDetail(params!)); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/meal-generation-params/:paramsId + * Modifier (MODERATOR) + */ +export const updateParams = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + const body = req.body as UpdateMealGenerationParamsInput; + + const params = await findParams(paramsId, communityId); + + // Si on set isDefault a true, desactiver l'ancien default + if (body.isDefault === true && !params.isDefault) { + await prisma.mealGenerationParams.updateMany({ + where: { communityId, isDefault: true, deletedAt: null, id: { not: paramsId } }, + data: { isDefault: false }, + }); + } + + const updated = await prisma.mealGenerationParams.update({ + where: { id: paramsId }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.description !== undefined && { description: body.description }), + ...(body.cooldownDays !== undefined && { cooldownDays: body.cooldownDays }), + ...(body.useIdeas !== undefined && { useIdeas: body.useIdeas }), + ...(body.isDefault !== undefined && { isDefault: body.isDefault }), + }, + include: paramsDetailInclude, + }); + + res.json(formatParamsDetail(updated)); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/meal-generation-params/:paramsId + * Soft delete (MODERATOR) + */ +export const deleteParams = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + + await findParams(paramsId, communityId); + + await prisma.mealGenerationParams.update({ + where: { id: paramsId }, + data: { deletedAt: new Date() }, + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +// ============================================= +// Exclusions +// ============================================= + +/** + * PUT /api/communities/:communityId/meal-generation-params/:paramsId/exclusions + * Set complet des exclusions (MODERATOR) + */ +export const setExclusions = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + const body = req.body as SetExclusionsInput; + + await findParams(paramsId, communityId); + + // Transaction : supprimer toutes les exclusions puis recreer + await prisma.$transaction([ + prisma.mealSlotExclusion.deleteMany({ where: { paramsId } }), + ...body.exclusions.map((exc) => + prisma.mealSlotExclusion.create({ + data: { paramsId, day: exc.day, mealTime: exc.mealTime }, + }) + ), + ]); + + // Retourner les exclusions a jour + const exclusions = await prisma.mealSlotExclusion.findMany({ + where: { paramsId }, + select: { id: true, day: true, mealTime: true }, + orderBy: [{ day: "asc" }, { mealTime: "asc" }], + }); + + res.json({ data: exclusions }); + } catch (error) { + next(error); + } +}; + +// ============================================= +// Rules +// ============================================= + +/** + * GET /api/communities/:communityId/meal-generation-params/:paramsId/rules + * Liste des regles (memberOf) + */ +export const listRules = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + await findParams(paramsId, communityId); + + const rules = await prisma.mealGenerationRule.findMany({ + where: { paramsId }, + select: ruleSelect, + }); + + res.json({ data: rules.map(formatRule) }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-generation-params/:paramsId/rules + * Ajouter une regle (MODERATOR) + */ +export const createRule = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + const body = req.body as CreateRuleInput; + + await findParams(paramsId, communityId); + validateRuleLogic(body); + + // Validate tag/recipe exists in community + if (body.tagId) { + const tag = await prisma.tag.findUnique({ where: { id: body.tagId } }); + if (!tag || (tag.communityId !== null && tag.communityId !== communityId)) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + } + if (body.recipeId) { + const recipe = await prisma.recipe.findFirst({ + where: { id: body.recipeId, communityId, deletedAt: null }, + }); + if (!recipe) { + throw createHttpError(404, "MEAL_004: Recipe not found in this community"); + } + } + + const rule = await prisma.mealGenerationRule.create({ + data: { + paramsId, + tagId: body.tagId || null, + recipeId: body.recipeId || null, + weight: body.weight, + mealTimeConstraint: body.mealTimeConstraint || null, + frequencyMin: body.frequencyMin ?? null, + frequencyMax: body.frequencyMax ?? null, + frequencyPer: body.frequencyPer || null, + tagCooldownDays: body.tagCooldownDays ?? null, + }, + select: ruleSelect, + }); + + res.status(201).json(formatRule(rule)); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/meal-generation-params/:paramsId/rules/:ruleId + * Modifier une regle (MODERATOR) + */ +export const updateRule = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId, ruleId } = req.params; + const body = req.body as UpdateRuleInput; + + await findParams(paramsId, communityId); + + const rule = await prisma.mealGenerationRule.findUnique({ where: { id: ruleId } }); + if (!rule || rule.paramsId !== paramsId) { + throw createHttpError(404, MEAL_GEN_006); + } + + // Validate business logic with merged data + const merged = { + tagId: rule.tagId, + recipeId: rule.recipeId, + weight: body.weight !== undefined ? body.weight : rule.weight, + frequencyMin: body.frequencyMin !== undefined ? body.frequencyMin : rule.frequencyMin, + frequencyMax: body.frequencyMax !== undefined ? body.frequencyMax : rule.frequencyMax, + tagCooldownDays: + body.tagCooldownDays !== undefined ? body.tagCooldownDays : rule.tagCooldownDays, + }; + + // Check frequency constraints on recipe rules + if (merged.recipeId && (merged.frequencyMin != null || merged.frequencyMax != null)) { + throw createHttpError(400, MEAL_GEN_009); + } + if (merged.recipeId && merged.tagCooldownDays != null) { + throw createHttpError(400, MEAL_GEN_011); + } + if ( + merged.frequencyMin != null && + merged.frequencyMax != null && + merged.frequencyMin > merged.frequencyMax + ) { + throw createHttpError(400, MEAL_GEN_010); + } + if (body.weight !== undefined && (body.weight < 0 || body.weight > 2)) { + throw createHttpError(400, MEAL_GEN_004); + } + + const updated = await prisma.mealGenerationRule.update({ + where: { id: ruleId }, + data: { + ...(body.weight !== undefined && { weight: body.weight }), + ...(body.mealTimeConstraint !== undefined && { + mealTimeConstraint: body.mealTimeConstraint, + }), + ...(body.frequencyMin !== undefined && { frequencyMin: body.frequencyMin }), + ...(body.frequencyMax !== undefined && { frequencyMax: body.frequencyMax }), + ...(body.frequencyPer !== undefined && { frequencyPer: body.frequencyPer }), + ...(body.tagCooldownDays !== undefined && { tagCooldownDays: body.tagCooldownDays }), + }, + select: ruleSelect, + }); + + res.json(formatRule(updated)); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/meal-generation-params/:paramsId/rules/:ruleId + * Supprimer une regle (MODERATOR, hard delete) + */ +export const deleteRule = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId, ruleId } = req.params; + + await findParams(paramsId, communityId); + + const rule = await prisma.mealGenerationRule.findUnique({ where: { id: ruleId } }); + if (!rule || rule.paramsId !== paramsId) { + throw createHttpError(404, MEAL_GEN_006); + } + + await prisma.mealGenerationRule.delete({ where: { id: ruleId } }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +// ============================================= +// Pins +// ============================================= + +/** + * PUT /api/communities/:communityId/meal-generation-params/:paramsId/pins + * Set complet des pins (MODERATOR) + */ +export const setPins = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, paramsId } = req.params; + const body = req.body as SetPinsInput; + + await findParams(paramsId, communityId); + + // Validate: pins cannot overlap with exclusions + const exclusions = await prisma.mealSlotExclusion.findMany({ + where: { paramsId }, + select: { day: true, mealTime: true }, + }); + const excludedSet = new Set(exclusions.map((e) => `${e.day}:${e.mealTime}`)); + + for (const pin of body.pins) { + if (excludedSet.has(`${pin.day}:${pin.mealTime}`)) { + throw createHttpError(400, MEAL_GEN_012); + } + } + + // Validate tags exist and belong to community (or are global) + const tagIds = [...new Set(body.pins.map((p) => p.tagId))]; + if (tagIds.length > 0) { + const tags = await prisma.tag.findMany({ + where: { id: { in: tagIds } }, + select: { id: true, communityId: true }, + }); + const foundIds = new Set(tags.map((t) => t.id)); + for (const tagId of tagIds) { + if (!foundIds.has(tagId)) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + } + for (const tag of tags) { + if (tag.communityId !== null && tag.communityId !== communityId) { + throw createHttpError(404, "TAG_001: Tag not found"); + } + } + } + + // Transaction : supprimer tous les pins puis recreer + await prisma.$transaction([ + prisma.mealSlotPin.deleteMany({ where: { paramsId } }), + ...body.pins.map((pin) => + prisma.mealSlotPin.create({ + data: { paramsId, day: pin.day, mealTime: pin.mealTime, tagId: pin.tagId }, + }) + ), + ]); + + // Retourner les pins a jour + const pins = await prisma.mealSlotPin.findMany({ + where: { paramsId }, + select: { + id: true, + day: true, + mealTime: true, + tagId: true, + tag: { select: { id: true, name: true } }, + }, + orderBy: [{ day: "asc" }, { mealTime: "asc" }], + }); + + res.json({ data: pins }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/mealIdeas.ts b/backend/src/controllers/mealIdeas.ts new file mode 100644 index 0000000..d85edaf --- /dev/null +++ b/backend/src/controllers/mealIdeas.ts @@ -0,0 +1,182 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { Prisma } from "@prisma/client"; +import prisma from "../util/db"; +import { MEAL_006 } from "../constants/errorCodes"; +import { parsePagination, buildPaginationMeta } from "../util/pagination"; +import { CreateMealIdeaInput, UpdateMealIdeaInput } from "../schemas/mealPlan.schema"; +import { formatDeletedRelation } from "../util/responseFormatters"; + +// Include for queries +const ideaInclude = { + recipe: { + select: { id: true, title: true, imageKey: true, deletedAt: true }, + }, + createdBy: { + select: { id: true, username: true }, + }, +}; + +function formatIdea(idea: Prisma.MealIdeaGetPayload<{ include: typeof ideaInclude }>) { + return { + ...idea, + recipe: formatDeletedRelation(idea.recipe, ["id", "title", "imageKey"]), + createdBy: idea.createdBy ? { id: idea.createdBy.id, username: idea.createdBy.username } : null, + }; +} + +/** + * GET /api/communities/:communityId/meal-ideas + * Liste paginee, searchable (memberOf) + */ +export const listIdeas = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + const search = req.query.search as string | undefined; + + const where: Prisma.MealIdeaWhereInput = { + communityId, + deletedAt: null, + }; + + if (search) { + where.name = { contains: search, mode: "insensitive" }; + } + + const [ideas, total] = await Promise.all([ + prisma.mealIdea.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + include: ideaInclude, + }), + prisma.mealIdea.count({ where }), + ]); + + res.json({ + data: ideas.map(formatIdea), + pagination: buildPaginationMeta(total, limit, offset, ideas.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-ideas + * Creer une idee (memberOf) + */ +export const createIdea = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const userId = req.session.userId!; + const body = req.body as CreateMealIdeaInput; + + // Validate recipeId if provided - must be in community + if (body.recipeId) { + const recipe = await prisma.recipe.findFirst({ + where: { id: body.recipeId, communityId, deletedAt: null }, + }); + if (!recipe) { + throw createHttpError(404, "MEAL_004: Recipe not found in this community"); + } + } + + const idea = await prisma.mealIdea.create({ + data: { + communityId, + name: body.name, + comment: body.comment, + recipeId: body.recipeId, + createdById: userId, + }, + include: ideaInclude, + }); + + res.status(201).json(formatIdea(idea)); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/meal-ideas/:ideaId + * Modifier (createur ou MODERATOR) + */ +export const updateIdea = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, ideaId } = req.params; + const userId = req.session.userId!; + const body = req.body as UpdateMealIdeaInput; + + const idea = await prisma.mealIdea.findUnique({ where: { id: ideaId } }); + + if (!idea || idea.communityId !== communityId || idea.deletedAt !== null) { + throw createHttpError(404, MEAL_006); + } + + // Permission: createur ou MODERATOR + const userCommunity = req.userCommunity!; + if (idea.createdById !== userId && userCommunity.role !== "MODERATOR") { + throw createHttpError(403, "COMMUNITY_002: Permission insufficient"); + } + + // Validate recipeId if provided - must be in community + if (body.recipeId) { + const recipe = await prisma.recipe.findFirst({ + where: { id: body.recipeId, communityId, deletedAt: null }, + }); + if (!recipe) { + throw createHttpError(404, "MEAL_004: Recipe not found in this community"); + } + } + + const updated = await prisma.mealIdea.update({ + where: { id: ideaId }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.comment !== undefined && { comment: body.comment }), + ...(body.recipeId !== undefined && { recipeId: body.recipeId }), + }, + include: ideaInclude, + }); + + res.json(formatIdea(updated)); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/meal-ideas/:ideaId + * Soft delete (createur ou MODERATOR) + */ +export const deleteIdea = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, ideaId } = req.params; + const userId = req.session.userId!; + + const idea = await prisma.mealIdea.findUnique({ where: { id: ideaId } }); + + if (!idea || idea.communityId !== communityId || idea.deletedAt !== null) { + throw createHttpError(404, MEAL_006); + } + + // Permission: createur ou MODERATOR + const userCommunity = req.userCommunity!; + if (idea.createdById !== userId && userCommunity.role !== "MODERATOR") { + throw createHttpError(403, "COMMUNITY_002: Permission insufficient"); + } + + await prisma.mealIdea.update({ + where: { id: ideaId }, + data: { deletedAt: new Date() }, + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/mealPlan.ts b/backend/src/controllers/mealPlan.ts new file mode 100644 index 0000000..feb1993 --- /dev/null +++ b/backend/src/controllers/mealPlan.ts @@ -0,0 +1,577 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { Prisma } from "@prisma/client"; +import prisma from "../util/db"; +import { + MEAL_001, + MEAL_003, + MEAL_004, + MEAL_007, + MEAL_008, + MEAL_009, + MEAL_010, + MEAL_011, + MEAL_012, + MEAL_013, +} from "../constants/errorCodes"; +import { parsePagination, buildPaginationMeta } from "../util/pagination"; +import { + CreateMealPlanInput, + UpdateMealPlanInput, + UpdateSlotInput, + SwapSlotsInput, +} from "../schemas/mealPlan.schema"; +import { formatDeletedRelation } from "../util/responseFormatters"; + +// Slot include for queries +const slotInclude = { + recipe: { + select: { id: true, title: true, imageKey: true, deletedAt: true }, + }, + updatedBy: { + select: { id: true, username: true }, + }, +}; + +function formatSlot(slot: Prisma.MealSlotGetPayload<{ include: typeof slotInclude }>) { + return { + ...slot, + recipe: formatDeletedRelation(slot.recipe, ["id", "title", "imageKey"]), + }; +} + +/** + * GET /api/communities/:communityId/meal-plan + * Retourne le plan ACTIVE avec ses slots, ou { plan: null } si aucun plan + */ +export const getActivePlan = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + + const [plan, defaultParamsCount] = await Promise.all([ + prisma.mealPlan.findFirst({ + where: { communityId, status: "ACTIVE" }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }), + prisma.mealGenerationParams.count({ + where: { communityId, isDefault: true, deletedAt: null }, + }), + ]); + + const hasDefaultGenerationParams = defaultParamsCount > 0; + + if (!plan) { + return res.json({ plan: null, hasDefaultGenerationParams }); + } + + res.json({ + plan: { ...plan, slots: plan.slots.map(formatSlot) }, + hasDefaultGenerationParams, + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-plan + * Creer un plan + slots (MODERATOR) + */ +export const createPlan = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const body = req.body as CreateMealPlanInput; + + const startDate = new Date(body.startDate + "T00:00:00.000Z"); + const endDate = new Date(body.endDate + "T00:00:00.000Z"); + + // Validation: startDate <= endDate + if (startDate > endDate) { + throw createHttpError(400, MEAL_009); + } + + // Validation: max 31 jours + const diffDays = + Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; + if (diffDays > 31) { + throw createHttpError(400, MEAL_008); + } + + // Generer toutes les dates du planning + const dates: Date[] = []; + for (let d = 0; d < diffDays; d++) { + const date = new Date(startDate); + date.setUTCDate(date.getUTCDate() + d); + dates.push(date); + } + + // Validation: pas de chevauchement de slots (date, mealTime) avec un autre plan + const existingSlots = await prisma.mealSlot.findMany({ + where: { + plan: { communityId }, + date: { in: dates }, + }, + select: { date: true, mealTime: true }, + }); + + // Exclure les slots du plan ACTIVE actuel (il sera archive) + const activePlan = await prisma.mealPlan.findFirst({ + where: { communityId, status: "ACTIVE" }, + select: { id: true }, + }); + + const conflictSlots = activePlan + ? await prisma.mealSlot.findMany({ + where: { + plan: { communityId }, + planId: { not: activePlan.id }, + date: { in: dates }, + }, + select: { date: true, mealTime: true }, + }) + : existingSlots; + + if (conflictSlots.length > 0) { + throw createHttpError(409, MEAL_010); + } + + // Recuperer le pattern disabled du plan precedent (si copyDisabledFromPrevious) + let previousDisabledPattern: Array<{ dayOfWeek: number; mealTime: string }> = []; + if (body.copyDisabledFromPrevious) { + const previousPlan = activePlan + ? await prisma.mealPlan.findUnique({ + where: { id: activePlan.id }, + include: { + slots: { + where: { disabled: true }, + select: { date: true, mealTime: true }, + }, + }, + }) + : await prisma.mealPlan.findFirst({ + where: { communityId, status: "ARCHIVED" }, + orderBy: { startDate: "desc" }, + include: { + slots: { + where: { disabled: true }, + select: { date: true, mealTime: true }, + }, + }, + }); + if (previousPlan) { + previousDisabledPattern = previousPlan.slots.map((s) => ({ + dayOfWeek: new Date(s.date).getUTCDay(), + mealTime: s.mealTime, + })); + } + } + + // Build disabled set + const disabledSet = new Set(); + + if (body.disabledSlots) { + for (const ds of body.disabledSlots) { + disabledSet.add(`${ds.date}_${ds.mealTime}`); + } + } + + if (previousDisabledPattern.length > 0) { + for (const date of dates) { + const dayOfWeek = date.getUTCDay(); + for (const pattern of previousDisabledPattern) { + if (pattern.dayOfWeek === dayOfWeek) { + const dateStr = date.toISOString().split("T")[0]; + disabledSet.add(`${dateStr}_${pattern.mealTime}`); + } + } + } + } + + // Transaction: archive existing + create new plan + slots + const plan = await prisma.$transaction(async (tx) => { + if (activePlan) { + await tx.mealPlan.update({ + where: { id: activePlan.id }, + data: { status: "ARCHIVED" }, + }); + } + + const newPlan = await tx.mealPlan.create({ + data: { + communityId, + startDate, + endDate, + defaultServings: body.defaultServings, + status: "ACTIVE", + }, + }); + + const mealTimes = ["LUNCH", "DINNER"] as const; + const slotsData = []; + for (const date of dates) { + const dateStr = date.toISOString().split("T")[0]; + for (const mt of mealTimes) { + slotsData.push({ + planId: newPlan.id, + date, + mealTime: mt, + servings: body.defaultServings, + disabled: disabledSet.has(`${dateStr}_${mt}`), + }); + } + } + + await tx.mealSlot.createMany({ data: slotsData }); + + return tx.mealPlan.findUnique({ + where: { id: newPlan.id }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }); + }); + + res.status(201).json({ + plan: { ...plan!, slots: plan!.slots.map(formatSlot) }, + }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/meal-plan + * Supprimer le plan ACTIVE + cascade slots (MODERATOR) + */ +export const deletePlan = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + + const plan = await prisma.mealPlan.findFirst({ + where: { communityId, status: "ACTIVE" }, + }); + + if (!plan) { + throw createHttpError(404, MEAL_001); + } + + await prisma.mealPlan.delete({ where: { id: plan.id } }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/meal-plan + * Update defaultServings / editableByMembers (MODERATOR) + */ +export const updatePlan = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const body = req.body as UpdateMealPlanInput; + + const plan = await prisma.mealPlan.findFirst({ + where: { communityId, status: "ACTIVE" }, + }); + + if (!plan) { + throw createHttpError(404, MEAL_001); + } + + const updated = await prisma.mealPlan.update({ + where: { id: plan.id }, + data: { + ...(body.defaultServings !== undefined && { defaultServings: body.defaultServings }), + ...(body.editableByMembers !== undefined && { editableByMembers: body.editableByMembers }), + }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }); + + res.json({ plan: { ...updated, slots: updated.slots.map(formatSlot) } }); + } catch (error) { + next(error); + } +}; + +/** + * PATCH /api/communities/:communityId/meal-plan/slots/:slotId + * Update un slot (permission dynamique: MODERATOR ou membre si editableByMembers) + */ +export const updateSlot = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, slotId } = req.params; + const userId = req.session.userId!; + const body = req.body as UpdateSlotInput; + + const slot = await prisma.mealSlot.findUnique({ + where: { id: slotId }, + include: { plan: true }, + }); + + if (!slot || slot.plan.communityId !== communityId) { + throw createHttpError(404, MEAL_003); + } + + if (slot.plan.status !== "ACTIVE") { + throw createHttpError(400, MEAL_011); + } + + const userCommunity = req.userCommunity!; + if (userCommunity.role !== "MODERATOR" && !slot.plan.editableByMembers) { + throw createHttpError(403, "COMMUNITY_002: Permission insufficient"); + } + + const updateData: Prisma.MealSlotUncheckedUpdateInput = { updatedById: userId }; + + if (body.type !== undefined) { + updateData.type = body.type; + + if (body.type === "RECIPE") { + if (!body.recipeId) { + throw createHttpError(400, "VALIDATION_001: recipeId required for type RECIPE"); + } + const recipe = await prisma.recipe.findFirst({ + where: { id: body.recipeId, communityId, deletedAt: null }, + }); + if (!recipe) { + throw createHttpError(404, MEAL_004); + } + updateData.recipeId = body.recipeId; + updateData.freeText = null; + if (slot.disabled) { + updateData.disabled = false; + } + } else if (body.type === "FREE_TEXT") { + if (!body.freeText) { + throw createHttpError(400, "VALIDATION_001: freeText required for type FREE_TEXT"); + } + updateData.freeText = body.freeText; + updateData.recipeId = null; + if (slot.disabled) { + updateData.disabled = false; + } + } else if (body.type === "EMPTY") { + updateData.recipeId = null; + updateData.freeText = null; + updateData.comment = null; + } + } + + if (body.comment !== undefined && body.type !== "EMPTY") { + updateData.comment = body.comment; + } + + if (body.freeText !== undefined && body.type === undefined) { + // freeText sans changer le type — on ne met pas a jour si type n'est pas fourni + } + + if (body.servings !== undefined) { + updateData.servings = body.servings; + } + + if (body.disabled !== undefined) { + updateData.disabled = body.disabled; + } + + if (body.locked !== undefined) { + updateData.locked = body.locked; + } + + const updated = await prisma.mealSlot.update({ + where: { id: slotId }, + data: updateData, + include: slotInclude, + }); + + res.json(formatSlot(updated)); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/communities/:communityId/meal-plan/slots/swap + * Swap contenu de 2 slots (permission dynamique) + */ +export const swapSlots = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const userId = req.session.userId!; + const { slotIdA, slotIdB } = req.body as SwapSlotsInput; + + if (slotIdA === slotIdB) { + throw createHttpError(400, MEAL_007); + } + + const [slotA, slotB] = await Promise.all([ + prisma.mealSlot.findUnique({ where: { id: slotIdA }, include: { plan: true } }), + prisma.mealSlot.findUnique({ where: { id: slotIdB }, include: { plan: true } }), + ]); + + if (!slotA || slotA.plan.communityId !== communityId) { + throw createHttpError(404, MEAL_003); + } + if (!slotB || slotB.plan.communityId !== communityId) { + throw createHttpError(404, MEAL_003); + } + if (slotA.planId !== slotB.planId) { + throw createHttpError(400, MEAL_003); + } + if (slotA.plan.status !== "ACTIVE") { + throw createHttpError(400, MEAL_011); + } + + const userCommunity = req.userCommunity!; + if (userCommunity.role !== "MODERATOR" && !slotA.plan.editableByMembers) { + throw createHttpError(403, "COMMUNITY_002: Permission insufficient"); + } + + if (slotA.disabled || slotB.disabled) { + throw createHttpError(400, MEAL_013); + } + + await prisma.$transaction([ + prisma.mealSlot.update({ + where: { id: slotIdA }, + data: { + type: slotB.type, + recipeId: slotB.recipeId, + freeText: slotB.freeText, + comment: slotB.comment, + servings: slotB.servings, + updatedById: userId, + }, + }), + prisma.mealSlot.update({ + where: { id: slotIdB }, + data: { + type: slotA.type, + recipeId: slotA.recipeId, + freeText: slotA.freeText, + comment: slotA.comment, + servings: slotA.servings, + updatedById: userId, + }, + }), + ]); + + const [updatedA, updatedB] = await Promise.all([ + prisma.mealSlot.findUnique({ where: { id: slotIdA }, include: slotInclude }), + prisma.mealSlot.findUnique({ where: { id: slotIdB }, include: slotInclude }), + ]); + + res.json({ slotA: formatSlot(updatedA!), slotB: formatSlot(updatedB!) }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/communities/:communityId/meal-plan/archives + * Liste paginee des plans ARCHIVED (memberOf) + */ +export const getArchives = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId } = req.params; + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + + const [archives, total] = await Promise.all([ + prisma.mealPlan.findMany({ + where: { communityId, status: "ARCHIVED" }, + orderBy: { startDate: "desc" }, + skip: offset, + take: limit, + include: { + _count: { select: { slots: true } }, + slots: { + where: { type: { not: "EMPTY" } }, + select: { id: true }, + }, + }, + }), + prisma.mealPlan.count({ where: { communityId, status: "ARCHIVED" } }), + ]); + + const data = archives.map((a) => ({ + id: a.id, + startDate: a.startDate, + endDate: a.endDate, + defaultServings: a.defaultServings, + createdAt: a.createdAt, + totalSlots: a._count.slots, + filledSlots: a.slots.length, + })); + + res.json({ + data, + pagination: buildPaginationMeta(total, limit, offset, data.length), + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/communities/:communityId/meal-plan/archives/:planId + * Detail d'un plan archive + slots (memberOf) + */ +export const getArchiveDetail = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, planId } = req.params; + + const plan = await prisma.mealPlan.findUnique({ + where: { id: planId }, + include: { + slots: { + orderBy: [{ date: "asc" }, { mealTime: "asc" }], + include: slotInclude, + }, + }, + }); + + if (!plan || plan.communityId !== communityId || plan.status !== "ARCHIVED") { + throw createHttpError(404, MEAL_012); + } + + res.json({ plan: { ...plan, slots: plan.slots.map(formatSlot) } }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/communities/:communityId/meal-plan/archives/:planId + * Supprimer une archive (MODERATOR, hard delete) + */ +export const deleteArchive = async (req: Request, res: Response, next: NextFunction) => { + try { + const { communityId, planId } = req.params; + + const plan = await prisma.mealPlan.findUnique({ where: { id: planId } }); + + if (!plan || plan.communityId !== communityId || plan.status !== "ARCHIVED") { + throw createHttpError(404, MEAL_012); + } + + await prisma.mealPlan.delete({ where: { id: planId } }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/middleware/requireFeature.ts b/backend/src/middleware/requireFeature.ts new file mode 100644 index 0000000..f9f54d3 --- /dev/null +++ b/backend/src/middleware/requireFeature.ts @@ -0,0 +1,38 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import prisma from "../util/db"; +import { MEAL_005 } from "../constants/errorCodes"; + +/** + * Middleware generique pour verifier qu'une feature est activee pour la communaute. + * Doit etre utilise apres memberOf (req.params.communityId doit exister). + */ +export const requireFeature = (featureCode: string) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const communityId = req.params.communityId; + + if (!communityId) { + return next(createHttpError(400, "Community ID required")); + } + + try { + const communityFeature = await prisma.communityFeature.findFirst({ + where: { + communityId, + revokedAt: null, + feature: { + code: featureCode, + }, + }, + }); + + if (!communityFeature) { + return next(createHttpError(403, MEAL_005)); + } + + next(); + } catch (error) { + next(error); + } + }; +}; diff --git a/backend/src/routes/changelog.ts b/backend/src/routes/changelog.ts new file mode 100644 index 0000000..224f1f5 --- /dev/null +++ b/backend/src/routes/changelog.ts @@ -0,0 +1,13 @@ +import express from "express"; +import * as changelogController from "../controllers/changelog"; +import { validateUUID } from "../middleware/validateUUID"; + +const router = express.Router(); + +// GET /api/changelog - Liste paginee +router.get("/", changelogController.getAll); + +// GET /api/changelog/:id - Detail +router.get("/:id", validateUUID, changelogController.getById); + +export default router; diff --git a/backend/src/routes/communities.ts b/backend/src/routes/communities.ts index aa1e678..d677967 100644 --- a/backend/src/routes/communities.ts +++ b/backend/src/routes/communities.ts @@ -9,6 +9,9 @@ import * as ActivityController from "../controllers/activity"; import { memberOf, requireCommunityRole } from "../middleware/community"; import { validateUUID } from "../middleware/validateUUID"; import { validateBody } from "../middleware/validateBody"; +import mealPlanRoutes from "./mealPlan"; +import mealIdeasRoutes from "./mealIdeas"; +import mealGenerationParamsRoutes from "./mealGenerationParams"; import { createRecipeSchema } from "../schemas/recipe.schema"; import { createCommunitySchema, updateCommunitySchema } from "../schemas/community.schema"; import { createInviteSchema } from "../schemas/invite.schema"; @@ -202,6 +205,24 @@ router.post( CommunityTagsController.rejectCommunityTag ); +// ===================================== +// Meal Plan routes (feature-gated) +// ===================================== + +router.use("/:communityId/meal-plan", mealPlanRoutes); + +// ===================================== +// Meal Ideas routes (feature-gated) +// ===================================== + +router.use("/:communityId/meal-ideas", mealIdeasRoutes); + +// ===================================== +// Meal Generation Params routes (feature-gated) +// ===================================== + +router.use("/:communityId/meal-generation-params", mealGenerationParamsRoutes); + // ===================================== // Activity feed // ===================================== diff --git a/backend/src/routes/mealGenerationParams.ts b/backend/src/routes/mealGenerationParams.ts new file mode 100644 index 0000000..5d9998e --- /dev/null +++ b/backend/src/routes/mealGenerationParams.ts @@ -0,0 +1,104 @@ +import express from "express"; +import { memberOf, requireCommunityRole } from "../middleware/community"; +import { validateUUID } from "../middleware/validateUUID"; +import { requireFeature } from "../middleware/requireFeature"; +import { validateBody } from "../middleware/validateBody"; +import { + createMealGenerationParamsSchema, + updateMealGenerationParamsSchema, + setExclusionsSchema, + createRuleSchema, + updateRuleSchema, + setPinsSchema, +} from "../schemas/mealGeneration.schema"; +import * as MealGenerationParamsController from "../controllers/mealGenerationParams"; + +const router = express.Router({ mergeParams: true }); + +// Tous les endpoints requierent membership + feature MEAL_PLAN +router.use(validateUUID, memberOf, requireFeature("MEAL_PLAN")); + +// GET /api/communities/:communityId/meal-generation-params — Liste (memberOf) +router.get("/", MealGenerationParamsController.listParams); + +// POST /api/communities/:communityId/meal-generation-params — Creer (MODERATOR) +router.post( + "/", + requireCommunityRole("MODERATOR"), + validateBody(createMealGenerationParamsSchema), + MealGenerationParamsController.createParams +); + +// GET /api/communities/:communityId/meal-generation-params/:paramsId — Detail (memberOf) +router.get("/:paramsId", MealGenerationParamsController.getParamsDetail); + +// PATCH /api/communities/:communityId/meal-generation-params/:paramsId — Modifier (MODERATOR) +router.patch( + "/:paramsId", + requireCommunityRole("MODERATOR"), + validateBody(updateMealGenerationParamsSchema), + MealGenerationParamsController.updateParams +); + +// DELETE /api/communities/:communityId/meal-generation-params/:paramsId — Soft delete (MODERATOR) +router.delete( + "/:paramsId", + requireCommunityRole("MODERATOR"), + MealGenerationParamsController.deleteParams +); + +// ============================================= +// Exclusions +// ============================================= + +// PUT .../meal-generation-params/:paramsId/exclusions — Set complet (MODERATOR) +router.put( + "/:paramsId/exclusions", + requireCommunityRole("MODERATOR"), + validateBody(setExclusionsSchema), + MealGenerationParamsController.setExclusions +); + +// ============================================= +// Rules +// ============================================= + +// GET .../meal-generation-params/:paramsId/rules — Liste (memberOf) +router.get("/:paramsId/rules", MealGenerationParamsController.listRules); + +// POST .../meal-generation-params/:paramsId/rules — Ajouter (MODERATOR) +router.post( + "/:paramsId/rules", + requireCommunityRole("MODERATOR"), + validateBody(createRuleSchema), + MealGenerationParamsController.createRule +); + +// PATCH .../meal-generation-params/:paramsId/rules/:ruleId — Modifier (MODERATOR) +router.patch( + "/:paramsId/rules/:ruleId", + requireCommunityRole("MODERATOR"), + validateBody(updateRuleSchema), + MealGenerationParamsController.updateRule +); + +// DELETE .../meal-generation-params/:paramsId/rules/:ruleId — Supprimer (MODERATOR, hard delete) +router.delete( + "/:paramsId/rules/:ruleId", + requireCommunityRole("MODERATOR"), + MealGenerationParamsController.deleteRule +); + +// ============================================= +// Pins +// ============================================= + +// PUT .../meal-generation-params/:paramsId/pins — Set complet (MODERATOR) +router.put( + "/:paramsId/pins", + requireCommunityRole("MODERATOR"), + validateBody(setPinsSchema), + MealGenerationParamsController.setPins +); + +export default router; diff --git a/backend/src/routes/mealIdeas.ts b/backend/src/routes/mealIdeas.ts new file mode 100644 index 0000000..b4227ce --- /dev/null +++ b/backend/src/routes/mealIdeas.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { memberOf } from "../middleware/community"; +import { validateUUID } from "../middleware/validateUUID"; +import { requireFeature } from "../middleware/requireFeature"; +import { validateBody } from "../middleware/validateBody"; +import { createMealIdeaSchema, updateMealIdeaSchema } from "../schemas/mealPlan.schema"; +import * as MealIdeasController from "../controllers/mealIdeas"; + +const router = express.Router({ mergeParams: true }); + +// Tous les endpoints meal-ideas requierent membership + feature MEAL_PLAN +router.use(validateUUID, memberOf, requireFeature("MEAL_PLAN")); + +// GET /api/communities/:communityId/meal-ideas — Liste paginee (memberOf) +router.get("/", MealIdeasController.listIdeas); + +// POST /api/communities/:communityId/meal-ideas — Creer une idee (memberOf) +router.post("/", validateBody(createMealIdeaSchema), MealIdeasController.createIdea); + +// PATCH /api/communities/:communityId/meal-ideas/:ideaId — Modifier (createur ou MODERATOR) +router.patch("/:ideaId", validateBody(updateMealIdeaSchema), MealIdeasController.updateIdea); + +// DELETE /api/communities/:communityId/meal-ideas/:ideaId — Soft delete (createur ou MODERATOR) +router.delete("/:ideaId", MealIdeasController.deleteIdea); + +export default router; diff --git a/backend/src/routes/mealPlan.ts b/backend/src/routes/mealPlan.ts new file mode 100644 index 0000000..81891f2 --- /dev/null +++ b/backend/src/routes/mealPlan.ts @@ -0,0 +1,78 @@ +import express from "express"; +import { memberOf, requireCommunityRole } from "../middleware/community"; +import { validateUUID } from "../middleware/validateUUID"; +import { requireFeature } from "../middleware/requireFeature"; +import { validateBody } from "../middleware/validateBody"; +import { + createMealPlanSchema, + updateMealPlanSchema, + updateSlotSchema, + swapSlotsSchema, +} from "../schemas/mealPlan.schema"; +import { generateSchema, replaceSlotSchema } from "../schemas/mealGeneration.schema"; +import * as MealPlanController from "../controllers/mealPlan"; +import * as MealGenerationController from "../controllers/mealGeneration"; + +const router = express.Router({ mergeParams: true }); + +// Tous les endpoints meal-plan requierent membership + feature MEAL_PLAN +router.use(validateUUID, memberOf, requireFeature("MEAL_PLAN")); + +// GET /api/communities/:communityId/meal-plan — Plan ACTIVE + slots (memberOf) +router.get("/", MealPlanController.getActivePlan); + +// POST /api/communities/:communityId/meal-plan — Creer plan + slots (MODERATOR) +router.post( + "/", + requireCommunityRole("MODERATOR"), + validateBody(createMealPlanSchema), + MealPlanController.createPlan +); + +// DELETE /api/communities/:communityId/meal-plan — Supprimer plan ACTIVE (MODERATOR) +router.delete("/", requireCommunityRole("MODERATOR"), MealPlanController.deletePlan); + +// PATCH /api/communities/:communityId/meal-plan — Update plan settings (MODERATOR) +router.patch( + "/", + requireCommunityRole("MODERATOR"), + validateBody(updateMealPlanSchema), + MealPlanController.updatePlan +); + +// POST /api/communities/:communityId/meal-plan/generate — Generer le planning (MODERATOR) +router.post( + "/generate", + requireCommunityRole("MODERATOR"), + validateBody(generateSchema), + MealGenerationController.generatePlan +); + +// PATCH /api/communities/:communityId/meal-plan/slots/:slotId — Update slot (permission dynamique) +router.patch("/slots/:slotId", validateBody(updateSlotSchema), MealPlanController.updateSlot); + +// POST /api/communities/:communityId/meal-plan/slots/:slotId/replace — Re-generer 1 slot (MODERATOR) +router.post( + "/slots/:slotId/replace", + requireCommunityRole("MODERATOR"), + validateBody(replaceSlotSchema), + MealGenerationController.replaceSlot +); + +// POST /api/communities/:communityId/meal-plan/slots/swap — Swap 2 slots (permission dynamique) +router.post("/slots/swap", validateBody(swapSlotsSchema), MealPlanController.swapSlots); + +// GET /api/communities/:communityId/meal-plan/archives — Liste paginee (memberOf) +router.get("/archives", MealPlanController.getArchives); + +// GET /api/communities/:communityId/meal-plan/archives/:planId — Detail archive (memberOf) +router.get("/archives/:planId", MealPlanController.getArchiveDetail); + +// DELETE /api/communities/:communityId/meal-plan/archives/:planId — Supprimer archive (MODERATOR) +router.delete( + "/archives/:planId", + requireCommunityRole("MODERATOR"), + MealPlanController.deleteArchive +); + +export default router; diff --git a/backend/src/schemas/mealGeneration.schema.ts b/backend/src/schemas/mealGeneration.schema.ts new file mode 100644 index 0000000..958cc34 --- /dev/null +++ b/backend/src/schemas/mealGeneration.schema.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; + +// =================================== +// Meal Generation Params +// =================================== + +export const createMealGenerationParamsSchema = z.object({ + name: z + .string() + .min(1, "VALIDATION_001: Name is required") + .max(100, "VALIDATION_001: Name must be at most 100 characters"), + description: z + .string() + .max(500, "VALIDATION_001: Description must be at most 500 characters") + .optional() + .nullable(), + cooldownDays: z + .number() + .int() + .min(0, "VALIDATION_001: cooldownDays must be >= 0") + .optional() + .default(3), + useIdeas: z.boolean().optional().default(true), + isDefault: z.boolean().optional().default(false), +}); + +export type CreateMealGenerationParamsInput = z.infer; + +export const updateMealGenerationParamsSchema = z + .object({ + name: z + .string() + .min(1, "VALIDATION_001: Name cannot be empty") + .max(100, "VALIDATION_001: Name must be at most 100 characters") + .optional(), + description: z + .string() + .max(500, "VALIDATION_001: Description must be at most 500 characters") + .optional() + .nullable(), + cooldownDays: z.number().int().min(0, "VALIDATION_001: cooldownDays must be >= 0").optional(), + useIdeas: z.boolean().optional(), + isDefault: z.boolean().optional(), + }) + .refine((data) => Object.values(data).some((v) => v !== undefined), { + message: "VALIDATION_001: At least one field required", + }); + +export type UpdateMealGenerationParamsInput = z.infer; + +// =================================== +// Meal Generation Exclusions +// =================================== + +const dayOfWeekEnum = z.enum(["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]); +const mealTimeEnum = z.enum(["LUNCH", "DINNER"]); + +export const setExclusionsSchema = z.object({ + exclusions: z.array( + z.object({ + day: dayOfWeekEnum, + mealTime: mealTimeEnum, + }) + ), +}); + +export type SetExclusionsInput = z.infer; + +// =================================== +// Meal Generation Rules +// =================================== + +export const createRuleSchema = z.object({ + tagId: z.string().uuid("VALIDATION_001: Invalid tagId").optional().nullable(), + recipeId: z.string().uuid("VALIDATION_001: Invalid recipeId").optional().nullable(), + weight: z.number().min(0).max(2).optional().default(1.0), + mealTimeConstraint: mealTimeEnum.optional().nullable(), + frequencyMin: z.number().int().min(0).optional().nullable(), + frequencyMax: z.number().int().min(0).optional().nullable(), + frequencyPer: z.enum(["PER_WEEK", "PER_PLANNING"]).optional().nullable(), + tagCooldownDays: z.number().int().min(0).optional().nullable(), +}); + +export type CreateRuleInput = z.infer; + +export const updateRuleSchema = z + .object({ + weight: z.number().min(0).max(2).optional(), + mealTimeConstraint: mealTimeEnum.optional().nullable(), + frequencyMin: z.number().int().min(0).optional().nullable(), + frequencyMax: z.number().int().min(0).optional().nullable(), + frequencyPer: z.enum(["PER_WEEK", "PER_PLANNING"]).optional().nullable(), + tagCooldownDays: z.number().int().min(0).optional().nullable(), + }) + .refine((data) => Object.values(data).some((v) => v !== undefined), { + message: "VALIDATION_001: At least one field required", + }); + +export type UpdateRuleInput = z.infer; + +// =================================== +// Meal Generation Pins +// =================================== + +export const setPinsSchema = z.object({ + pins: z.array( + z.object({ + day: dayOfWeekEnum, + mealTime: mealTimeEnum, + tagId: z.string().uuid("VALIDATION_001: Invalid tagId"), + }) + ), +}); + +export type SetPinsInput = z.infer; + +// =================================== +// Meal Generation API (generate / replace) +// =================================== + +export const generateSchema = z.object({ + paramsId: z.string().uuid("VALIDATION_001: Invalid paramsId"), + fillEmptyOnly: z.boolean().optional().default(false), +}); + +export type GenerateInput = z.infer; + +export const replaceSlotSchema = z.object({ + paramsId: z.string().uuid("VALIDATION_001: Invalid paramsId"), +}); + +export type ReplaceSlotInput = z.infer; diff --git a/backend/src/schemas/mealPlan.schema.ts b/backend/src/schemas/mealPlan.schema.ts new file mode 100644 index 0000000..9da275f --- /dev/null +++ b/backend/src/schemas/mealPlan.schema.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +// Regex pour date YYYY-MM-DD +const dateStringSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "VALIDATION_001: Date must be YYYY-MM-DD format") + .refine((d) => !isNaN(new Date(d).getTime()), "VALIDATION_001: Invalid date"); + +export const createMealPlanSchema = z.object({ + startDate: dateStringSchema, + endDate: dateStringSchema, + defaultServings: z.number().int().min(1).max(100).optional().default(4), + disabledSlots: z + .array( + z.object({ + date: dateStringSchema, + mealTime: z.enum(["LUNCH", "DINNER"]), + }) + ) + .optional(), + copyDisabledFromPrevious: z.boolean().optional().default(false), +}); + +export type CreateMealPlanInput = z.infer; + +export const updateMealPlanSchema = z + .object({ + defaultServings: z.number().int().min(1).max(100).optional(), + editableByMembers: z.boolean().optional(), + }) + .refine((data) => data.defaultServings !== undefined || data.editableByMembers !== undefined, { + message: "VALIDATION_001: At least one field required (defaultServings or editableByMembers)", + }); + +export type UpdateMealPlanInput = z.infer; + +export const updateSlotSchema = z + .object({ + type: z.enum(["EMPTY", "RECIPE", "FREE_TEXT"]).optional(), + recipeId: z.string().uuid("VALIDATION_001: Invalid recipeId").optional(), + freeText: z.string().min(1).max(255).optional(), + comment: z.string().max(500).optional().nullable(), + servings: z.number().int().min(1).max(100).optional(), + disabled: z.boolean().optional(), + locked: z.boolean().optional(), + }) + .refine( + (data) => { + // Au moins un champ doit etre present + return Object.values(data).some((v) => v !== undefined); + }, + { message: "VALIDATION_001: At least one field required" } + ); + +export type UpdateSlotInput = z.infer; + +export const swapSlotsSchema = z.object({ + slotIdA: z.string().uuid("VALIDATION_001: Invalid slotIdA"), + slotIdB: z.string().uuid("VALIDATION_001: Invalid slotIdB"), +}); + +export type SwapSlotsInput = z.infer; + +// =================================== +// Meal Ideas +// =================================== + +export const createMealIdeaSchema = z.object({ + name: z + .string() + .min(1, "VALIDATION_001: Name is required") + .max(255, "VALIDATION_001: Name must be at most 255 characters"), + comment: z.string().max(500, "VALIDATION_001: Comment must be at most 500 characters").optional(), + recipeId: z.string().uuid("VALIDATION_001: Invalid recipeId").optional(), +}); + +export type CreateMealIdeaInput = z.infer; + +export const updateMealIdeaSchema = z + .object({ + name: z + .string() + .min(1, "VALIDATION_001: Name cannot be empty") + .max(255, "VALIDATION_001: Name must be at most 255 characters") + .optional(), + comment: z + .string() + .max(500, "VALIDATION_001: Comment must be at most 500 characters") + .optional() + .nullable(), + recipeId: z.string().uuid("VALIDATION_001: Invalid recipeId").optional().nullable(), + }) + .refine((data) => Object.values(data).some((v) => v !== undefined), { + message: "VALIDATION_001: At least one field required", + }); + +export type UpdateMealIdeaInput = z.infer; diff --git a/backend/src/scripts/createAdmin.ts b/backend/src/scripts/createAdmin.ts index ea1d64a..8925d22 100644 --- a/backend/src/scripts/createAdmin.ts +++ b/backend/src/scripts/createAdmin.ts @@ -11,18 +11,37 @@ import { PrismaClient } from "@prisma/client"; import bcrypt from "bcrypt"; import { generateSecret } from "otplib"; -import { read } from "read"; +import * as readline from "readline"; const prisma = new PrismaClient(); +function ask(question: string, silent = false): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + if (silent) { + // Supprime l'echo des caracteres saisis (mode mot de passe) + (rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = (s: string) => { + if (s === question) process.stdout.write(s); + }; + } + rl.question(question, (answer) => { + if (silent) process.stdout.write("\n"); + rl.close(); + resolve(answer.trim()); + }); + }); +} + async function prompt(question: string): Promise { - const result = await read({ prompt: question }); - return result.trim(); + return ask(question); } async function promptHidden(question: string): Promise { - const result = await read({ prompt: question, silent: true }); - return result.trim(); + return ask(question, true); } async function validateEmail(email: string): Promise { diff --git a/backend/src/scripts/insertChangelog.ts b/backend/src/scripts/insertChangelog.ts new file mode 100644 index 0000000..bf2c74a --- /dev/null +++ b/backend/src/scripts/insertChangelog.ts @@ -0,0 +1,131 @@ +/** + * CLI Script: Insert Changelog Entry + * Usage: node dist/scripts/insertChangelog.js '' + * + * Executed inside the backend container via Portainer exec from CI. + * Receives a JSON string with { version, title, content } and inserts + * the changelog entry into the database via Prisma. + */ + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +interface ChangelogItem { + text: string; +} + +interface ChangelogContent { + features: ChangelogItem[]; + improvements: ChangelogItem[]; + fixes: ChangelogItem[]; +} + +interface ChangelogInput { + version: string; + title: string; + content: ChangelogContent; +} + +function validateInput(input: unknown): input is ChangelogInput { + if (!input || typeof input !== "object") return false; + + const obj = input as Record; + + // Version semver + if (typeof obj.version !== "string" || !/^\d+\.\d+\.\d+$/.test(obj.version)) { + console.error("Invalid version format (expected x.y.z)"); + return false; + } + + // Title + if (typeof obj.title !== "string" || obj.title.length === 0 || obj.title.length > 200) { + console.error("Invalid title (1-200 characters)"); + return false; + } + + // Content + if (!obj.content || typeof obj.content !== "object") { + console.error("Missing or invalid content"); + return false; + } + + const content = obj.content as Record; + const categories = ["features", "improvements", "fixes"]; + + for (const cat of categories) { + if (!Array.isArray(content[cat])) { + console.error(`content.${cat} must be an array`); + return false; + } + for (const item of content[cat] as unknown[]) { + if ( + !item || + typeof item !== "object" || + typeof (item as Record).text !== "string" + ) { + console.error(`Each item in content.${cat} must have a text string`); + return false; + } + } + } + + const c = content as unknown as ChangelogContent; + if (c.features.length === 0 && c.improvements.length === 0 && c.fixes.length === 0) { + console.error("Content must have at least one item"); + return false; + } + + return true; +} + +async function main() { + const jsonArg = process.argv[2]; + + if (!jsonArg) { + console.error("Usage: node dist/scripts/insertChangelog.js ''"); + process.exit(1); + } + + let input: unknown; + try { + input = JSON.parse(jsonArg); + } catch { + console.error("Invalid JSON argument"); + process.exit(1); + } + + if (!validateInput(input)) { + process.exit(1); + } + + // Check for duplicate version + const existing = await prisma.changelogEntry.findUnique({ + where: { version: input.version }, + }); + + if (existing) { + console.error(`Version ${input.version} already exists (id: ${existing.id})`); + process.exit(1); + } + + const entry = await prisma.changelogEntry.create({ + data: { + version: input.version, + title: input.title, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content: input.content as any, + }, + }); + + console.log(JSON.stringify({ success: true, id: entry.id, version: entry.version })); +} + +main() + .catch((e) => { + console.error("Insert changelog error:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/services/mealGeneration.ts b/backend/src/services/mealGeneration.ts new file mode 100644 index 0000000..b72be9c --- /dev/null +++ b/backend/src/services/mealGeneration.ts @@ -0,0 +1,715 @@ +/** + * Meal Generation Algorithm + * Logique metier isolee pour la generation automatique du planning de repas + */ + +import { + MealGenerationParams, + MealSlotExclusion, + MealGenerationRule, + MealSlotPin, + MealTime, + FrequencyPer, +} from "@prisma/client"; + +// ============================================= +// Types +// ============================================= + +/** Recette dans le pool de generation */ +export interface PoolEntry { + id: string; // recipeId ou ideaId + type: "RECIPE" | "IDEA"; // source + recipeId: string | null; // null pour idees sans recette + tagIds: string[]; // tags de la recette (vide pour idees sans recette) + // Pour idees sans recette -> FREE_TEXT + freeText?: string; + comment?: string | null; +} + +/** Slot a traiter */ +export interface SlotInfo { + id: string; + date: Date; + mealTime: MealTime; + type: string; + disabled: boolean; + locked: boolean; + recipeId: string | null; +} + +/** Resultat d'un slot rempli par la generation */ +export interface SlotAssignment { + slotId: string; + recipeId: string | null; + type: "RECIPE" | "FREE_TEXT" | "EMPTY"; + freeText?: string | null; + comment?: string | null; + /** Poids final de la recette choisie (pour rattrapage) */ + weight: number; +} + +/** Slot rempli du plan archive precedent (pour cross-planning cooldown) */ +export interface PreviousSlotInfo { + date: Date; + recipeId: string | null; + tagIds: string[]; +} + +export interface GenerationWarning { + type: + | "POOL_EXHAUSTED" + | "FREQUENCY_MIN_NOT_MET" + | "FREQUENCY_MAX_EXCEEDED" + | "CONFLICTING_CONSTRAINTS"; + slotDay?: string; + slotMealTime?: string; + tagId?: string; + tagName?: string; + required?: number; + actual?: number; + reason: string; +} + +export interface GenerationReport { + slotsGenerated: number; + slotsSkipped: { + excluded: number; + locked: number; + disabled: number; + alreadyFilled: number; + }; + slotsEmpty: number; + warnings: GenerationWarning[]; +} + +export interface GenerationResult { + assignments: SlotAssignment[]; + report: GenerationReport; +} + +// ============================================= +// Helpers +// ============================================= + +const DAY_OF_WEEK_MAP: Record = { + 0: "SUN", + 1: "MON", + 2: "TUE", + 3: "WED", + 4: "THU", + 5: "FRI", + 6: "SAT", +}; + +export function dateToDayOfWeek(date: Date): string { + return DAY_OF_WEEK_MAP[new Date(date).getUTCDay()]; +} + +/** Difference en jours entre deux dates (entiers positifs) */ +function daysBetween(a: Date, b: Date): number { + const msPerDay = 86400000; + const dateA = new Date(a); + const dateB = new Date(b); + dateA.setUTCHours(0, 0, 0, 0); + dateB.setUTCHours(0, 0, 0, 0); + return Math.abs(Math.floor((dateB.getTime() - dateA.getTime()) / msPerDay)); +} + +/** Tirage aleatoire pondere */ +export function weightedRandomPick(items: T[], weights: number[]): T | null { + if (items.length === 0) return null; + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + if (totalWeight <= 0) return null; + + let random = Math.random() * totalWeight; + for (let i = 0; i < items.length; i++) { + random -= weights[i]; + if (random <= 0) return items[i]; + } + return items[items.length - 1]; +} + +// ============================================= +// Main generation function +// ============================================= + +export interface GenerationInput { + params: MealGenerationParams; + exclusions: MealSlotExclusion[]; + rules: (MealGenerationRule & { tag?: { id: string; name: string } | null })[]; + pins: MealSlotPin[]; + slots: SlotInfo[]; + pool: PoolEntry[]; + previousSlots: PreviousSlotInfo[]; + fillEmptyOnly: boolean; +} + +export function generate(input: GenerationInput): GenerationResult { + const { params, exclusions, rules, pins, slots, pool, previousSlots, fillEmptyOnly } = input; + + const report: GenerationReport = { + slotsGenerated: 0, + slotsSkipped: { excluded: 0, locked: 0, disabled: 0, alreadyFilled: 0 }, + slotsEmpty: 0, + warnings: [], + }; + + const assignments: SlotAssignment[] = []; + + // Build lookup sets + const excludedSlots = new Set(exclusions.map((e) => `${e.day}:${e.mealTime}`)); + const pinMap = new Map(pins.map((p) => [`${p.day}:${p.mealTime}`, p.tagId])); + + // Track assignments for cooldown/frequency (maps date+recipeId -> slot index) + // assignedRecipes: slotIndex -> recipeId assigned at that slot + const assignedRecipes: Map = new Map(); + // assignedDates: slotIndex -> date of the slot + const assignedDates: Map = new Map(); + // tagCounters: tagId -> count per frequency window + // (we'll track globally and compute per-window at the end) + const assignedTagsBySlotIndex: Map = new Map(); + + // Sort slots chronologically (should already be, but ensure) + const sortedSlots = [...slots].sort((a, b) => { + const dateDiff = new Date(a.date).getTime() - new Date(b.date).getTime(); + if (dateDiff !== 0) return dateDiff; + return a.mealTime === "LUNCH" ? -1 : 1; + }); + + // ============================================= + // PASS 1: Main pass + // ============================================= + for (let i = 0; i < sortedSlots.length; i++) { + const slot = sortedSlots[i]; + const slotDay = dateToDayOfWeek(slot.date); + const slotKey = `${slotDay}:${slot.mealTime}`; + + // 1. Skip disabled + if (slot.disabled) { + report.slotsSkipped.disabled++; + continue; + } + + // 2. Skip excluded + if (excludedSlots.has(slotKey)) { + report.slotsSkipped.excluded++; + continue; + } + + // 3. Skip locked + if (slot.locked) { + report.slotsSkipped.locked++; + // Track locked slot's recipe for cooldown tracking + if (slot.recipeId) { + assignedRecipes.set(i, slot.recipeId); + assignedDates.set(i, slot.date); + } + continue; + } + + // 4. Skip if fillEmptyOnly and not empty + if (fillEmptyOnly && slot.type !== "EMPTY") { + report.slotsSkipped.alreadyFilled++; + // Track existing slot's recipe for cooldown tracking + if (slot.recipeId) { + assignedRecipes.set(i, slot.recipeId); + assignedDates.set(i, slot.date); + } + continue; + } + + // 5. Build eligible pool + let eligible = [...pool]; + + // 6. Apply pin filter + const pinnedTagId = pinMap.get(slotKey); + if (pinnedTagId) { + eligible = eligible.filter((e) => e.tagIds.includes(pinnedTagId)); + } + + // 7-8-9-10. Filter by cooldown and frequency, then compute weights + eligible = applyCooldownRecipe( + eligible, + i, + slot, + sortedSlots, + assignedRecipes, + assignedDates, + previousSlots, + params.cooldownDays + ); + eligible = applyCooldownTag( + eligible, + i, + slot, + sortedSlots, + assignedRecipes, + assignedDates, + assignedTagsBySlotIndex, + previousSlots, + rules, + pool + ); + eligible = applyFrequencyMax( + eligible, + i, + sortedSlots, + assignedTagsBySlotIndex, + rules, + params, + pinnedTagId, + report + ); + + // 11. Compute weights + const weights = eligible.map((entry) => computeWeight(entry, slot.mealTime, rules)); + + // Filter out weight=0 entries + const finalEntries: PoolEntry[] = []; + const finalWeights: number[] = []; + for (let j = 0; j < eligible.length; j++) { + if (weights[j] > 0) { + finalEntries.push(eligible[j]); + finalWeights.push(weights[j]); + } + } + + // 12. Weighted random pick + const picked = weightedRandomPick(finalEntries, finalWeights); + + if (!picked) { + // Pool exhausted + report.slotsEmpty++; + report.warnings.push({ + type: "POOL_EXHAUSTED", + slotDay, + slotMealTime: slot.mealTime, + reason: "All recipes excluded by cooldown and frequency constraints", + }); + assignments.push({ + slotId: slot.id, + recipeId: null, + type: "EMPTY", + weight: 0, + }); + continue; + } + + // 13. Write assignment + const pickedWeight = finalWeights[finalEntries.indexOf(picked)]; + if (picked.type === "IDEA" && !picked.recipeId) { + // MealIdea sans recette -> FREE_TEXT + assignments.push({ + slotId: slot.id, + recipeId: null, + type: "FREE_TEXT", + freeText: picked.freeText, + comment: picked.comment, + weight: pickedWeight, + }); + } else { + assignments.push({ + slotId: slot.id, + recipeId: picked.recipeId, + type: "RECIPE", + weight: pickedWeight, + }); + } + + // Track for subsequent slots + if (picked.recipeId) { + assignedRecipes.set(i, picked.recipeId); + } + assignedDates.set(i, slot.date); + assignedTagsBySlotIndex.set(i, picked.tagIds); + + report.slotsGenerated++; + } + + // ============================================= + // PASS 2: Frequency min catch-up (Phase 5) + // ============================================= + runFrequencyMinCatchUp( + sortedSlots, + assignments, + assignedRecipes, + assignedDates, + assignedTagsBySlotIndex, + excludedSlots, + pinMap, + rules, + params, + pool, + previousSlots, + report + ); + + return { assignments, report }; +} + +// ============================================= +// Cooldown recette (global + cross-planning) +// ============================================= + +function applyCooldownRecipe( + eligible: PoolEntry[], + slotIndex: number, + slot: SlotInfo, + sortedSlots: SlotInfo[], + assignedRecipes: Map, + assignedDates: Map, + previousSlots: PreviousSlotInfo[], + cooldownDays: number +): PoolEntry[] { + if (cooldownDays <= 0) return eligible; + + const cooldownRecipeIds = new Set(); + + // Intra-generation: check previously assigned slots + for (let j = 0; j < slotIndex; j++) { + const rid = assignedRecipes.get(j); + const date = assignedDates.get(j); + if (rid && date) { + const dist = daysBetween(date, slot.date); + if (dist <= cooldownDays) { + cooldownRecipeIds.add(rid); + } + } + } + + // Cross-planning: check previous archived plan slots + for (const prev of previousSlots) { + if (prev.recipeId) { + const dist = daysBetween(prev.date, slot.date); + if (dist <= cooldownDays) { + cooldownRecipeIds.add(prev.recipeId); + } + } + } + + return eligible.filter((e) => !e.recipeId || !cooldownRecipeIds.has(e.recipeId)); +} + +// ============================================= +// Cooldown tag (per rule + cross-planning) +// ============================================= + +function applyCooldownTag( + eligible: PoolEntry[], + slotIndex: number, + slot: SlotInfo, + sortedSlots: SlotInfo[], + assignedRecipes: Map, + assignedDates: Map, + assignedTagsBySlotIndex: Map, + previousSlots: PreviousSlotInfo[], + rules: MealGenerationRule[], + _pool: PoolEntry[] +): PoolEntry[] { + // Find tag rules with tagCooldownDays set + const tagCooldownRules = rules.filter( + (r) => r.tagId && r.tagCooldownDays != null && r.tagCooldownDays > 0 + ); + if (tagCooldownRules.length === 0) return eligible; + + const cooldownTagIds = new Set(); + + for (const rule of tagCooldownRules) { + const tagId = rule.tagId!; + const cooldownDays = rule.tagCooldownDays!; + + // Intra-generation + for (let j = 0; j < slotIndex; j++) { + const date = assignedDates.get(j); + const tags = assignedTagsBySlotIndex.get(j); + if (date && tags && tags.includes(tagId)) { + const dist = daysBetween(date, slot.date); + if (dist <= cooldownDays) { + cooldownTagIds.add(tagId); + break; + } + } + } + + // Cross-planning + for (const prev of previousSlots) { + if (prev.tagIds.includes(tagId)) { + const dist = daysBetween(prev.date, slot.date); + if (dist <= cooldownDays) { + cooldownTagIds.add(tagId); + break; + } + } + } + } + + if (cooldownTagIds.size === 0) return eligible; + + return eligible.filter((e) => { + // Ideas sans recette ne sont pas affectees par le tag cooldown + if (e.tagIds.length === 0) return true; + // Exclure si l'entree a un tag en cooldown + return !e.tagIds.some((t) => cooldownTagIds.has(t)); + }); +} + +// ============================================= +// Frequency max (per tag per window) +// ============================================= + +function applyFrequencyMax( + eligible: PoolEntry[], + slotIndex: number, + sortedSlots: SlotInfo[], + assignedTagsBySlotIndex: Map, + rules: (MealGenerationRule & { tag?: { id: string; name: string } | null })[], + params: MealGenerationParams, + pinnedTagId: string | undefined, + report: GenerationReport +): PoolEntry[] { + const freqMaxRules = rules.filter((r) => r.tagId && r.frequencyMax != null); + if (freqMaxRules.length === 0) return eligible; + + const slot = sortedSlots[slotIndex]; + const excludedTagIds = new Set(); + + for (const rule of freqMaxRules) { + const tagId = rule.tagId!; + const maxCount = rule.frequencyMax!; + const per = rule.frequencyPer || "PER_WEEK"; + + // Count current occurrences in the appropriate window + let count = 0; + for (let j = 0; j < slotIndex; j++) { + const tags = assignedTagsBySlotIndex.get(j); + if (!tags || !tags.includes(tagId)) continue; + + if (per === "PER_PLANNING") { + count++; + } else { + // PER_WEEK: same 7-day tranche + const slotDate = new Date(sortedSlots[j].date); + const startDate = new Date(sortedSlots[0].date); + const currentDate = new Date(slot.date); + const slotWeek = Math.floor(daysBetween(startDate, slotDate) / 7); + const currentWeek = Math.floor(daysBetween(startDate, currentDate) / 7); + if (slotWeek === currentWeek) count++; + } + } + + if (count >= maxCount) { + // If this tag is pinned for this slot, we can't exclude it - add warning + if (pinnedTagId === tagId) { + report.warnings.push({ + type: "FREQUENCY_MAX_EXCEEDED", + tagId, + tagName: rule.tag?.name, + required: maxCount, + actual: count + 1, + reason: "Pin forces tag beyond frequencyMax", + }); + } else { + excludedTagIds.add(tagId); + } + } + } + + if (excludedTagIds.size === 0) return eligible; + + return eligible.filter((e) => { + if (e.tagIds.length === 0) return true; + return !e.tagIds.some((t) => excludedTagIds.has(t)); + }); +} + +// ============================================= +// Weight computation +// ============================================= + +function computeWeight( + entry: PoolEntry, + slotMealTime: MealTime, + rules: MealGenerationRule[] +): number { + // Ideas sans recette: poids base 1.0, pas de regles + if (entry.tagIds.length === 0 && !entry.recipeId) return 1.0; + + let weight = 1.0; + + for (const rule of rules) { + // Check mealTimeConstraint + if (rule.mealTimeConstraint && rule.mealTimeConstraint !== slotMealTime) continue; + + // Tag rule + if (rule.tagId && entry.tagIds.includes(rule.tagId)) { + weight *= rule.weight; + } + + // Recipe rule + if (rule.recipeId && entry.recipeId === rule.recipeId) { + weight *= rule.weight; + } + } + + return weight; +} + +// ============================================= +// PASS 2: Frequency min catch-up +// ============================================= + +function runFrequencyMinCatchUp( + sortedSlots: SlotInfo[], + assignments: SlotAssignment[], + assignedRecipes: Map, + assignedDates: Map, + assignedTagsBySlotIndex: Map, + excludedSlots: Set, + pinMap: Map, + rules: (MealGenerationRule & { tag?: { id: string; name: string } | null })[], + params: MealGenerationParams, + pool: PoolEntry[], + previousSlots: PreviousSlotInfo[], + report: GenerationReport +): void { + const freqMinRules = rules.filter((r) => r.tagId && r.frequencyMin != null && r.frequencyMin > 0); + if (freqMinRules.length === 0) return; + + // Build assignment lookup: slotId -> assignment + const assignmentMap = new Map(assignments.map((a) => [a.slotId, a])); + const startDate = new Date(sortedSlots[0].date); + + for (const rule of freqMinRules) { + const tagId = rule.tagId!; + const minCount = rule.frequencyMin!; + const per = rule.frequencyPer || "PER_WEEK"; + + // Determine windows + const windows = getFrequencyWindows(sortedSlots, per, startDate); + + for (const window of windows) { + // Count current occurrences of this tag in this window + let count = 0; + for (const idx of window.slotIndices) { + const tags = assignedTagsBySlotIndex.get(idx); + if (tags && tags.includes(tagId)) count++; + } + + if (count >= minCount) continue; // Already met + + const deficit = minCount - count; + + // Find replaceable slots in this window: + // non-locked, non-excluded, non-pinned, not already having this tag + const replaceableCandidates: { slotIdx: number; weight: number }[] = []; + for (const idx of window.slotIndices) { + const slot = sortedSlots[idx]; + const slotDay = dateToDayOfWeek(slot.date); + const slotKey = `${slotDay}:${slot.mealTime}`; + + if (slot.disabled || slot.locked || excludedSlots.has(slotKey)) continue; + if (pinMap.has(slotKey)) continue; + + const assignment = assignmentMap.get(slot.id); + if (!assignment) continue; + if (assignment.type === "EMPTY") continue; // Can't replace empty with tagged recipe + + // Skip if this slot already has the tag + const slotTags = assignedTagsBySlotIndex.get(idx); + if (slotTags && slotTags.includes(tagId)) continue; + + replaceableCandidates.push({ slotIdx: idx, weight: assignment.weight }); + } + + // Sort by weight ascending (replace least important first) + replaceableCandidates.sort((a, b) => a.weight - b.weight); + + let replaced = 0; + for (const candidate of replaceableCandidates) { + if (replaced >= deficit) break; + + const slot = sortedSlots[candidate.slotIdx]; + + // Find a recipe with this tag that respects cooldown + const taggedPool = pool.filter((e) => e.tagIds.includes(tagId) && e.recipeId); + const eligibleForReplace = applyCooldownRecipe( + taggedPool, + candidate.slotIdx, + slot, + sortedSlots, + assignedRecipes, + assignedDates, + previousSlots, + params.cooldownDays + ); + + if (eligibleForReplace.length === 0) continue; + + // Check frequencyMax for the tag we're adding wouldn't be exceeded + // (simplified: just pick one) + const weights = eligibleForReplace.map((e) => computeWeight(e, slot.mealTime, rules)); + const positiveEntries: PoolEntry[] = []; + const positiveWeights: number[] = []; + for (let j = 0; j < eligibleForReplace.length; j++) { + if (weights[j] > 0) { + positiveEntries.push(eligibleForReplace[j]); + positiveWeights.push(weights[j]); + } + } + + const picked = weightedRandomPick(positiveEntries, positiveWeights); + if (!picked) continue; + + // Replace the assignment + const assignment = assignmentMap.get(slot.id)!; + assignment.recipeId = picked.recipeId; + assignment.type = "RECIPE"; + assignment.freeText = null; + assignment.comment = null; + assignment.weight = positiveWeights[positiveEntries.indexOf(picked)]; + + // Update tracking + if (picked.recipeId) { + assignedRecipes.set(candidate.slotIdx, picked.recipeId); + } + assignedTagsBySlotIndex.set(candidate.slotIdx, picked.tagIds); + + replaced++; + } + + const finalCount = count + replaced; + if (finalCount < minCount) { + report.warnings.push({ + type: "FREQUENCY_MIN_NOT_MET", + tagId, + tagName: rule.tag?.name, + required: minCount, + actual: finalCount, + reason: "Not enough eligible recipes with this tag", + }); + } + } + } +} + +/** Get frequency windows (slotIndices per window) */ +function getFrequencyWindows( + sortedSlots: SlotInfo[], + per: FrequencyPer | string, + startDate: Date +): { slotIndices: number[] }[] { + if (per === "PER_PLANNING") { + return [{ slotIndices: sortedSlots.map((_, i) => i) }]; + } + + // PER_WEEK: group by 7-day tranches from startDate + const windowMap = new Map(); + for (let i = 0; i < sortedSlots.length; i++) { + const week = Math.floor(daysBetween(startDate, sortedSlots[i].date) / 7); + if (!windowMap.has(week)) windowMap.set(week, []); + windowMap.get(week)!.push(i); + } + + return Array.from(windowMap.values()).map((indices) => ({ slotIndices: indices })); +} diff --git a/backend/src/services/mealGenerationService.ts b/backend/src/services/mealGenerationService.ts new file mode 100644 index 0000000..ff4a7fc --- /dev/null +++ b/backend/src/services/mealGenerationService.ts @@ -0,0 +1,114 @@ +/** + * Meal Generation Service + * Couche DB pour alimenter l'algorithme de generation (mealGeneration.ts) + */ + +import createHttpError from "http-errors"; +import { MealSlot } from "@prisma/client"; +import prisma from "../util/db"; +import { MEAL_GEN_001 } from "../constants/errorCodes"; +import { PoolEntry, SlotInfo, PreviousSlotInfo } from "./mealGeneration"; + +/** + * Charger un jeu de params avec ses relations (exclusions, rules, pins). + * Leve 404 si introuvable, mauvaise communaute, ou soft-deleted. + */ +export async function loadGenerationParams(paramsId: string, communityId: string) { + const params = await prisma.mealGenerationParams.findUnique({ + where: { id: paramsId }, + include: { + exclusions: true, + rules: { include: { tag: { select: { id: true, name: true } } } }, + slotPins: true, + }, + }); + + if (!params || params.communityId !== communityId || params.deletedAt) { + throw createHttpError(404, MEAL_GEN_001); + } + + return params; +} + +/** + * Construire le pool de recettes (+ idees si useIdeas) pour la generation. + */ +export async function buildPool(communityId: string, useIdeas: boolean): Promise { + const recipes = await prisma.recipe.findMany({ + where: { communityId, deletedAt: null }, + include: { tags: { select: { tagId: true } } }, + }); + + const pool: PoolEntry[] = recipes.map((r) => ({ + id: r.id, + type: "RECIPE" as const, + recipeId: r.id, + tagIds: r.tags.map((t) => t.tagId), + })); + + if (useIdeas) { + const ideas = await prisma.mealIdea.findMany({ + where: { communityId, deletedAt: null }, + include: { + recipe: { + select: { id: true, tags: { select: { tagId: true } } }, + }, + }, + }); + + for (const idea of ideas) { + pool.push({ + id: idea.id, + type: "IDEA" as const, + recipeId: idea.recipe?.id ?? null, + tagIds: idea.recipe?.tags.map((t) => t.tagId) ?? [], + freeText: idea.name, + comment: idea.comment, + }); + } + } + + return pool; +} + +/** + * Charger les slots du dernier plan archive pour cross-planning cooldown. + */ +export async function buildPreviousSlots( + communityId: string, + currentPlanId: string +): Promise { + const lastArchive = await prisma.mealPlan.findFirst({ + where: { communityId, status: "ARCHIVED", id: { not: currentPlanId } }, + orderBy: { startDate: "desc" }, + include: { + slots: { + where: { type: { not: "EMPTY" }, recipeId: { not: null } }, + include: { recipe: { select: { tags: { select: { tagId: true } } } } }, + }, + }, + }); + + if (!lastArchive) return []; + + return lastArchive.slots.map((s) => ({ + date: s.date, + recipeId: s.recipeId, + tagIds: s.recipe?.tags.map((t) => t.tagId) ?? [], + })); +} + +/** + * Transformer les slots Prisma en SlotInfo pour l'algorithme. + */ +export function slotsToSlotInfo(slots: MealSlot[]): SlotInfo[] { + return slots.map((s) => ({ + id: s.id, + date: s.date, + mealTime: s.mealTime, + type: s.type, + disabled: s.disabled, + locked: s.locked, + recipeId: s.recipeId, + })); +} diff --git a/backend/src/util/responseFormatters.ts b/backend/src/util/responseFormatters.ts index 425b53e..5f32a2b 100644 --- a/backend/src/util/responseFormatters.ts +++ b/backend/src/util/responseFormatters.ts @@ -48,3 +48,21 @@ export function formatSteps(steps: RawStep[]) { instruction: s.instruction, })); } + +/** + * Transforme une relation avec deletedAt en { ...fields, isDeleted }. + * Pattern reutilise par mealPlan (slot.recipe), mealIdeas (idea.recipe), + * mealGenerationParams (rule.recipe). + */ +export function formatDeletedRelation>( + relation: (T & { deletedAt: Date | null }) | null, + fields: (keyof T)[] +): Record | null { + if (!relation) return null; + const result: Record = {}; + for (const key of fields) { + result[key as string] = relation[key]; + } + result.isDeleted = relation.deletedAt !== null; + return result; +} diff --git a/backend/src/util/validateEnv.ts b/backend/src/util/validateEnv.ts index e512abf..c0c7c92 100644 --- a/backend/src/util/validateEnv.ts +++ b/backend/src/util/validateEnv.ts @@ -1,19 +1,25 @@ -import { cleanEnv } from "envalid"; -import { bool, port, str } from "envalid/dist/validators"; +import { z } from "zod"; -export default cleanEnv(process.env, { - DATABASE_URL: str(), - PORT: port(), - SESSION_SECRET: str(), - ADMIN_SESSION_SECRET: str(), - CORS_ORIGIN: str({ default: "" }), - NODE_ENV: str({ choices: ["development", "production", "test"], default: "development" }), +const portValidator = z.coerce.number().int().min(0).max(65535); + +const envSchema = z.object({ + DATABASE_URL: z.string(), + PORT: portValidator, + SESSION_SECRET: z.string(), + ADMIN_SESSION_SECRET: z.string(), + CORS_ORIGIN: z.string().default(""), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), // 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 }), + MINIO_ENDPOINT: z.string().default("minio"), + MINIO_PORT: portValidator.default(9000), + MINIO_ACCESS_KEY: z.string().default("minioadmin"), + MINIO_SECRET_KEY: z.string().default("minioadmin"), + MINIO_BUCKET: z.string().default("forestmanager-images-dev"), + MINIO_PUBLIC_URL: z.string().default("http://localhost:9000"), + MINIO_USE_SSL: z + .string() + .default("false") + .transform((v) => v === "true"), }); + +export default envSchema.parse(process.env); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 8df6b3b..dfab2e0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -52,6 +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. */ + "rootDir": "./src", "outDir": "./dist" /* Specify an output folder for all emitted files. */, // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ @@ -103,6 +104,8 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, + "include": ["src"], + "exclude": ["node_modules", "dist"], "ts-node": { "files": true } diff --git a/docs/0 - brainstorming futur.md b/docs/0 - brainstorming futur.md index f642f85..3ad80e3 100644 --- a/docs/0 - brainstorming futur.md +++ b/docs/0 - brainstorming futur.md @@ -101,28 +101,43 @@ Bottom tab bar, bottom sheets, breakpoints md:, touch targets 44px, safe areas, 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 +## ~~systeme + page de changelog automatique~~ SPEC DONE -(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) +Spec complete : `docs/features/changelog/SPEC_CHANGELOG.md` +Generation auto via CI, versioning semver, page user blog-like, CRUD admin, API key CI, conventional commits. -- Mise en place d'un système de version propre pour suivre les patch du changelog ? +---------------------------------------- Special feature 1---------------------------- ## Gestionnaire de planning de repas dans une communauté -automatique + drag and drop (à a Trello ? ) +Nous sommes ici à notre première nouvelle feature majeure. (penser à utiliser le système de feature) +Ici deux features différentes : la première va être la gestion d'un planning de repas, (manuellement) +La deuxième va être la gestion de plusieurs jeux de paramètres de génération automatisé du planning. +Il va falloir se concentrer sur la première mais mettre de côté les informations concernant la seconde, la conserver en tête et la faire dans un second temps + +automatique + drag and drop pour réorganiser le planning (à a Trello ? ) 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. +Dans chaque communauté, il ne peut y avoir qu'un seul planning, mais il peut y avoir plusieurs jeu de paramètres de génération pour pouvoir faire plusieurs template de génération (par saison par exemple)etc. +Pour générer un planning, un jeu de paramètres de génération offre la possibilité de créer des règles d'automatisation selon des tags (possibilité avancée de mettre des poids sur les tags, voire même sur des recettes spécifiques de la communauté), cooldown avant qu'une recette ne revienne, dédier des recettes aux repas du midi, repas du soir, pouvoir mettre des poids sur les recettes afin de favoriser ou non leur récurence etc. +Il faut aussi pouvoir préciser des repas à ne pas entrer dans la plannification dans le jeu de donnée (exemple, si la communauté ne mange jamais ensemble le mardi soir, ça ne sert à rien de générer un menu pour ce repas là). + +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 (champ libre de recherche parmis les recettes de la communauté, puis du user (en proposant d'ajouter sa recette à la commu si ce n'est pas le cas) puis champ libre si besoin (en cas de champ libre, il faut un second champ pour pouvoir ajouter des commentaires visible lorsque l'on clique sur la carte du menu). -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. +il faut aussi un nombre de personne standard qui sera utilisé pour calculer les quantités d'ingrédients (future feature liste de courses). Ce chiffre doit pouvoir être modifié (comme sur les recette) facilement sur chaque repas. -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. +Pouvoir ajouter une liste de recettes (le nom + un commentaire facultatif 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. +## Feature génération automatique du planning de repas + +Mettre ici tout ce qu'il faut conserver pour la feature génération automatique du planning de repas. + +---------------------------------------- Fin -------------------------------------------------- + ## Brique liste de courses +Nous sommes ici une nouvelle feature majeure. (penser à utiliser le système de feature) + Création d'une liste de courses associée au planning. Possibilité d'ajouter un lien web à un ingrédient, Possibilité de compléter avec des articles non liés à une recette. @@ -165,10 +180,14 @@ L'idee serait d'introduire un concept de `RecipeSection` (ou groupe) : ## Brique on joue à quoi ? +Nous sommes ici une nouvelle feature majeure. (penser à utiliser le système de feature) + (utiliser api publique pour trouver des jeux et faire une liste) ## Brique On regarde quoi et ou ? +Nous sommes ici une nouvelle feature majeure. (penser à utiliser le système de feature) + (utiliser api publique pour trouver des films/séries et faire une liste, s'inspirer de Netflix pour l'interface) ## Multi langue ? @@ -197,6 +216,12 @@ Comptage vues (RecipeView, RecipeAnalytics) Affichage statistiques sur recettes Dashboard analytics utilisateur +### Idees futures liees au changelog + +- **Notification de nouveau changelog** : badge/notification in-app quand une nouvelle version est publiee. Potentiellement une categorie `CHANGELOG` dans NotificationCategory. +- **Changelog public** : rendre le changelog accessible sans connexion (flag `isPublic` sur le modele) +- **Markdown dans le contenu** : enrichir les items avec du markdown (liens, gras, etc.) + --- Hello ! diff --git a/docs/RECIPE_FLOWS.md b/docs/RECIPE_FLOWS.md new file mode 100644 index 0000000..8f9fc32 --- /dev/null +++ b/docs/RECIPE_FLOWS.md @@ -0,0 +1,181 @@ +# Flux recettes — Reference complete + +Ce document decrit tous les types de recettes possibles, les transitions entre eux, et les regles de permission associees. + +--- + +## 1. Types de recettes (champs discriminants) + +| Champ | Valeur | Signification | +| ----------------------- | ------- | ---------------------------------------- | +| `communityId` | `null` | Recette personnelle | +| `communityId` | UUID | Recette communautaire | +| `isVariant` | `false` | Recette "canonique" | +| `isVariant` | `true` | Variante (nee d'un refus de proposition) | +| `originRecipeId` | `null` | Racine (pas d'ancetre) | +| `originRecipeId` | UUID | Enfant d'une autre recette | +| `sharedFromCommunityId` | `null` | Creee localement ou publiee depuis perso | +| `sharedFromCommunityId` | UUID | Forkee depuis cette communaute | + +--- + +## 2. Points d'entree — Creation + +``` + POINT D'ENTREE + | + .---------------------------.-------------------. + | | | + POST /api/recipes/ POST /communities/:id/recipes POST /recipes/import-url + | | | + v v v + [PERSONNELLE] [COMMUNAUTAIRE NATIVE] [PERSONNELLE] + comId: null comId: X comId: null + origin: null origin: perso.id origin: null + variant: false variant: false variant: false + + Note : la creation communautaire cree deux recettes en une transaction atomique : + 1. une recette personnelle (communityId: null) + 2. une copie communautaire (communityId: X, originRecipeId: perso.id) +``` + +--- + +## 3. Arbre des possibilites + +### Depuis une recette PERSONNELLE + +``` + [PERSONNELLE] + comId: null + | + |-- publish -> com X, Y, Z... (owner uniquement) + | POST /api/recipes/:id/publish + | + v + [COPIE COMMUNAUTAIRE] x N communautes en une seule requete + comId: X + origin: perso.id + sharedFrom: null + variant: false + | + '-- (meme comportement que COMMUNAUTAIRE, voir section suivante) + + Ce que la PERSONNELLE NE PEUT PAS faire : + x share (SHARE_002 : communityId est null) + x recevoir des proposals (PROPOSAL_001 : communityId est null) +``` + +### Depuis une recette COMMUNAUTAIRE (native, publiee ou forkee) + +``` + [COMMUNAUTAIRE] + comId: X + variant: false + -- native : origin = personal.id, sharedFrom = null + -- publiee : origin = personal.id, sharedFrom = null + -- forkee : origin = source.id, sharedFrom = comSource + | + .------------------------.--------------------------. + | | | + v v v + [SHARE -> com Y] [PROPOSAL] [EDIT / DELETE] + POST .../share POST .../proposals owner ou membre + | | + v .----'----. + [FORK] | | + comId: Y [ACCEPT] [REJECT] + origin: src.id | | + sharedFrom: X | v + variant: false | [VARIANTE] + | | comId: X (meme com) + | mise a jour origin: recette.id + | in-place sharedFrom: null + | de la variant: true + | recette creatorId: proposeur + | originale steps/ing: proposedXxx + | tags: copies de l'original + | | + | .-------------' + | | La VARIANTE herite des memes droits + | | qu'une recette communautaire : + | | ok peut recevoir des proposals (aucun check isVariant) + | | ok peut etre partagee/forkee (aucun check isVariant) + | | ok peut etre editee / supprimee + | v + | [FORK de variante] -> meme comportement recursif + | + v + [FORK (com Y)] -> meme comportement recursif + ok proposals + ok fork encore vers com Z + ok variante +``` + +--- + +## 4. Tableau de synthese des permissions + +| Type de recette | Share -> com | Publish -> com | Recevoir proposal | Editer | Supprimer | +| --------------- | ------------ | -------------- | ----------------- | ------------ | ------------ | +| Personnelle | non | oui (owner) | non | owner | owner | +| Communautaire | oui \* | non | oui \*\* | owner/membre | owner/membre | +| Fork (partagee) | oui \* | non | oui \*\* | owner/membre | owner/membre | +| Variante | oui \* | non | oui \*\* | owner/membre | owner/membre | + +``` +* createur de la recette OU moderateur dans la com source OU moderateur dans la com cible +** tout membre de la communaute, sauf le createur de la recette lui-meme +``` + +--- + +## 5. Arbre de parente (chaine originRecipeId) + +``` +[Recette perso] <--- racine de la famille + | + |-- originRecipeId --> [Recette com A] (creation directe ou publish) + | | + | |-- originRecipeId --> [Fork com B] + | | sharedFromCommunityId: A + | | | + | | '--> [Fork com C] + | | sharedFromCommunityId: B + | | + | '-- originRecipeId --> [Variante com A] + | isVariant: true + | | + | '--> [Fork de variante com D] + | + '-- originRecipeId --> [Recette com X] (publish vers autre communaute) + | + '--> originRecipeId --> [Fork com Z] + +Analytics : getRecipeFamilyCommunities remonte jusqu'a la racine (BFS), + updateAncestorAnalytics incremente shares++ sur toute la chaine lors d'un fork. +``` + +--- + +## 6. Resolution des tags lors d'un fork / publish + +``` +Tag GLOBAL ---------------------------------> GLOBAL (inchange) + +Tag COMMUNITY -- tag APPROVED existe dans com cible ? --> lie directement (APPROVED) +(com source) -- tag absent dans com cible ? --> cree PENDING dans com cible + (attend approbation moderateur) + Notification -> moderateurs +``` + +--- + +## 7. Regles metier notables + +- Un fork vers une communaute deja destinataire de cette recette est bloque (SHARE_006). +- Le publish skip silencieusement les communautes deja couvertes (pas d'erreur, juste ignorees). +- Une variante peut elle-meme etre forkee ou recevoir des proposals — il n'y a pas de profondeur maximale dans le code. +- Le publish ne peut cibler qu'une recette avec `communityId === null` (PUBLISH_002) — impossible de "re-publier" une recette deja communautaire. +- Les proposals ne sont pas possibles sur une recette personnelle (PROPOSAL_001). +- Lors d'un REJECT de proposal, la variante creee recoit les tags de la recette originale (pas les tags proposes). diff --git a/docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md b/docs/features/V1/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md similarity index 100% rename from docs/features/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md rename to docs/features/V1/audit-refactorisation/SPEC_AUDIT_REFACTORISATION.md diff --git a/docs/features/e2e-testing/ROADMAP.md b/docs/features/V1/e2e-testing/ROADMAP.md similarity index 100% rename from docs/features/e2e-testing/ROADMAP.md rename to docs/features/V1/e2e-testing/ROADMAP.md diff --git a/docs/features/e2e-testing/SPEC_E2E_TESTING.md b/docs/features/V1/e2e-testing/SPEC_E2E_TESTING.md similarity index 100% rename from docs/features/e2e-testing/SPEC_E2E_TESTING.md rename to docs/features/V1/e2e-testing/SPEC_E2E_TESTING.md diff --git a/docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md b/docs/features/V1/ingredients-rework/SPEC_INGREDIENTS_REWORK.md similarity index 100% rename from docs/features/ingredients-rework/SPEC_INGREDIENTS_REWORK.md rename to docs/features/V1/ingredients-rework/SPEC_INGREDIENTS_REWORK.md diff --git a/docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md b/docs/features/V1/input-validation-security/SPEC_INPUT_VALIDATION.md similarity index 100% rename from docs/features/input-validation-security/SPEC_INPUT_VALIDATION.md rename to docs/features/V1/input-validation-security/SPEC_INPUT_VALIDATION.md diff --git a/docs/features/mobile-rework/ROADMAP.md b/docs/features/V1/mobile-rework/ROADMAP.md similarity index 100% rename from docs/features/mobile-rework/ROADMAP.md rename to docs/features/V1/mobile-rework/ROADMAP.md diff --git a/docs/features/mobile-rework/SPEC_MOBILE_REWORK.md b/docs/features/V1/mobile-rework/SPEC_MOBILE_REWORK.md similarity index 100% rename from docs/features/mobile-rework/SPEC_MOBILE_REWORK.md rename to docs/features/V1/mobile-rework/SPEC_MOBILE_REWORK.md diff --git a/docs/features/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md b/docs/features/V1/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md similarity index 100% rename from docs/features/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md rename to docs/features/V1/notifications-rework/SPEC_NOTIFICATIONS_REWORK.md diff --git a/docs/features/photo-upload/GUIDE_MINIO.md b/docs/features/V1/photo-upload/GUIDE_MINIO.md similarity index 100% rename from docs/features/photo-upload/GUIDE_MINIO.md rename to docs/features/V1/photo-upload/GUIDE_MINIO.md diff --git a/docs/features/photo-upload/SPEC_PHOTO_UPLOAD.md b/docs/features/V1/photo-upload/SPEC_PHOTO_UPLOAD.md similarity index 100% rename from docs/features/photo-upload/SPEC_PHOTO_UPLOAD.md rename to docs/features/V1/photo-upload/SPEC_PHOTO_UPLOAD.md diff --git a/docs/features/recipe-import/MANUAL_TESTS.md b/docs/features/V1/recipe-import/MANUAL_TESTS.md similarity index 100% rename from docs/features/recipe-import/MANUAL_TESTS.md rename to docs/features/V1/recipe-import/MANUAL_TESTS.md diff --git a/docs/features/recipe-import/ROADMAP.md b/docs/features/V1/recipe-import/ROADMAP.md similarity index 100% rename from docs/features/recipe-import/ROADMAP.md rename to docs/features/V1/recipe-import/ROADMAP.md diff --git a/docs/features/recipe-import/SPEC_RECIPE_IMPORT.md b/docs/features/V1/recipe-import/SPEC_RECIPE_IMPORT.md similarity index 100% rename from docs/features/recipe-import/SPEC_RECIPE_IMPORT.md rename to docs/features/V1/recipe-import/SPEC_RECIPE_IMPORT.md diff --git a/docs/features/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md b/docs/features/V1/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md similarity index 100% rename from docs/features/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md rename to docs/features/V1/recipe-rework-v2/SPEC_RECIPE_REWORK_V2.md diff --git a/docs/features/tags-rework/SPEC_TAGS_REWORK.md b/docs/features/V1/tags-rework/SPEC_TAGS_REWORK.md similarity index 100% rename from docs/features/tags-rework/SPEC_TAGS_REWORK.md rename to docs/features/V1/tags-rework/SPEC_TAGS_REWORK.md diff --git a/docs/features/changelog/ROADMAP.md b/docs/features/changelog/ROADMAP.md new file mode 100644 index 0000000..b2661c9 --- /dev/null +++ b/docs/features/changelog/ROADMAP.md @@ -0,0 +1,124 @@ +# Roadmap : Changelog Automatique + +Spec : `docs/features/changelog/SPEC_CHANGELOG.md` + +--- + +## Phase 1 — Modele de donnees & migration ✅ + +- [x] Ajouter l'enum `CHANGELOG_CREATED | CHANGELOG_UPDATED | CHANGELOG_DELETED` a `AdminActionType` +- [x] Creer le modele `ChangelogEntry` dans `schema.prisma` +- [x] Generer et appliquer la migration Prisma +- [x] Ajouter l'upsert `v1.0.0` dans le seed (idempotent, par version) +- [x] Verifier que le seed passe sans erreur + +--- + +## Phase 2 — Backend API (Admin) ✅ + +- [x] Creer `admin/controllers/changelogController.ts` +- [x] Creer `admin/routes/changelogRoutes.ts` +- [x] `GET /api/admin/changelog` — liste paginee (includeDeleted optionnel) +- [x] `POST /api/admin/changelog` — creation manuelle (requireSuperAdmin) +- [x] `PATCH /api/admin/changelog/:id` — modification (title, content, version, publishedAt) +- [x] `DELETE /api/admin/changelog/:id` — soft delete +- [x] Validation : version semver, content JSON structure, title 1-200 chars +- [x] Audit log (`CHANGELOG_CREATED`, `CHANGELOG_UPDATED`, `CHANGELOG_DELETED`) +- [x] Codes erreur : CHANGELOG_001 a CHANGELOG_004 +- [x] Tests integration admin CRUD (17 tests) + +--- + +## Phase 3 — Backend API (User) ✅ + +- [x] Creer `controllers/changelog.ts` +- [x] Creer `routes/changelog.ts` +- [x] `GET /api/changelog` — liste paginee (requireAuth, deletedAt: null) +- [x] `GET /api/changelog/:id` — detail (requireAuth, deletedAt: null) +- [x] Brancher les routes dans `app.ts` +- [x] Tests integration user endpoints (7 tests) + +--- + +## Phase 4 — Script de generation & script d'insertion ✅ + +- [x] Creer `scripts/generate-changelog.js` (JS pur, tourne dans le CI) + - [x] Parser conventional commits (regex) + - [x] Filtrer : exclure test/docs/ci/build/chore (sauf chore(deps)) + - [x] Exclure merge commits + - [x] Categoriser : feat → features, fix → fixes, refactor/perf/style → improvements + - [x] Calculer la prochaine version semver depuis le dernier tag + - [x] Generer le titre auto (ex: "2 nouveautes et 3 corrections") + - [x] Sortie JSON sur stdout, exit code 2 si rien a publier +- [x] Test du script en local (avec des commits de test) +- [x] Creer `backend/src/scripts/insertChangelog.ts` (compile dans dist/, tourne dans le container) + - [x] Recoit JSON changelog en argument + - [x] Validation : version semver, content structure + - [x] Insert en DB via Prisma (`changelogEntry.create`) + - [x] Gestion conflit version (erreur si doublon) + - [x] Compile dans dist/scripts/insertChangelog.js (rootDir + include ajoutes au tsconfig) + +--- + +## Phase 5 — Job CI (generate-changelog via Portainer exec) ✅ + +- [x] Ajouter le job `generate-changelog` dans `deploy.yml` + - [x] `needs: [deploy-prod]`, uniquement si deploy reussi + - [x] Checkout avec `fetch-depth: 0` + - [x] Determiner le dernier tag `v*` + - [x] Executer `scripts/generate-changelog.js` pour parser les commits + - [x] Skip si aucun commit user-facing (exit code 2) + - [x] Trouver le container backend via API Portainer (filtre par nom) + - [x] Executer `dist/scripts/insertChangelog.js` dans le container via Portainer exec + - [x] Verifier exit code de l'exec + - [x] Creer et pousser le tag git `vX.Y.Z` +- [x] Aucun nouveau secret GitHub necessaire (reutilise PORTAINER_URL, PORTAINER_API, ENDPOINT_ID) + +--- + +## Phase 6 — Frontend User (page changelog) ✅ + +- [x] Creer `pages/ChangelogPage.tsx` + - [x] Liste de cartes empilees, du plus recent au plus ancien + - [x] Badge version colore + - [x] Date relative + absolue + - [x] 3 categories avec icone/couleur : Nouveautes (vert), Ameliorations (bleu), Corrections (rouge) + - [x] Pagination classique (load more) +- [x] Ajouter la route `/changelog` (requireAuth) +- [x] Service API : `getChangelog(limit, offset)`, `getChangelogEntry(id)` +- [x] Modifier `Sidebar.tsx` : + - [x] Version dynamique (derniere version du changelog) + - [x] Texte version cliquable → lien `/changelog` + - [x] Mode compact : version avec tooltip "Changelog" +- [x] Tests composant ChangelogPage (6 tests) + +--- + +## Phase 7 — Frontend Admin (page CRUD) ✅ + +- [x] Creer `pages/admin/AdminChangelogPage.tsx` + - [x] Table : Version, Titre, Date, Status, Actions + - [x] Bouton "Nouvelle entree" + - [x] Filtre afficher/masquer supprimees +- [x] Modal creation/edition : + - [x] Champs : version (semver), titre, date publication + - [x] Editeur structure : 3 sections (Nouveautes, Ameliorations, Corrections) + - [x] Ajout/suppression d'items par section + - [x] Bouton sauvegarder avec confirmation +- [x] Modal suppression avec confirmation +- [x] Ajouter dans `AdminLayout.tsx` : nav item "Changelog" (icone `FaNewspaper`) +- [x] Ajouter la route `/admin/changelog` dans `adminRoutes.tsx` +- [x] Service API admin : CRUD changelog +- [x] Tests composant AdminChangelogPage (10 tests) + +--- + +## Phase 8 — Mise a jour docs & contexte ✅ + +- [x] Mettre a jour `API_MAP.md` (nouveaux endpoints: 6 user + 4 admin) +- [x] Mettre a jour `DB_MODELS.md` (ChangelogEntry model + CHANGELOG\_\* enum values) +- [x] Mettre a jour `FILE_MAP.md` (nouveaux fichiers backend + frontend) +- [x] Mettre a jour `PROGRESS.md` (feature terminee) +- [x] Mettre a jour `TESTS.md` (24 backend + 16 frontend tests) +- [x] Mettre a jour `CLAUDE.md` (codes erreur CHANGELOG_001-004) +- [x] Cocher toutes les taches de cette roadmap diff --git a/docs/features/changelog/SPEC_CHANGELOG.md b/docs/features/changelog/SPEC_CHANGELOG.md new file mode 100644 index 0000000..7192b61 --- /dev/null +++ b/docs/features/changelog/SPEC_CHANGELOG.md @@ -0,0 +1,501 @@ +# Spec : Systeme de Changelog Automatique + +## Vue d'ensemble + +Systeme de changelog automatise qui genere une entree a chaque deploiement en production. Les commits sont analyses, filtres (seuls les changements impactant l'experience utilisateur sont retenus), categorises et stockes en base. Le changelog est consultable par les utilisateurs connectes et entierement gerable par l'admin. + +--- + +## 1. Modele de donnees + +### 1.1 Nouveau modele Prisma : `ChangelogEntry` + +```prisma +model ChangelogEntry { + id String @id @default(uuid()) + version String @unique // semver "1.2.0" + title String // titre libre, ex: "Rework mobile complet" + content Json // structure categoriee (voir 1.2) + publishedAt DateTime @default(now()) // date de publication (= date deploy) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // soft delete admin + + @@index([publishedAt]) + @@index([deletedAt]) +} +``` + +**Regles** : + +- Soft delete (coherent avec les conventions du projet) +- UUID v4 +- `version` unique — empeche les doublons +- `publishedAt` indexe pour le tri chronologique + +### 1.2 Format du champ `content` (JSON) + +```json +{ + "features": [{ "text": "Import de recettes depuis une URL externe" }], + "improvements": [ + { "text": "Navigation mobile entierement repensee" }, + { "text": "Formulaires adaptes aux ecrans tactiles" } + ], + "fixes": [{ "text": "Correction du partage de recettes entre communautes" }] +} +``` + +**3 categories** : +| Categorie | Label affiche | Commits sources | +| -------------- | -------------------- | -------------------------------------- | +| `features` | Nouveautes | `feat:`, `feat(scope):` | +| `improvements` | Ameliorations | `refactor:`, `perf:`, `style:` `chore(deps):` | +| `fixes` | Corrections | `fix:`, `fix(scope):` | + +**Commits ignores** (pas user-facing) : `test:`, `docs:`, `ci:`, `build:`, `chore:` (sauf deps) + +Chaque item est un objet `{ text: string }` plutot qu'un simple string pour permettre d'enrichir plus tard (ex: ajout d'un lien, d'une icone). + +--- + +## 2. Versionnement Semver + +### 2.1 Schema + +Format : `MAJOR.MINOR.PATCH` + +| Increment | Condition | Exemple | +| --------- | -------------------------------------------------------------------------------- | ------------- | +| MAJOR | Breaking change explicite (`BREAKING CHANGE:` dans le body ou `!` apres le type) | 1.0.0 → 2.0.0 | +| MINOR | Au moins un `feat:` dans le diff | 1.0.0 → 1.1.0 | +| PATCH | Seulement `fix:`, `refactor:`, `perf:`, `style:` | 1.0.0 → 1.0.1 | + +### 2.2 Version initiale + +La premiere version sera `1.0.0` — elle marque le MVP complet actuel. Elle sera creee manuellement par l'admin comme premiere entree du changelog (recapitulatif de l'etat actuel de l'app). + +### 2.3 Git tags + +A chaque generation de changelog, le CI cree un tag git `vMAJOR.MINOR.PATCH` sur le commit de merge. Cela permet de delimiter facilement les commits entre deux versions. Le tag est pousse vers le remote. + +### 2.4 Detection du diff + +Le script CI determine les commits a analyser : + +1. Recupere le dernier tag git `v*` +2. Si aucun tag : prend tous les commits (premiere generation uniquement) +3. `git log --oneline ..HEAD` donne la liste des commits a categoriser + +--- + +## 3. Generation automatique (CI/CD) + +### 3.1 Nouveau job dans `deploy.yml` : `generate-changelog` + +Position : apres `deploy-prod`, uniquement sur push to master. + +```yaml +generate-changelog: + runs-on: ubuntu-latest + needs: [deploy-prod] + if: needs.deploy-prod.result == 'success' +``` + +### 3.2 Etapes du job + +1. **Checkout** avec `fetch-depth: 0` (historique complet pour les tags) +2. **Determiner le dernier tag** : `git describe --tags --abbrev=0 --match "v*"` (ou fallback si aucun tag) +3. **Lister les commits** : `git log --oneline ..HEAD` +4. **Parser et categoriser** via un script Node.js (`scripts/generate-changelog.ts`) + - Filtre les commits non user-facing + - Categorise en features / improvements / fixes + - Calcule la prochaine version semver + - Si aucun commit user-facing : **skip** (pas de changelog vide) +5. **Generer le titre** : auto-genere a partir du contenu (ex: `"3 nouveautes, 5 ameliorations et 2 corrections"`) — modifiable par l'admin ensuite +6. **Inserer en DB via Portainer exec** : executer un script dans le container backend prod (voir 3.4) +7. **Creer et pousser le tag git** : `git tag v && git push origin v` + +### 3.3 Script `scripts/generate-changelog.ts` + +```typescript +// Entree : liste de commits (via stdin ou argument) +// Sortie : JSON { version, title, content } sur stdout + +// Parsing : regex sur le format conventional commit +// ^(feat|fix|refactor|perf|style|chore|test|docs|ci|build)(\(.+\))?(!)?:\s(.+)$ +// - group 1 : type +// - group 2 : scope (optionnel) +// - group 3 : breaking (!) +// - group 4 : description + +// Commits non-conventionnels : ignores (merge commits, messages libres) +// Merge commits (^Merge) : toujours ignores +``` + +### 3.4 Insertion via Portainer exec (CI → Backend container) + +**Principe** : plutot qu'exposer un endpoint HTTP dedie avec API key, le CI execute directement une commande dans le container backend prod via l'API Portainer. Le backend n'est pas expose publiquement (reseau Docker `internal` uniquement) et cette approche garde tout en interne. + +**Le CI a deja acces a** : `PORTAINER_URL`, `PORTAINER_API`, `ENDPOINT_ID` (utilises pour le deploy). + +**Script d'insertion** : `scripts/insert-changelog.ts` — petit script executable dans le container backend qui : + +1. Recoit le JSON changelog en argument (version, title, content) +2. Insere en DB via Prisma (`changelogEntry.create`) +3. Gere le conflit de version (upsert ou erreur si doublon) + +```typescript +// scripts/insert-changelog.ts +// Usage : npx ts-node scripts/insert-changelog.ts '' +// ou : node dist/scripts/insert-changelog.js '' +// +// Le script est inclus dans l'image Docker backend (build stage) +// Il a acces a Prisma et a DATABASE_URL via l'env du container +``` + +**Flux Portainer exec dans le CI** : + +```bash +# 1. Trouver le container backend +CONTAINERS=$(curl -s -H "X-API-Key: ${PORTAINER_API}" \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?filters=%7B%22name%22%3A%5B%22forestmanager-backend%22%5D%7D") +CONTAINER_ID=$(echo "$CONTAINERS" | jq -r '.[0].Id') + +# 2. Creer l'exec +EXEC_ID=$(curl -s -H "X-API-Key: ${PORTAINER_API}" \ + -H "Content-Type: application/json" \ + -d "{\"Cmd\":[\"node\",\"dist/scripts/insert-changelog.js\",\"${CHANGELOG_JSON}\"],\"AttachStdout\":true,\"AttachStderr\":true}" \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/exec" \ + | jq -r '.Id') + +# 3. Demarrer l'exec +curl -s -H "X-API-Key: ${PORTAINER_API}" \ + -H "Content-Type: application/json" \ + -d '{"Detach":false}' \ + "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/exec/${EXEC_ID}/start" +``` + +**Avantages par rapport a un endpoint HTTP** : + +- Zero surface d'attaque supplementaire (pas d'endpoint public, pas d'API key dediee) +- Le backend reste uniquement sur le reseau interne Docker +- Reutilise les credentials Portainer existants (deja dans les secrets GitHub) +- Acces direct a Prisma/DB depuis le container (pas de latence reseau supplementaire) + +**Inconvenients acceptes** : + +- Legere complexite supplementaire dans le script CI (3 appels API Portainer) +- Necessite que le container backend soit running (garanti car le job `generate-changelog` depend de `deploy-prod`) + +--- + +## 4. API Backend + +### 4.1 Endpoints User (requireAuth) + +``` +GET /api/changelog # Liste paginee (page, limit) +GET /api/changelog/:id # Detail d'une entree +``` + +**Response GET list** : + +```json +{ + "data": [ + { + "id": "uuid", + "version": "1.2.0", + "title": "3 nouveautes et 2 corrections", + "content": { "features": [...], "improvements": [...], "fixes": [...] }, + "publishedAt": "2026-03-17T14:00:00Z" + } + ], + "pagination": { "page": 1, "limit": 10, "total": 25, "totalPages": 3 } +} +``` + +**Filtrage** : `deletedAt: null` (seules les entrees actives sont visibles) + +### 4.2 Endpoints Admin (requireSuperAdmin) + +``` +GET /api/admin/changelog # Liste (inclut soft-deleted si ?includeDeleted=true) +POST /api/admin/changelog # Creer manuellement une entree +PATCH /api/admin/changelog/:id # Modifier (title, content, version, publishedAt) +DELETE /api/admin/changelog/:id # Soft delete +``` + +**POST /api/admin/changelog** (creation manuelle par admin) : + +```json +{ + "version": "1.0.0", + "title": "Lancement de Forest Manager", + "content": { + "features": [ + { "text": "Gestion de recettes personnelles et communautaires" }, + { "text": "Systeme d'invitations et de communautes privees" } + ], + "improvements": [], + "fixes": [] + } +} +``` + +**Note** : l'insertion automatique par le CI se fait via Portainer exec (voir section 3.4), pas via un endpoint HTTP. Pas d'endpoint `/generate` — le CI insere directement en DB depuis le container backend. + +### 4.3 Codes erreur + +| Code | HTTP | Description | +| ------------- | ---- | ---------------------------------------- | +| CHANGELOG_001 | 404 | Entree non trouvee | +| CHANGELOG_002 | 409 | Version deja existante | +| CHANGELOG_003 | 400 | Contenu invalide (format JSON incorrect) | +| CHANGELOG_004 | 400 | Version invalide (format semver) | + +### 4.4 Validation + +- `version` : regex `/^\d+\.\d+\.\d+$/` +- `title` : string, 1-200 caracteres +- `content` : objet avec au moins une des 3 cles (`features`, `improvements`, `fixes`), chaque cle est un array d'objets `{ text: string }` avec `text` de 1-500 caracteres +- `publishedAt` : date ISO valide (optionnel, defaut now) + +--- + +## 5. Frontend User + +### 5.1 Acces : bouton dans le footer sidebar + +**Emplacement** : footer du `Sidebar.tsx`, entre le texte de version et le toggle theme. + +Le texte de version actuel (`Forest Manager v0.1`) devient un lien cliquable vers `/changelog`. La version affichee sera dynamique (derniere version du changelog). + +En mode compact : icone cliquable avec tooltip "Changelog". + +### 5.2 Page `/changelog` + +Layout type blog, du plus recent au plus ancien. + +``` +[Header: "Changelog"] + +[Carte] + v1.2.0 — 17 mars 2026 + "3 nouveautes, 5 ameliorations et 2 corrections" + + Nouveautes + * Import de recettes depuis une URL externe + * ... + + Ameliorations + * Navigation mobile repensee + * ... + + Corrections + * Correction du partage entre communautes + * ... +[/Carte] + +[Carte suivante...] + +[Pagination] +``` + +**Design** : + +- Cartes empilees verticalement, responsive +- Badge de version colore (ex: `badge badge-primary`) +- Date en format relatif + absolu (ex: "il y a 3 jours — 17 mars 2026") +- Categories avec icone/couleur distincte (vert nouveautes, bleu ameliorations, rouge corrections) +- Pagination classique (coherent avec le reste de l'app, pas d'infinite scroll) + +### 5.3 Pas de page de detail + +Le contenu est affiche directement dans la liste (chaque carte = une entree complete). Pas besoin d'une page `/changelog/:id` cote user — le contenu est assez court pour etre lu inline. L'endpoint `GET /api/changelog/:id` existe pour l'admin ou pour un usage futur si necessaire. + +--- + +## 6. Frontend Admin + +### 6.1 Nouvelle page `/admin/changelog` + +Ajout dans la navigation admin (sidebar `AdminLayout.tsx`) avec icone `FaNewspaper` ou `FaHistory`. + +**Liste** : + +- Table avec colonnes : Version, Titre, Date, Status (actif/supprime), Actions +- Bouton "Nouvelle entree" en haut +- Filtre : afficher/masquer les entrees supprimees +- Actions par ligne : Modifier, Supprimer + +**Modal creation/edition** : + +- Champ version (input text, validation semver) +- Champ titre (input text) +- Editeur de contenu structure : + - 3 sections (Nouveautes, Ameliorations, Corrections) + - Chaque section : liste d'items avec bouton + pour ajouter, x pour supprimer + - Chaque item : input text +- Date de publication (date picker, defaut now) +- Bouton sauvegarder avec confirmation + +**Modal suppression** : + +- Confirmation avec le texte : "Supprimer la version X.Y.Z ?" +- Bouton confirmer / annuler + +### 6.2 AdminActionType + +Nouveau type d'action pour l'audit log : + +``` +CHANGELOG_CREATED | CHANGELOG_UPDATED | CHANGELOG_DELETED +``` + +Chaque action admin sur le changelog est tracee dans `AdminActivityLog`. + +--- + +## 7. Convention de commits (officialisation) + +Le projet utilise deja les conventional commits de facto. Cette spec les officialise : + +``` +(): + +[body optionnel] + +[BREAKING CHANGE: description] +``` + +### Types reconnus + +| Type | Usage | Apparait dans le changelog | +| ---------- | ------------------------------------------- | -------------------------- | +| `feat` | Nouvelle fonctionnalite | Oui (Nouveautes) | +| `fix` | Correction de bug | Oui (Corrections) | +| `refactor` | Refactorisation sans changement fonctionnel | Oui (Ameliorations)\* | +| `perf` | Amelioration de performance | Oui (Ameliorations) | +| `style` | Changement CSS/UI sans fonctionnel | Oui (Ameliorations)\* | +| `test` | Ajout/modification de tests | Non | +| `docs` | Documentation | Non | +| `ci` | CI/CD | Non | +| `build` | Build, deps | Non | +| `chore` | Maintenance | Non\* | + +\*`refactor` et `style` : inclus uniquement s'ils ont un impact visible pour l'utilisateur. Le script les inclut par defaut mais l'admin peut les retirer manuellement. + +\*`chore(deps)` : inclus dans Ameliorations (mises a jour de dependances = securite/perf). + +### Scope + +Le scope est optionnel mais recommande pour les features multi-fichiers : + +- `feat(mobile):` — changement lie au mobile +- `fix(recipe):` — correction sur les recettes +- `refactor(auth):` — refactorisation auth + +Le scope est utilise uniquement pour le parsing, pas affiche dans le changelog (le texte du commit suffit). + +--- + +## 8. Flux complet (diagramme) + +``` +Developer GitHub CI (Actions) Backend (Prod) + | | | | + |-- push branch ----------->| | | + | |-- PR merge to master ---->| | + | | |-- test ------------------>| + | | |-- build & push images --->| + | | |-- deploy-prod ----------->| (Portainer stack update) + | | | | + | | |-- generate-changelog: | + | | | 1. git describe (last tag) + | | | 2. git log tag..HEAD | + | | | 3. parse & categorize | + | | | 4. compute version | + | | | 5. Portainer exec ------>| (insert en DB via Prisma) + | | | 6. git tag & push | + | | | | +User | + | | + |-- GET /api/changelog ------------------------------------------------------------>| + |<-- liste paginee (JSON) ----------------------------------------------------------| +``` + +--- + +## 9. Variables d'environnement + +Aucune nouvelle variable d'environnement necessaire. + +Le job CI reutilise les secrets Portainer existants (`PORTAINER_URL`, `PORTAINER_API`, `ENDPOINT_ID`) deja configures pour le deploy. L'insertion en DB se fait via Portainer exec dans le container backend (voir section 3.4). + +**Note architecture reseau** : le backend n'est pas expose publiquement. Il est uniquement sur le reseau Docker `internal`. Le CI n'a pas besoin d'acceder au backend via HTTP — il execute directement une commande dans le container via l'API Portainer. + +--- + +## 10. Securite + +- **Pas d'endpoint HTTP expose pour le CI** : l'insertion se fait via Portainer exec, donc aucune surface d'attaque supplementaire cote backend +- **Acces Portainer** : protege par l'API key Portainer existante, deja en secret GitHub +- **Isolation reseau** : le backend reste exclusivement sur le reseau Docker `internal` +- **Validation stricte** : version semver, content JSON structure, taille des champs (dans le script d'insertion ET dans les endpoints admin) +- **Audit** : chaque action admin (CRUD manuel) est loguee dans AdminActivityLog. Les insertions CI sont tracables via les logs du container et les git tags + +--- + +## 11. Seed + +L'entree `v1.0.0` sera creee par le seed (upsert par version) pour que l'environnement de dev ait toujours au moins une entree de changelog. + +```typescript +await prisma.changelogEntry.upsert({ + where: { version: "1.0.0" }, + update: {}, + create: { + version: "1.0.0", + title: "Lancement de Forest Manager", + content: { + features: [ + { text: "Gestion de recettes personnelles et communautaires" }, + { text: "Systeme de communautes privees avec invitations" }, + { text: "Propositions de modifications collaboratives" }, + { text: "Import de recettes depuis des URLs externes" }, + ], + improvements: [], + fixes: [], + }, + }, +}); +``` + +--- + +## 12. Points exclus (hors scope) + +- **Notifications de nouveau changelog** : prevu pour plus tard (voir brainstorming futur) +- **Changelog public (non connecte)** : non pour l'instant, pourra etre ajoute via un flag `isPublic` sur le modele +- **Generation par LLM** : le parsing des conventional commits est deterministe et suffisant. Pas besoin d'IA. +- **Markdown dans le contenu** : pas pour la v1. Les items sont du texte brut. Enrichissement possible plus tard. +- **Webhook/notification Slack** : hors scope + +--- + +## 13. Impact sur l'existant + +| Element | Modification | +| ------------------------ | ------------------------------------------------------------------ | +| `schema.prisma` | + modele `ChangelogEntry` | +| `AdminActionType` (enum) | + `CHANGELOG_CREATED`, `CHANGELOG_UPDATED`, `CHANGELOG_DELETED` | +| `deploy.yml` | + job `generate-changelog` (Portainer exec) | +| `scripts/` | + `generate-changelog.ts` (CI) + `insert-changelog.ts` (container) | +| `Sidebar.tsx` | Version cliquable → lien `/changelog` | +| `AdminLayout.tsx` | + nav item "Changelog" | +| `adminRoutes.tsx` | + route `/admin/changelog` | +| Routes user | + route `/changelog` | +| Backend routes | + `/api/changelog`, `/api/admin/changelog` | +| `seed.ts` | + upsert changelog v1.0.0 | diff --git a/docs/features/deps-cleanup/ROADMAP.md b/docs/features/deps-cleanup/ROADMAP.md new file mode 100644 index 0000000..7791c50 --- /dev/null +++ b/docs/features/deps-cleanup/ROADMAP.md @@ -0,0 +1,136 @@ +# Roadmap : Nettoyage des dependances + +Spec : `docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md` + +Ordre choisi : du plus simple/sans risque au plus consequent. + +--- + +## Phase 1 — Suppression `classnames` + +Risque : faible. 1 fichier impacte. + +Attention : l'usage est avec l'API objet (`cn({ "classe": true })`), pas avec des strings. +Pas besoin d'utilitaire — remplacer par des strings/template literals directement. + +- [x] Mettre a jour `src/components/Modal.tsx` : + - Remplacer `cn({ "modal modal-bottom sm:modal-middle": true, "modal-open": true })` par la string constante `"modal modal-bottom sm:modal-middle modal-open"` + - Remplacer `cn("modal-box", className)` par `` `modal-box${className ? ` ${className}` : ""}` `` + - Supprimer l'import `classnames` +- [x] Desinstaller `classnames` dans le container frontend +- [x] Verifier que les tests frontend passent (le test "should have modal-open class" valide le rendu) + +--- + +## Phase 2 — Suppression `usehooks-ts` + +Risque : faible. 1 fichier impacte. + +`src/hooks/useClickOutside.ts` existe deja mais n'ecoute que `mousedown`. +`usehooks-ts` ecoutait aussi `touchstart` — regression mobile si on branche sans enrichir. +Enrichir le hook existant et ajouter un test `touchstart` avant de modifier Modal. + +- [x] Ajouter `touchstart` dans `src/hooks/useClickOutside.ts` (en plus de `mousedown` existant) +- [x] Ajouter un test `touchstart` dans `useClickOutside.test.ts` : + - "should call callback when touching outside the ref element" (`TouchEvent` / `fireEvent.touchStart`) + - "should not call callback when touching inside the ref element" +- [x] Mettre a jour `src/components/Modal.tsx` — remplacer l'import `usehooks-ts` par `useClickOutside` +- [x] Desinstaller `usehooks-ts` dans le container frontend +- [x] Verifier que les tests frontend passent + +--- + +## Phase 3 — Backend : deplacement `@types/helmet` + suppression `read` + +Risque : faible. Changements isoles. + +- [x] Verifier si `helmet` embarque ses propres types (`npm info helmet` ou `ls node_modules/helmet/dist/*.d.ts`) + - Si oui : supprimer `@types/helmet` completement + - Si non : deplacer dans `devDependencies` +- [x] Reediter `src/scripts/createAdmin.ts` — remplacer `read` par `readline` stdlib + - Reimplementer `ask(prompt)` et `ask(prompt, silent: true)` avec `readline` + - Tester manuellement le script (`npx ts-node src/scripts/createAdmin.ts`) +- [x] Desinstaller `read` dans le container backend +- [x] Verifier que les tests backend passent + +--- + +## Phase 4 — Backend : remplacement `envalid` → `zod` + +Risque : faible. 1 fichier impacte, Zod deja present. + +- [x] Reediter `src/util/validateEnv.ts` — remplacer `cleanEnv` par `z.object().parse()` + - Reproduire exactement les memes variables et types + - Verifier que les messages d'erreur en cas de variable manquante sont clairs +- [x] Desinstaller `envalid` dans le container backend +- [ ] Redemarrer le backend, verifier le demarrage (`npm run docker:logs`) +- [x] Verifier que les tests backend passent + +--- + +## Phase 5 — Tests manquants avant remplacement axios + +Prerequis obligatoire avant Phase 6. Ces tests doivent passer avec axios, puis continuer +a passer apres le remplacement par fetch — c'est le filet de securite. + +### 5a — Tests unitaires `apiClient` + +Fichier : `src/__tests__/unit/network/apiClient.test.ts` + +- [x] `apiFetch` envoie bien `credentials: "include"` sur chaque requete +- [x] `apiFetch` prefixe l'URL avec `VITE_BACKEND_URL` +- [x] `apiFetch` envoie le header `Content-Type: application/json` +- [x] `apiFetch` lit le cookie `XSRF-TOKEN` et l'injecte dans `X-XSRF-TOKEN` +- [x] `apiFetch` ne plante pas si le cookie `XSRF-TOKEN` est absent +- [x] `apiFetch` leve une `ApiError` sur status >= 400 (avec `status` et `message` corrects) +- [x] `apiFetch` retourne `{ data }` parse en JSON sur status 2xx +- [x] `apiFetch` retourne `{ data: undefined }` sur status 204 sans appeler `.json()` + +### 5b — Tests unitaires `handleApiError` / `handleApiErrorWith` + +Fichier : `src/__tests__/unit/network/apiClient.test.ts` (meme fichier) + +- [x] `handleApiError` leve `UnauthorizedError` sur 401 +- [x] `handleApiError` leve `ConflictError` sur 409 +- [x] `handleApiError` leve une `Error` generique sur autre status (avec le message du body) +- [x] `handleApiError` leve `Error("Network error...")` si pas de response +- [x] `handleApiErrorWith` applique l'override sur le status specifie +- [x] `handleApiErrorWith` tombe en fallback sur `handleApiError` si status non override + +### 5c — Test du cas special 410 dans `removeMember` + +Fichier : `src/__tests__/unit/network/apiClient.test.ts` ou test dedie + +- [x] Le handler inline de `removeMember` retourne correctement sur status 410 (sans lever d'erreur) + +--- + +## Phase 6 — Frontend : remplacement `axios` → `fetch` natif + +Risque : moyen. Changement du client HTTP central, impacte tous les appels API. +Prerequis : Phase 5 completement verte. + +- [x] Creer le nouveau `src/network/apiClient.ts` base sur `fetch` + - Fonction `apiFetch(path, options?)` avec `credentials: "include"`, baseURL, Content-Type, CSRF + - Classe `ApiError` avec `status` et `message` + - Reimplementer `handleApiError` et `handleApiErrorWith` avec la meme signature externe +- [x] Mettre a jour `src/network/api.ts` + - Remplacer tous les appels `API.get/post/patch/delete` par `apiFetch` + - Remplacer les types `AxiosError` par `ApiError` +- [x] Desinstaller `axios` dans le container frontend +- [x] Verifier que les tests frontend passent (MSW supporte fetch natif, aucune modification des tests requise) +- [ ] **Tester manuellement les flux critiques** : + - [ ] Login / logout + - [ ] Chargement des recettes + - [ ] Creation / edition de recette + - [ ] Upload d'image (presigned URL) + - [ ] Flux CSRF (verifier dans les DevTools que le header `X-XSRF-TOKEN` est bien envoye) + - [ ] Gestion des erreurs 401 (redirect logout) et 409 (conflict) + +--- + +## Phase 7 — Mise a jour docs & contexte + +- [x] Mettre a jour `CLAUDE.md` si necessaire +- [x] Mettre a jour `docs/features/deps-cleanup/ROADMAP.md` (cocher les taches) +- [x] Mettre a jour `.claude/context/PROGRESS.md` diff --git a/docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md b/docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md new file mode 100644 index 0000000..99dd485 --- /dev/null +++ b/docs/features/deps-cleanup/SPEC_DEPS_CLEANUP.md @@ -0,0 +1,276 @@ +# Spec : Nettoyage des dependances + +## Contexte + +Suite a un audit de securite (CVEs axios, mai 2026) et a une revue des dependances, plusieurs packages ont ete identifies comme inutiles, redondants, ou remplacables par du code natif / des dependances deja presentes. L'objectif est de reduire la surface d'attaque, la taille du bundle, et la dette de maintenance. + +--- + +## Perimetre + +### Frontend — 5 packages concernes + +| Package | Statut | Raison | +| -------------------- | --------- | ---------------------------------------------------------------------------- | +| `@dnd-kit/core` | Garder | Utilise dans `StepEditor.tsx` — liste triable avec support clavier et touch. | +| `@dnd-kit/sortable` | Garder | Idem. | +| `@dnd-kit/utilities` | Garder | Idem. | +| `classnames` | Supprimer | 1 seul usage dans `Modal.tsx`. Remplacable par une fonction inline. | +| `usehooks-ts` | Supprimer | 1 seul usage dans `Modal.tsx` (`useOnClickOutside`). Hook de ~10 lignes. | +| `axios` | Remplacer | Remplacable par `fetch` natif + wrapper. Elimine le risque CVE a la racine. | + +### Backend — 3 packages concernes + +| Package | Statut | Raison | +| --------------- | --------- | ----------------------------------------------------------------------------------- | +| `envalid` | Supprimer | Redondant avec `zod` (deja dependance). `z.object().parse()` couvre le besoin. | +| `read` | Supprimer | Utilise uniquement dans un script CLI. Remplacable par `readline` (stdlib Node.js). | +| `@types/helmet` | Deplacer | Package de types dans `dependencies`. Doit etre en `devDependencies`. | + +--- + +## Specifications techniques + +### 1. Suppression `classnames` + +**Usage actuel** (`Modal.tsx`) : + +```tsx +// Appel avec OBJET (API classnames, pas juste des strings) +cn({ "modal modal-bottom sm:modal-middle": true, "modal-open": true }); +// Appel avec strings +cn("modal-box", className); +``` + +**Point d'attention** : le premier appel utilise l'API objet de `classnames` (`{ "classe": condition }`). Une fonction `cn(...strings[])` ne couvre pas ce cas. Ici toutes les conditions sont `true` donc la replacement est une string constante directe — pas besoin d'utilitaire : + +```tsx +// Avant +const modalClass = cn({ "modal modal-bottom sm:modal-middle": true, "modal-open": true }); +// Apres +const modalClass = "modal modal-bottom sm:modal-middle modal-open"; +``` + +Pour le second appel (`cn("modal-box", className)`), remplacer par un template literal : + +```tsx +// Avant +
+// Apres +
+``` + +Aucune creation de fichier utilitaire necessaire. + +--- + +### 2. Suppression `usehooks-ts` + +**Usage actuel** (`Modal.tsx`) : + +```tsx +import { useOnClickOutside } from "usehooks-ts"; +useOnClickOutside(ref, () => { + onClose(); +}); +``` + +**Point d'attention** : `src/hooks/useClickOutside.ts` existe deja dans le projet et couvre le cas `mousedown`. Mais `usehooks-ts` ecoute egalement `touchstart` — fermeture du modal au tap sur mobile. Brancher sur le hook existant sans l'enrichir est une regression mobile silencieuse (les tests n'utilisent que `fireEvent.mouseDown`). + +**Remplacement** : enrichir `src/hooks/useClickOutside.ts` avec `touchstart`, puis brancher `Modal.tsx` dessus : + +```ts +// src/hooks/useClickOutside.ts — ajouter touchstart +document.addEventListener("mousedown", handleClickOutside); +document.addEventListener("touchstart", handleClickOutside); +return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); +}; +``` + +Les tests existants de `useClickOutside.test.ts` couvrent `mousedown`. Ajouter un test pour `touchstart` avant de modifier. + +--- + +### 3. Remplacement `axios` → `fetch` natif + +C'est le changement le plus consequent. Toute la logique est concentree dans deux fichiers : + +- `src/network/apiClient.ts` — configuration centrale (baseURL, credentials, intercepteurs) +- `src/network/api.ts` — fonctions API metier (`AxiosError` type) +- `src/network/adminApi.ts` et `src/network/mealApi.ts` utilisent `API` et `handleApiError` depuis `apiClient.ts` + +**Comportement a reproduire exactement :** + +| Fonctionnalite axios | Equivalent fetch | +| ----------------------------------- | ---------------------------------------------------------- | +| `axios.create({ withCredentials })` | `credentials: "include"` dans chaque requete | +| `baseURL` | Prefixer l'URL avec `VITE_BACKEND_URL` | +| Intercepteur Content-Type | Header `"Content-Type": "application/json"` systematique | +| Intercepteur CSRF (cookie → header) | Lire `XSRF-TOKEN` dans le cookie, injecter dans la requete | +| `AxiosError.response.status` | `ApiError.status` | +| `AxiosError.response.data.error` | `await response.json()` puis `.error` | +| Rejet auto sur status >= 400 | A implementer manuellement (`if (!res.ok) throw ...`) | + +**Choix architectural : wrapper `{ data }` (Option A)** + +`response.data` est utilise 110+ fois dans `api.ts`, `adminApi.ts`, `mealApi.ts`. Pour eviter de les modifier tous, `apiFetch` retourne un objet `{ data: T }` qui reproduit le shape axios : + +```ts +async function apiFetch(path: string, options?: RequestInit): Promise<{ data: T }> { + const res = await fetch(`${API_URL}${path}`, { credentials: "include", ...options }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(res.status, body.error || `Request failed (${res.status})`); + } + // 204 No Content ou body vide : ne pas appeler .json() + if (res.status === 204 || res.headers.get("content-length") === "0") { + return { data: undefined as T }; + } + const data = await res.json(); + return { data }; +} +``` + +**Gestion du status 204 No Content** + +8 endpoints backend retournent 204 avec un body vide (suppressions). Appeler `.json()` sur un body vide leve `SyntaxError`. Le `apiFetch` detecte ce cas via le status ou le header `content-length` avant d'appeler `.json()`. + +**Cas special : `error.response?.status === 410` dans `removeMember`** + +Dans `api.ts`, le handler inline de `removeMember` accede a `error.response?.status` (pattern axios). Apres migration, c'est `error.status` (pattern `ApiError`). Ce handler doit etre adapte : + +```ts +// Avant +(error: AxiosError) => { + if (error.response?.status === 410) return error.response; + return handleApiError(error); +}; +// Apres +(error: ApiError | Error) => { + if (error instanceof ApiError && error.status === 410) { + return { data: error.message }; + } + return handleApiError(error as ApiError); +}; +``` + +**Gestion des erreurs** : `AxiosError` est remplace par `ApiError` : + +```ts +export class ApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + } +} +``` + +Les helpers `handleApiError` et `handleApiErrorWith` sont preserves avec la meme signature externe. + +**Contrainte CSRF** : le mecanisme de lecture du cookie `XSRF-TOKEN` et d'injection dans le header `X-XSRF-TOKEN` doit etre rigoureusement preserve — c'est un element de securite critique. + +**Note** : `useImageUpload.ts` utilise deja `fetch` natif pour l'upload vers MinIO (PUT presigned URL) — non impacte. + +--- + +### 4. Remplacement `envalid` → `zod` (backend) + +**Usage actuel** (`src/util/validateEnv.ts`) — import depuis les internals du package : + +```ts +import { cleanEnv } from "envalid"; +import { bool, port, str } from "envalid/dist/validators"; // chemin interne +``` + +**Equivalences exactes des validators envalid → Zod :** + +| envalid | Zod | +| --------------------------------------- | ---------------------------------------------------------- | +| `str()` | `z.string()` | +| `str({ default: "val" })` | `z.string().default("val")` | +| `str({ choices: ["a","b","c"] })` | `z.enum(["a","b","c"])` | +| `str({ choices: [...], default: "a" })` | `z.enum(["a","b","c"]).default("a")` | +| `port()` | `z.coerce.number().int().min(0).max(65535)` | +| `port({ default: 9000 })` | `z.coerce.number().int().min(0).max(65535).default(9000)` | +| `bool({ default: false })` | `z.string().transform(v => v === "true").default("false")` | + +**Remplacement complet de `validateEnv.ts` :** + +```ts +import { z } from "zod"; + +const portValidator = z.coerce.number().int().min(0).max(65535); + +const envSchema = z.object({ + DATABASE_URL: z.string(), + PORT: portValidator, + SESSION_SECRET: z.string(), + ADMIN_SESSION_SECRET: z.string(), + CORS_ORIGIN: z.string().default(""), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + MINIO_ENDPOINT: z.string().default("minio"), + MINIO_PORT: portValidator.default(9000), + MINIO_ACCESS_KEY: z.string().default("minioadmin"), + MINIO_SECRET_KEY: z.string().default("minioadmin"), + MINIO_BUCKET: z.string().default("forestmanager-images-dev"), + MINIO_PUBLIC_URL: z.string().default("http://localhost:9000"), + MINIO_USE_SSL: z + .string() + .default("false") + .transform((v) => v === "true"), +}); + +export default envSchema.parse(process.env); +``` + +Zod leve une `ZodError` explicite si une variable manque ou est mal typee — comportement identique a `cleanEnv`. + +--- + +### 5. Remplacement `read` → `readline` (backend script) + +**Usage actuel** (`src/scripts/createAdmin.ts`) : + +```ts +import { read } from "read"; +const result = await read({ prompt: "..." }); +const result = await read({ prompt: "...", silent: true }); +``` + +**Remplacement** avec `readline` stdlib : + +```ts +import * as readline from "readline"; +function ask(prompt: string, silent = false): Promise { ... } +``` + +Le flag `silent: true` (masquer la saisie du mot de passe) necessite de jouer sur `process.stdin` directement — solution standard bien documentee en Node.js. + +--- + +### 6. Deplacement `@types/helmet` → `devDependencies` + +Simple deplacement dans `backend/package.json`. Verifier si helmet embarque deja ses propres types (auquel cas `@types/helmet` est completement suppressible). + +--- + +## Contraintes + +- **Zero regression fonctionnelle** : tous les tests backend doivent passer apres chaque phase. +- **CSRF preserve** : le mecanisme de protection CSRF ne doit pas etre altere lors du remplacement axios. +- **Tests frontend** : les tests existants (MSW) mocquent les requetes HTTP — ils doivent continuer a passer apres remplacement d'axios par fetch (MSW supporte les deux). +- **Pas de changement de comportement visible** : les messages d'erreur retournes a l'utilisateur restent identiques. + +--- + +## Ce qui est hors perimetre + +- `@dnd-kit` (×3) : utilise dans `StepEditor.tsx` pour liste triable avec support clavier et touch. Supprimer impliquerait de reimplementer l'accessibilite from scratch. +- `date-fns` : tree-shakee par Vite, cout negligeable, `formatDistanceToNow` complexe a reimplementer. +- `react-hook-form` : usage etendu (7 fichiers), gain faible. +- `cheerio` : usage justifie pour le parsing HTML de l'import de recettes. +- `react-hot-toast` : 43 fichiers, entierement embarquee. diff --git a/docs/features/meal-plan/MANUAL_TEST.md b/docs/features/meal-plan/MANUAL_TEST.md new file mode 100644 index 0000000..753cc0c --- /dev/null +++ b/docs/features/meal-plan/MANUAL_TEST.md @@ -0,0 +1,204 @@ +# Protocole de test manuel — Meal Plan & Generation + +## Prerequis + +- Feature MEAL_PLAN activee sur la communaute (via SuperAdmin) +- Au moins un MODERATOR dans la communaute +- Au moins 10 recettes publiees dans la communaute (avec des tags varies) +- Au moins 2-3 tags communautaires (ex: vegetarien, rapide, poisson) + +--- + +## 1. Planning Manuel + +### 1.1 Creation de plan + +- [ ] Moderateur cree un plan (date debut/fin, servings par defaut) +- [ ] Verifier que la grille s'affiche avec tous les jours + 2 repas par jour +- [ ] Verifier les slots EMPTY avec icone "+" + +### 1.2 Edition de slot + +- [ ] Clic sur un slot EMPTY → modal edition +- [ ] Assigner une recette (recherche autocomplete) +- [ ] Assigner un texte libre +- [ ] Modifier le nombre de couverts +- [ ] Ajouter/modifier un commentaire +- [ ] Desactiver un slot (disabled) +- [ ] Verifier l'affichage correct apres chaque modification + +### 1.3 Verrouillage + +- [ ] Moderateur : clic cadenas pour verrouiller un slot → icone change, style visuel distinct +- [ ] Moderateur : clic cadenas pour deverrouiller +- [ ] Membre : voit le cadenas mais ne peut pas le toggler + +### 1.4 Drag & Drop (desktop) + +- [ ] Glisser un slot sur un autre → les contenus sont echanges +- [ ] Verifier que les slots disabled ne sont pas draggable + +### 1.5 Parametres du plan + +- [ ] Modifier servings par defaut +- [ ] Toggler editableByMembers +- [ ] En tant que membre, verifier que l'edition est autorisee/interdite selon le toggle + +### 1.6 Archives + +- [ ] Creer un nouveau plan (archive l'ancien) +- [ ] Onglet Archives → voir l'ancien plan +- [ ] Clic sur une archive → modal avec la grille en lecture seule +- [ ] Supprimer une archive + +### 1.7 Idees de repas + +- [ ] Onglet Ideas → creer une idee (nom, commentaire, recette optionnelle) +- [ ] Modifier une idee +- [ ] Supprimer une idee + +--- + +## 2. Generation — Parametres + +### 2.1 Creation jeu de params + +- [ ] Onglet Generation → clic "New Set" +- [ ] Remplir nom, description, cooldownDays, useIdeas, isDefault +- [ ] Verifier apparition dans la liste avec badge "Default" si applicable + +### 2.2 Edition jeu de params + +- [ ] Clic sur un jeu → detail +- [ ] Modifier nom, description, cooldownDays via bouton Edit +- [ ] Changer isDefault → verifier que l'ancien default perd le badge + +### 2.3 Suppression + +- [ ] Supprimer un jeu → confirmation → disparait de la liste +- [ ] Si c'etait le default → plus de badge default dans la liste + +--- + +## 3. Generation — Exclusions & Pins + +### 3.1 Exclusions (desktop) + +- [ ] Cocher une case → le slot sera skip a la generation +- [ ] Decocher → le slot est inclus +- [ ] Verifier qu'on ne peut pas exclure un slot deja epingle (message d'erreur) + +### 3.2 Exclusions (mobile) + +- [ ] Layout vertical par jour (cards) +- [ ] Checkbox "Excl." fonctionnelle +- [ ] Meme validation (pas d'exclusion sur pin) + +### 3.3 Pins (desktop) + +- [ ] Clic icone recherche → autocomplete tag → selectionner → badge tag affiche +- [ ] Supprimer un pin (bouton x) +- [ ] Verifier qu'un slot exclu affiche "--" et pas de bouton pin + +### 3.4 Pins (mobile) + +- [ ] Recherche tag et selection fonctionnelles dans le layout mobile +- [ ] Suppression de pin + +--- + +## 4. Generation — Regles + +### 4.1 Regles par tag + +- [ ] Clic "Add tag rule" → autocomplete → selectionner un tag +- [ ] Slider poids : 0% (exclu), 100% (neutre), 200% (favorise) +- [ ] Select meal time : Both / Lunch / Dinner +- [ ] Frequence : None → toggle Exact (1 champ) → toggle Range (min/max) +- [ ] Cooldown tag (jours) +- [ ] Verifier sauvegarde auto (debounce 600ms) + +### 4.2 Regles par recette + +- [ ] Clic "Add recipe rule" → autocomplete → selectionner une recette +- [ ] Slider poids +- [ ] Select meal time +- [ ] Pas de frequence ni cooldown tag (champs absents) + +### 4.3 Suppression de regle + +- [ ] Bouton corbeille → regle disparait + +--- + +## 5. Generation — Lancement + +### 5.1 Bouton Generate + +- [ ] Bouton "Generate" visible uniquement pour les moderateurs +- [ ] Sur mobile : icone seule (sans texte) +- [ ] Clic → modal avec selecteur de params (pre-selectionne sur default) + +### 5.2 Fill empty only + +- [ ] Cocher "Fill empty slots only" (defaut : coche) +- [ ] Decocher → info "X non-locked slot(s) will be regenerated" +- [ ] Clic Generate avec fillEmptyOnly=false et slots non-vides → modal confirmation + +### 5.3 Generation + +- [ ] Confirmer la generation → spinner → toast "X slots generated" +- [ ] Verifier que la grille est mise a jour avec les nouvelles recettes +- [ ] Rapport affiche : nombre genere, nombre skip (excluded, locked, already filled) +- [ ] Slots verrouilles : inchanges +- [ ] Slots exclus : inchanges +- [ ] Slots disabled : inchanges + +### 5.4 Warnings + +- [ ] Generer avec un pool insuffisant (beaucoup de cooldown, peu de recettes) +- [ ] Verifier le warning "Pool exhausted" dans le rapport +- [ ] Generer avec frequencyMin non atteignable → warning "Frequency min not met" + +### 5.5 Dismiss rapport + +- [ ] Bouton X sur le rapport → disparait + +--- + +## 6. Generation — Replace + +### 6.1 Bouton Replace + +- [ ] Icone refresh visible sur les slots non-vides, non-locked, quand un default params existe +- [ ] Invisible sur slots locked, disabled, empty +- [ ] Invisible si aucun default params n'existe + +### 6.2 Remplacement + +- [ ] Clic icone refresh → modal confirmation "Replace this slot?" +- [ ] Confirmer → spinner → toast "Slot replaced" +- [ ] Verifier que la recette a change +- [ ] Annuler → rien ne se passe + +--- + +## 7. Mobile + +### 7.1 Planning + +- [ ] Layout vertical (cards par jour, 2 colonnes lunch/dinner) +- [ ] Cadenas fonctionnel +- [ ] Bouton Replace fonctionnel + +### 7.2 Generation + +- [ ] Bouton Generate (icone seule) dans le header +- [ ] Modal generation : affichage correct sur petit ecran +- [ ] Rapport : lisible sur mobile + +### 7.3 Params / Exclusions / Rules + +- [ ] Onglet Generation : liste des jeux de params +- [ ] Detail : exclusions/pins en layout vertical par jour +- [ ] Regles : sliders et controles utilisables sur mobile diff --git a/docs/features/meal-plan/ROADMAP.md b/docs/features/meal-plan/ROADMAP.md new file mode 100644 index 0000000..061adf6 --- /dev/null +++ b/docs/features/meal-plan/ROADMAP.md @@ -0,0 +1,170 @@ +# Roadmap : Meal Plan (Feature 1 — Planning Manuel) + +Spec : `docs/features/meal-plan/SPEC_MEAL_PLAN.md` + +--- + +## Phase 1 — Modele de donnees & migration + +- [x] Ajouter les enums `DayOfWeek`, `MealTime`, `MealSlotType`, `MealPlanStatus` dans `schema.prisma` +- [x] Creer le modele `MealPlan` (communityId, startDate, endDate, status, defaultServings, editableByMembers) +- [x] Creer le modele `MealSlot` (planId, date, mealTime, type, disabled, locked, recipeId?, freeText?, comment?, servings) +- [x] Creer le modele `MealIdea` (communityId, name, comment?, recipeId?, createdById?, deletedAt) +- [x] Ajouter les relations dans `Community`, `Recipe`, `User` +- [x] Generer et appliquer la migration Prisma +- [x] Upsert Feature `MEAL_PLAN` (code unique, isDefault: false) dans le seed +- [x] Seed de test : creer un plan ACTIVE avec slots remplis + disabled pour la communaute de test +- [x] Verifier que le seed passe sans erreur (idempotent) + +--- + +## Phase 2 — Middleware & Feature guard + +- [x] Creer middleware `requireFeature(featureCode)` generique (ou verifier s'il existe deja) +- [x] Le middleware verifie que la feature est activee pour la communaute (CommunityFeature, revokedAt null) +- [x] Retourne 403 avec code `MEAL_005` si feature non activee +- [x] Tests middleware requireFeature + +--- + +## Phase 3 — Backend API Meal Plan + +- [x] Creer `controllers/mealPlan.ts` +- [x] Creer `routes/mealPlan.ts` +- [x] `GET /api/communities/:communityId/meal-plan` — plan ACTIVE + tous les slots + recipe data (memberOf) +- [x] `POST /api/communities/:communityId/meal-plan` — creer plan + slots (MODERATOR) + - [x] Validation dates (startDate <= endDate, max 31 jours, pas de chevauchement) + - [x] Auto-archivage du plan actif existant + - [x] Creation dynamique des slots (N jours x 2 repas) + - [x] Support `disabledSlots` et `copyDisabledFromPrevious` +- [x] `DELETE /api/communities/:communityId/meal-plan` — supprimer plan ACTIVE + cascade (MODERATOR) +- [x] `PATCH /api/communities/:communityId/meal-plan` — update defaultServings / editableByMembers (MODERATOR) +- [x] `PATCH /api/communities/:communityId/meal-plan/slots/:slotId` — update slot (permission dynamique) + - [x] Validation type (RECIPE/FREE_TEXT/EMPTY) + - [x] Auto-enable du slot disabled quand on set un contenu + - [x] Refus si plan ARCHIVED +- [x] `POST /api/communities/:communityId/meal-plan/slots/swap` — swap 2 slots (permission dynamique) +- [x] Gestion recette soft-deleted : renvoyer flag `isDeleted` dans la reponse +- [x] Codes erreur MEAL_001 a MEAL_011 +- [x] Brancher les routes dans `app.ts` (sous communaute routes, avec requireFeature) +- [x] Tests unitaires plan CRUD (creation, archivage auto, validation dates) +- [x] Tests unitaires slots (update, swap, disabled auto-enable, locked) +- [x] Tests permissions (MODERATOR vs membre, editableByMembers toggle) +- [x] Tests feature guard (403 si feature desactivee) +- [x] Tests plan archive non-editable + +--- + +## Phase 4 — Backend API Archives + +- [x] `GET /api/communities/:communityId/meal-plan/archives` — liste paginee (memberOf) +- [x] `GET /api/communities/:communityId/meal-plan/archives/:planId` — detail archive + slots (memberOf) +- [x] `DELETE /api/communities/:communityId/meal-plan/archives/:planId` — supprimer archive (MODERATOR) +- [x] Validation : le plan demande doit appartenir a la communaute et etre ARCHIVED +- [x] Tests unitaires archives + +--- + +## Phase 5 — Backend API Meal Ideas + +- [x] Creer `controllers/mealIdeas.ts` +- [x] Creer `routes/mealIdeas.ts` +- [x] `GET /api/communities/:communityId/meal-ideas` — liste paginee, search par nom (memberOf) +- [x] `POST /api/communities/:communityId/meal-ideas` — creer idee (memberOf) +- [x] `PATCH /api/communities/:communityId/meal-ideas/:ideaId` — modifier (createur ou MODERATOR) +- [x] `DELETE /api/communities/:communityId/meal-ideas/:ideaId` — soft delete (createur ou MODERATOR) +- [x] Validation : name max 255, comment max 500, recipeId optionnel et valide +- [x] Brancher les routes dans `communities.ts` +- [x] Tests unitaires CRUD idees +- [x] Tests permissions (createur vs MODERATOR) + +--- + +## Phase 6 — Frontend : creation de planning + +- [x] Page planning dans la section communaute (`MealPlanPage.tsx`) +- [x] Conditionner l'acces a la feature `MEAL_PLAN` (403 si non activee) +- [x] Formulaire creation : date debut, date fin, nombre de personnes par defaut +- [x] Apercu visuel du planning avec checkboxes pour desactiver des slots +- [x] Option "Reprendre les desactivations du planning precedent" +- [x] Message "Le planning actuel sera archive" si plan actif existe +- [x] Appel API POST + affichage du plan cree + +--- + +## Phase 7 — Frontend : vue planning (desktop) + +- [x] Grille N colonnes (jours) x 2 lignes (midi/soir) +- [x] Scroll horizontal si > 7 jours +- [x] En-tete : jour de la semaine + date +- [x] Carte slot : nom recette ou texte libre, badge servings +- [x] Slot EMPTY : "+" cliquable +- [x] Slot disabled : grise +- [x] Slot locked : icone cadenas +- [x] Slot recette soft-deleted : badge "Deleted recipe", style barre +- [x] Clic carte → modal edition +- [x] Boutons creer/supprimer plan, settings (MODERATOR) + +--- + +## Phase 8 — Frontend : edition de slot + +- [x] Modal d'edition au clic sur un slot (`SlotEditModal.tsx`) +- [x] Recherche de recettes (autocomplete communaute) +- [x] Mode texte libre : champ freeText + champ commentaire +- [x] Mode EMPTY : onglet reset +- [x] Toggle disabled/enabled +- [x] Toggle locked/unlocked (MODERATOR) +- [x] Edition servings dans le modal + +--- + +## Phase 9 — Frontend : drag & drop (swap) + +- [x] Utilisation HTML5 native drag & drop +- [x] Implementer le drag & drop entre slots (swap du contenu) +- [x] Feedback visuel pendant le drag (opacity) +- [x] Appel API swap au drop +- [x] Gestion optimiste via state update + +--- + +## Phase 10 — Frontend : vue mobile + +- [x] Layout mobile : cartes-jours empilees verticalement (via `useIsMobile`) +- [x] En-tete carte : jour de la semaine + date +- [x] Chaque carte-jour contient 2 sous-cartes (midi / soir) +- [x] Slots disabled grises +- [x] Scroll vertical natif +- [x] Meme fonctionnalites que desktop (edition, drag & drop) +- [x] Breakpoint responsive automatique + +--- + +## Phase 11 — Frontend : archives + +- [x] Onglet "Archives" dans la page planning +- [x] Liste des anciens plannings (dates, nb slots remplis) +- [x] Clic → vue read-only du planning archive (meme grille, sans edition) +- [x] Suppression archive (MODERATOR) + +--- + +## Phase 12 — Frontend : liste d'idees + +- [x] Onglet "Ideas" dans la page planning (`MealIdeasPanel.tsx`) +- [x] Liste paginee avec recherche +- [x] Formulaire creation/edition d'idee (nom, commentaire) +- [x] Bouton supprimer +- [x] Lien vers la recette si recipeId present + +--- + +## Phase 13 — Mise a jour docs & contexte + +- [x] Mettre a jour `.claude/context/DB_MODELS.md` (nouveaux modeles + enums) +- [x] Mettre a jour `.claude/context/API_MAP.md` (nouveaux endpoints) +- [x] Mettre a jour `.claude/context/PROGRESS.md` +- [x] Mettre a jour `.claude/CLAUDE.md` (table features) +- [x] Mettre a jour `.claude/context/FILE_MAP.md` si necessaire +- [x] Mettre a jour `.claude/context/TESTS.md` diff --git a/docs/features/meal-plan/ROADMAP_GENERATION.md b/docs/features/meal-plan/ROADMAP_GENERATION.md new file mode 100644 index 0000000..52cdbfb --- /dev/null +++ b/docs/features/meal-plan/ROADMAP_GENERATION.md @@ -0,0 +1,169 @@ +# Roadmap : Meal Plan Generation (Feature 2 — Generation Automatique) + +Spec : `docs/features/meal-plan/SPEC_MEAL_GENERATION.md` +Prerequis : Feature 1 (Planning Manuel) completement implementee. + +--- + +## Phase 1 — Modele de donnees & migration + +- [x] Creer le modele `MealGenerationParams` dans `schema.prisma` +- [x] Creer le modele `MealSlotExclusion` (pivot, cascade) +- [x] Creer le modele `MealGenerationRule` (tagId XOR recipeId, frequencyMin/Max, tagCooldownDays) +- [x] Creer le modele `MealSlotPin` (pivot, cascade, unique par slot par jeu) +- [x] Ajouter `locked Boolean @default(false)` sur `MealSlot` (Feature 1 migration) — deja present +- [x] Ajouter les relations dans `Community`, `Tag`, `Recipe` +- [x] Generer et appliquer la migration Prisma +- [x] Seed de test : creer un jeu "Standard" avec regles, exclusions et pins pour la communaute de test +- [x] Verifier que le seed passe sans erreur (idempotent) + +--- + +## Phase 2 — Backend API Params CRUD + +- [x] Creer `controllers/mealGenerationParams.ts` +- [x] Creer `routes/mealGenerationParams.ts` +- [x] `GET /api/communities/:communityId/meal-generation-params` — liste (memberOf) +- [x] `POST /api/communities/:communityId/meal-generation-params` — creer (MODERATOR) +- [x] `GET /api/communities/:communityId/meal-generation-params/:paramsId` — detail + exclusions + regles + pins (memberOf) +- [x] `PATCH /api/communities/:communityId/meal-generation-params/:paramsId` — modifier (MODERATOR) +- [x] `DELETE /api/communities/:communityId/meal-generation-params/:paramsId` — soft delete (MODERATOR) +- [x] Gestion `isDefault` : un seul par communaute, desactiver l'ancien quand un nouveau est set +- [x] Validation : name max 100, description max 500, cooldownDays >= 0 +- [x] Codes erreur MEAL_GEN_001, MEAL_GEN_005 +- [x] Brancher les routes (avec requireFeature MEAL_PLAN) +- [x] Tests unitaires CRUD params (26 tests) + +--- + +## Phase 3 — Backend API Exclusions, Rules & Pins + +- [x] `PUT .../exclusions` — set complet (MODERATOR) +- [x] `GET .../rules` — liste (memberOf) +- [x] `POST .../rules` — ajouter (MODERATOR) +- [x] `PATCH .../rules/:ruleId` — modifier (MODERATOR) +- [x] `DELETE .../rules/:ruleId` — supprimer (MODERATOR, hard delete) +- [x] `PUT .../pins` — set complet des pins (MODERATOR) +- [x] Validation rules : tagId XOR recipeId, weight 0.0–2.0, frequencyMin <= frequencyMax +- [x] Validation rules : frequencyMin/Max et tagCooldownDays uniquement si tagId (pas recipeId) +- [x] Validation pins : slot ne peut pas etre exclu ET epingle +- [x] Codes erreur MEAL_GEN_003, MEAL_GEN_004, MEAL_GEN_006, MEAL_GEN_009-012 +- [x] Tests unitaires exclusions (5 tests) +- [x] Tests unitaires rules CRUD + toutes validations (22 tests) +- [x] Tests unitaires pins CRUD + validations (7 tests) + integration (1 test) + +--- + +## Phase 4 — Algorithme de generation (passe principale) + +- [x] Creer `services/mealGeneration.ts` (logique metier isolee) +- [x] Construire le pool de recettes (communaute + idees si useIdeas) +- [x] Skip : slots exclus, verrouilles, deja remplis (si fillEmptyOnly) +- [x] Appliquer le pin (filtrer par tag epingle) +- [x] Filtrage par mealTimeConstraint +- [x] Exclusion par cooldown recette (global + cross-planning) +- [x] Exclusion par cooldown tag (tagCooldownDays par regle + cross-planning) +- [x] Exclusion par frequencyMax (compteur par tag, PER_WEEK/PER_PLANNING) +- [x] Calcul des poids (base x regles tag x regles recette) +- [x] Tirage aleatoire pondere +- [x] Gestion MealIdea sans recipeId → slot FREE_TEXT +- [x] Gestion pool insuffisant → slot EMPTY + warning +- [x] Tests unitaires : poids, cooldown recette, cooldown tag, frequencyMax, pin, locked, fillEmptyOnly, pool vide (25 tests) + +--- + +## Phase 5 — Algorithme de generation (passe de rattrapage + rapport) + +- [x] Passe de rattrapage frequencyMin : identifier deficits, remplacer slots les moins prioritaires +- [x] Gestion conflits de contraintes (voir spec section 3.9) +- [x] Construction du rapport de generation (slotsGenerated, slotsSkipped, warnings) +- [x] Types de warning : POOL_EXHAUSTED, FREQUENCY_MIN_NOT_MET, FREQUENCY_MAX_EXCEEDED, CONFLICTING_CONSTRAINTS +- [x] Tests unitaires : frequencyMin, exact (min == max), rattrapage, conflits, rapport complet (6 tests) + +--- + +## Phase 6 — Backend API Generate & Replace + +- [x] `POST /api/communities/:communityId/meal-plan/generate` (MODERATOR) + - [x] Validation : paramsId requis et valide, plan doit exister + - [x] Mode `fillEmptyOnly` + respect des slots verrouilles + - [x] Reponse : plan complet + rapport de generation +- [x] `POST /api/communities/:communityId/meal-plan/slots/:slotId/replace` (MODERATOR) + - [x] Validation : slot non verrouille, paramsId requis + - [x] Re-roll en excluant la recette actuelle + - [x] Codes erreur MEAL_GEN_002, MEAL_GEN_007, MEAL_GEN_008 +- [x] Fix `hasDefaultGenerationParams` dans GET /meal-plan (spec 2.5) +- [x] Tests integration generate (full + fillEmptyOnly + locked + exclusions + pin + pool vide) — 9 tests +- [x] Tests integration replace (re-roll, slot verrouille, slot exclu, permissions, params invalides) — 5 tests +- [x] Tests integration hasDefaultGenerationParams flag — 3 tests + +--- + +## Phase 7 — Frontend : verrouillage de slots + +- [x] Ajouter icone cadenas sur chaque carte de slot +- [x] Toggle verrouille/deverrouille au clic (moderateur : bouton direct sur la carte) +- [x] Style visuel distinct pour les slots verrouilles (ring warning + fond teinte) +- [x] Masquer le bouton "Remplacer" sur les slots verrouilles (done in Phase 10) +- [x] API call PATCH slot avec `{ locked: true/false }` + +--- + +## Phase 8 — Frontend : page parametres de generation + +- [x] Section/onglet "Parametres de generation" dans la page planning (onglet "Generation") +- [x] Liste des jeux de params avec badge "par defaut" +- [x] Formulaire creation/edition : nom, description, cooldownDays, useIdeas, isDefault +- [x] Grille d'exclusion : 7x2 checkboxes +- [x] Grille d'epinglage : 7x2 selects tag (autocomplete), incompatible avec exclusion visuelle +- [x] Bouton supprimer avec confirmation (dupliquer reporte — pas d'endpoint backend) + +--- + +## Phase 9 — Frontend : edition des regles + +- [x] Section regles dans le detail d'un jeu de params +- [x] Regles par tag : + - [x] Autocomplete tag communaute + - [x] Jauge poids 0–200% (slider) + - [x] Select contrainte LUNCH/DINNER + - [x] Toggle frequence : Aucune / Exact / Plage (min/max) + - [x] Champ cooldown tag (jours, optionnel) +- [x] Regles par recette : + - [x] Autocomplete recette communaute + - [x] Jauge poids 0–200% + - [x] Select contrainte LUNCH/DINNER +- [x] CRUD regles inline + +--- + +## Phase 10 — Frontend : generation + rapport + +- [x] Bouton "Generer le planning" (MODERATOR) +- [x] Selecteur jeu de params (pre-selectionne sur isDefault) +- [x] Toggle fillEmptyOnly +- [x] Modal confirmation si ecrasement de slots non verrouilles +- [x] Affichage rapport post-generation : slots generes, skips, warnings +- [x] Warning visuel clair pour frequencyMin non atteint, pool epuise, etc. +- [x] Bouton "Remplacer" sur chaque carte (masque si locked ou pas de jeu par defaut) +- [x] Modal confirmation au clic sur "Remplacer" + +--- + +## Phase 11 — Frontend mobile + +- [x] Adaptation responsive des pages params/regles/pins +- [x] Bouton generer et remplacer fonctionnels sur mobile +- [x] Grilles exclusion/pins adaptees mobile (layout vertical par jour) +- [x] Icone cadenas fonctionnel sur mobile + +--- + +## Phase 12 — Mise a jour docs & contexte + +- [x] Mettre a jour `.claude/context/DB_MODELS.md` (nouveaux modeles) — deja a jour +- [x] Mettre a jour `.claude/context/API_MAP.md` (nouveaux endpoints) — deja a jour +- [x] Mettre a jour `.claude/context/PROGRESS.md` +- [x] Mettre a jour `.claude/context/FILE_MAP.md` (nouveaux composants frontend) +- [x] Mettre a jour Readme (ajout features meal plan + generation) +- [x] Rediger un protocole MANUAL test complet (`docs/features/meal-plan/MANUAL_TEST.md`) diff --git a/docs/features/meal-plan/SPEC_MEAL_GENERATION.md b/docs/features/meal-plan/SPEC_MEAL_GENERATION.md new file mode 100644 index 0000000..0322113 --- /dev/null +++ b/docs/features/meal-plan/SPEC_MEAL_GENERATION.md @@ -0,0 +1,606 @@ +# Spec : Meal Plan Generation (Feature 2 — Generation Automatique) + +## Vue d'ensemble + +Systeme de generation automatique du planning de repas, extremement modulable et personnalisable. Chaque communaute peut avoir plusieurs jeux de parametres (par saison, regime, etc.). Un jeu definit : + +- Des regles de poids sur tags et recettes (favoriser/defavoriser/exclure) +- Des contraintes de frequence par tag (min/max/exact, par planning ou par semaine) +- Un cooldown global par recette (pas la meme recette trop souvent) +- Un cooldown par tag (pas le meme type de cuisine trop souvent) +- Des exclusions de slots (jours ou on ne mange pas ensemble) +- Des epinglages de tag sur un slot (vendredi soir = poisson) +- L'inclusion optionnelle du pool d'idees +- Le respect des slots verrouilles manuellement + +L'objectif : generer une liste de menus variee pour la periode du planning (duree libre, pas forcement 7 jours), en suivant des regles precises, sans produire la meme liste chaque fois. + +> **Note** : les plannings utilisent des dates reelles (Feature 1). Les exclusions et pins utilisent `DayOfWeek` car ce sont des **patterns recurrents** ("chaque mercredi midi", "chaque vendredi soir"). Au moment de la generation, ces patterns sont mappes aux dates reelles du planning actif. Les contraintes de frequence s'appliquent sur l'ensemble du planning, quelle que soit sa duree. + +**Prerequis** : Feature 1 (Meal Plan Manuel) doit etre implementee. Meme feature flag `MEAL_PLAN`. + +### Nouvel enum + +```prisma +enum FrequencyPer { + PER_WEEK // contrainte par tranche de 7 jours (defaut) + PER_PLANNING // contrainte sur l'ensemble du planning +} +``` + +--- + +## 1. Modele de donnees + +### 1.1 MealGenerationParams — N par communaute + +```prisma +model MealGenerationParams { + id String @id @default(uuid()) + communityId String + name String // max 100 chars, ex: "Ete", "Standard", "Regime leger" + description String? // max 500 chars + cooldownDays Int @default(3) // jours min avant qu'une recette revienne + useIdeas Boolean @default(true) // inclure MealIdea dans la generation + isDefault Boolean @default(false) // un seul par communaute + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // soft delete + + community Community @relation(fields: [communityId], references: [id]) + exclusions MealSlotExclusion[] + rules MealGenerationRule[] + slotPins MealSlotPin[] + + @@index([communityId, deletedAt]) +} +``` + +**Regles** : + +- Soft delete (`deletedAt`) +- `isDefault` : un seul jeu par communaute peut etre `isDefault: true`. Contrainte applicative — quand on set un jeu en default, l'ancien est automatiquement desactive. Un jeu soft-deleted avec `isDefault: true` ne compte pas : `hasDefaultGenerationParams` retourne `false` si le seul jeu isDefault est soft-deleted +- `cooldownDays` : nombre minimum de jours entre deux apparitions d'une meme recette dans le planning. Min 0 (pas de cooldown), defaut 3 +- `useIdeas` : si true, les MealIdea (Feature 1) sont incluses dans le pool de generation + +### 1.2 MealSlotExclusion — Slots a ne pas generer (pivot) + +```prisma +model MealSlotExclusion { + id String @id @default(uuid()) + paramsId String + day DayOfWeek + mealTime MealTime + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + + @@unique([paramsId, day, mealTime]) +} +``` + +**Regles** : + +- Hard delete en cascade quand le jeu de params est supprime +- Contrainte unique : une seule exclusion par jour+repas par jeu +- Exemple : exclure `TUE/DINNER` = la communaute ne mange jamais ensemble le mardi soir + +### 1.3 MealGenerationRule — Regles de poids et contraintes (N par jeu) + +```prisma +model MealGenerationRule { + id String @id @default(uuid()) + paramsId String + + // Cible : tag OU recette (jamais les deux) + tagId String? + recipeId String? + + // Poids (tous types de regles) + weight Float @default(1.0) // 0.0-2.0 + mealTimeConstraint MealTime? // null = les deux, LUNCH/DINNER = un seul + + // Contraintes de frequence (tag rules uniquement) + frequencyMin Int? // min occurrences (null = pas de min) + frequencyMax Int? // max occurrences (null = pas de max) + frequencyPer FrequencyPer? // PER_WEEK | PER_PLANNING — defaut PER_WEEK si frequencyMin ou Max set + + // Cooldown par tag (tag rules uniquement) + tagCooldownDays Int? // jours min entre 2 recettes du meme tag (null = pas de cooldown tag) + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull) + recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: SetNull) + + @@index([paramsId]) +} +``` + +**Regles** : + +- Hard delete en cascade quand le jeu de params est supprime (paramsId) +- Si le tag est supprime → `tagId` passe a null (SetNull). La regle devient orpheline et est ignoree silencieusement a la generation +- Si la recette est soft-deleted → la regle reste (recipeId intact). Si la recette est hard-deleted → `recipeId` passe a null (SetNull). Meme comportement : regle ignoree +- **Un `tagId` OU un `recipeId`, jamais les deux** sur la meme regle. Contrainte applicative +- `weight` : Float entre 0.0 et 2.0 + - `0.0` = completement exclu du tirage + - `0.01–0.99` = defavorise (moins de chances) + - `1.0` = neutre (comportement par defaut) + - `1.01–2.0` = favorise (plus de chances) + - **Frontend** : jauge 0–200% (0.0 → 0%, 1.0 → 100%, 2.0 → 200%) +- `mealTimeConstraint` : restreint l'application de la regle a un type de repas. `null` = les deux +- **`frequencyMin` / `frequencyMax` / `frequencyPer`** (tag rules uniquement, ignore si recipeId) : + - `frequencyPer: PER_WEEK` (defaut) : la contrainte s'applique par tranche de 7 jours a partir du `startDate`. Planning de 14 jours = 2 tranches. Planning de 10 jours = tranche 1 (j1→j7) + tranche 2 incomplete (j8→j10). Les memes limites s'appliquent a la tranche incomplete (pas de calcul proportionnel — plus simple et intuitif) + - `frequencyPer: PER_PLANNING` : la contrainte s'applique sur l'ensemble du planning, quelle que soit la duree + - `frequencyMin = 2, frequencyMax = 2` → mode exact (exactement 2 par tranche/planning) + - `frequencyMin = null, frequencyMax = 3` → max 3 par tranche/planning + - `frequencyMin = 2, frequencyMax = null` → min 2 par tranche/planning + - `frequencyMin = 2, frequencyMax = 5` → entre 2 et 5 par tranche/planning + - Validation : si les deux sont set, `frequencyMin <= frequencyMax` + - `frequencyPer` est ignore si ni `frequencyMin` ni `frequencyMax` ne sont set +- **`tagCooldownDays`** (tag rules uniquement, ignore si recipeId) : + - Jours minimum entre deux recettes portant le meme tag + - Exemple : tag "pates", `tagCooldownDays = 2` → pas de pates deux jours de suite + - Distinct du cooldown global (qui est par recette). Ici c'est par categorie/tag + +### 1.4 MealSlotPin — Epinglage de tag sur un slot (N par jeu) + +```prisma +model MealSlotPin { + id String @id @default(uuid()) + paramsId String + day DayOfWeek + mealTime MealTime + tagId String + + params MealGenerationParams @relation(fields: [paramsId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([paramsId, day, mealTime]) +} +``` + +**Regles** : + +- Hard delete en cascade (paramsId) +- Si le tag epingle est supprime → Cascade sur le pin (le pin disparait, le slot n'est plus epingle) +- Contrainte unique : un seul pin par slot par jeu (un slot ne peut etre epingle qu'a un tag) +- Exemple : `FRI/DINNER` + tag "poisson" → le vendredi soir, le generateur ne pioche que dans les recettes taguees "poisson" +- Un slot ne peut pas etre a la fois exclu et epingle (validation applicative) +- Interagit avec les regles de poids : les poids s'appliquent normalement a l'interieur du pool filtre par le tag epingle + +### 1.5 Modification de MealSlot (Feature 1) — ajout `locked` + +```prisma +// Ajout au modele MealSlot existant (Feature 1) +model MealSlot { + // ... champs existants ... + locked Boolean @default(false) // verrouille = la generation ne touche pas ce slot +} +``` + +**Regles** : + +- Un slot verrouille est ignore par la generation, quel que soit le mode (`fillEmptyOnly` ou non) +- Permet de proteger des choix manuels pendant une regeneration +- Le verrouillage est independant du type de slot (meme un slot EMPTY peut etre verrouille = "pas de repas ici cette semaine") +- Toggle via `PATCH /meal-plan/slots/:slotId` avec `{ "locked": true/false }` +- Frontend : icone cadenas visible sur les slots verrouilles + +--- + +## 2. API + +### 2.1 Generation Params (nested sous /api/communities/:communityId) + +``` +GET /meal-generation-params # liste des jeux (memberOf) +POST /meal-generation-params # creer un jeu (MODERATOR) +GET /meal-generation-params/:paramsId # detail avec exclusions + regles + pins (memberOf) +PATCH /meal-generation-params/:paramsId # modifier (MODERATOR) +DELETE /meal-generation-params/:paramsId # soft delete (MODERATOR) +POST /meal-generation-params/:paramsId/duplicate # dupliquer un jeu (MODERATOR) +``` + +### 2.2 Exclusions (nested sous params) + +``` +PUT /meal-generation-params/:paramsId/exclusions # set complet des exclusions (MODERATOR) +``` + +Body : tableau de `{ day, mealTime }`. Remplace toutes les exclusions existantes (delete + re-create en transaction). + +### 2.3 Rules (nested sous params) + +``` +GET /meal-generation-params/:paramsId/rules # liste des regles (memberOf) +POST /meal-generation-params/:paramsId/rules # ajouter une regle (MODERATOR) +PATCH /meal-generation-params/:paramsId/rules/:ruleId # modifier (MODERATOR) +DELETE /meal-generation-params/:paramsId/rules/:ruleId # supprimer (MODERATOR, hard delete) +``` + +### 2.4 Slot Pins (nested sous params) + +``` +PUT /meal-generation-params/:paramsId/pins # set complet des pins (MODERATOR) +``` + +Body : tableau de `{ day, mealTime, tagId }`. Remplace tous les pins existants (meme logique que les exclusions). + +**POST /meal-generation-params/:paramsId/duplicate** : + +- Cree un nouveau jeu de params avec le meme nom suffixe " (copie)", memes valeurs (cooldownDays, useIdeas), memes exclusions, regles et pins +- `isDefault` est toujours `false` sur la copie +- Retourne le nouveau jeu complet + +### 2.5 Flag dans GET /meal-plan + +La reponse de `GET /meal-plan` inclut un champ calcule : + +```json +{ "hasDefaultGenerationParams": true } +``` + +Ce flag indique si un jeu de params `isDefault: true` et non soft-deleted existe pour cette communaute. Le frontend l'utilise pour afficher ou masquer le bouton "Remplacer" sur les cartes. + +### 2.6 Generation & Replace + +``` +POST /meal-plan/generate # generer le planning (MODERATOR) +POST /meal-plan/slots/:slotId/replace # re-generer 1 slot (MODERATOR) +``` + +**POST /meal-plan/generate** : + +```json +{ + "paramsId": "uuid", + "fillEmptyOnly": false +} +``` + +- `paramsId` : jeu de params a utiliser +- `fillEmptyOnly` : si `true`, ne remplit que les slots `EMPTY` (et non verrouilles). Si `false`, regenere tous les slots non-exclus et non-verrouilles + +**POST /meal-plan/slots/:slotId/replace** : + +```json +{ + "paramsId": "uuid" +} +``` + +- Re-genere un seul slot en utilisant le meme algorithme, en excluant la recette actuelle +- Refuse si le slot est verrouille (`MEAL_GEN_008`) +- Si le slot est `disabled` → le replace l'active automatiquement (meme comportement que l'edition manuelle — mettre un contenu reactive le slot) +- Le `paramsId` est requis pour savoir quelles regles appliquer +- Ignore `fillEmptyOnly` (action ciblee sur un slot specifique) + +--- + +## 3. Algorithme de generation + +### 3.1 Vue d'ensemble + +L'algorithme procede en 3 passes : + +1. **Passe principale** : remplir les slots en respectant les contraintes hard (exclusions, pins, cooldown, frequencyMax, tagCooldown, verrouillage) +2. **Passe de rattrapage** : satisfaire les contraintes frequencyMin non atteintes +3. **Rapport** : lister les warnings (slots non remplis, contraintes non satisfaites) + +### 3.2 Passe principale — Etapes par slot + +Ordre de traitement : premier jour midi → premier jour soir → deuxieme jour midi → ... → dernier jour soir (ordre chronologique des dates reelles du planning). + +Pour chaque slot : + +1. **Skip** si le slot est `disabled` (desactive au niveau du plan) +2. **Skip** si le slot est exclu (MealSlotExclusion — matcher le `DayOfWeek` de la date du slot) +3. **Skip** si le slot est verrouille (`locked = true`) +4. **Skip** si `fillEmptyOnly = true` et le slot n'est pas EMPTY +5. **Construire le pool de recettes eligibles** : + a. Toutes les recettes de la communaute (non soft-deleted) + b. Si `useIdeas: true` : ajouter les MealIdea (non soft-deleted) au pool +6. **Appliquer le pin** (si un MealSlotPin existe pour ce slot) : + - Filtrer le pool : ne garder que les recettes qui portent le tag epingle +7. **Filtrer par mealTimeConstraint** : + - Pour chaque regle avec `mealTimeConstraint` set : si le slot ne matche pas, la regle ne s'applique pas a ce slot (pas d'exclusion, juste ignore) + - Pour chaque regle avec `weight = 0.0` et `mealTimeConstraint` matchant ce slot : exclure les recettes ciblees +8. **Exclure par cooldown recette** (global) : + - Exclure les recettes deja planifiees dans les `cooldownDays` precedents (slots deja remplis dans cette generation) +9. **Exclure par cooldown tag** : + - Pour chaque regle tag avec `tagCooldownDays` set : exclure les recettes portant ce tag si une recette avec le meme tag a ete planifiee dans les N jours precedents +10. **Exclure par frequencyMax** : + - Pour chaque regle tag avec `frequencyMax` set : compter les slots deja remplis (dans cette generation) avec une recette portant ce tag. Si le max est atteint → exclure toutes les recettes portant ce tag +11. **Calculer le poids final** de chaque recette restante (voir 3.3) +12. **Tirage aleatoire pondere** +13. **Ecrire le slot** : `type: RECIPE` ou `type: FREE_TEXT` (si MealIdea sans recipeId) + +### 3.3 Calcul du poids final + +Pour une recette donnee dans un slot donne : + +1. Poids de base = `1.0` +2. Pour chaque regle tag applicable (la recette possede le tag ET mealTimeConstraint matche le slot) : `poids *= rule.weight` +3. Pour chaque regle recette applicable (match direct ET mealTimeConstraint matche le slot) : `poids *= rule.weight` +4. Si poids final = `0.0` → recette exclue +5. Sinon → poids final utilise pour le tirage pondere + +**Exemple** : + +- Recette "Ratatouille" a les tags `vegetarien` et `ete` +- Regle tag `vegetarien` : weight 1.5 (150%) +- Regle tag `ete` : weight 1.8 (180%) +- Poids final = 1.0 x 1.5 x 1.8 = 2.7 (tres favorisee) + +### 3.4 Passe de rattrapage — frequencyMin + +Apres la passe principale, verifier les contraintes `frequencyMin` : + +- Si `frequencyPer: PER_PLANNING` : verifier le compteur global sur l'ensemble du planning +- Si `frequencyPer: PER_WEEK` : verifier chaque tranche de 7 jours separement. Un deficit dans la tranche 2 ne peut pas etre comble par un exces dans la tranche 1 + +Pour chaque tranche (ou pour le planning entier si PER_PLANNING) : + +1. Pour chaque regle tag avec `frequencyMin` set : + - Compter les slots remplis avec une recette portant ce tag dans la tranche/planning + - Si le compteur < frequencyMin → **deficit a combler dans cette tranche** +2. Pour chaque deficit : + a. Identifier les slots remplacables : slots non-verrouilles, non-exclus, non-epingles, qui n'ont PAS de recette avec ce tag + b. Trier ces slots par "poids de la recette actuelle" (ascendant) → remplacer en priorite les choix les moins "importants" + c. Pour chaque slot a remplacer : tirer une recette portant le tag manquant (en respectant cooldown et les autres contraintes) + d. Si impossible (pas assez de recettes avec ce tag, ou pas assez de slots remplacables) → warning dans le rapport + +**Ordre de priorite des contraintes** (en cas de conflit) : + +1. Verrouillage (absolu, jamais outrepasse) +2. Exclusion de slot (absolu) +3. Pin de slot (absolu) +4. frequencyMax (hard — jamais depasse) +5. Cooldown recette (hard) +6. Cooldown tag (hard) +7. frequencyMin (best effort — rattrapage, mais peut echouer) +8. Poids (soft — influence probabiliste) + +### 3.5 Cooldown recette (global, cross-planning) + +Le cooldown s'applique en deux phases : + +1. **Intra-generation** : les recettes deja tirees pendant cette generation sont exclues pour les slots suivants dans la fenetre du cooldown +2. **Cross-planning** : au demarrage de la generation, les slots remplis du **plan archive precedent** (le plus recent) sont egalement pris en compte. Si "Ratatouille" etait dimanche soir dans l'ancien planning et `cooldownDays = 3`, elle est exclue de lundi, mardi et mercredi du nouveau planning + +**Calcul de la distance** : on compare les dates reelles. Si l'ancien planning se terminait le 23/03 et le nouveau commence le 24/03, la distance est de 1 jour (gap respecte, le cooldown s'applique bien). + +Si aucun plan archive n'existe (premier planning de la communaute) → uniquement l'intra-generation. + +Exemple intra-generation : si lundi midi a recu "Ratatouille" et `cooldownDays = 3`, alors "Ratatouille" est exclue des tirages pour mardi, mercredi et jeudi (midi et soir). + +### 3.6 Cooldown tag (cross-planning) + +Distinct du cooldown recette. Fonctionne par tag et non par recette individuelle. Applique egalement le principe cross-planning : les slots du plan archive precedent sont pris en compte pour calculer si le cooldown tag est respecte sur les premiers jours du nouveau planning. + +Exemple : tag "pates", `tagCooldownDays = 2`. Si lundi midi a recu "Carbonara" (taguee "pates"), alors AUCUNE recette taguee "pates" ne sera tiree pour lundi soir et mardi midi (2 slots = ~1 jour de distance). + +Le cooldown tag se mesure en **jours** (comme le cooldown recette) : chaque jour contient 2 slots. Un `tagCooldownDays = 1` signifie que le tag ne peut pas apparaitre le meme jour une 2e fois NI le lendemain. + +### 3.7 Gestion des MealIdea + +Si `useIdeas: true` : + +- Les idees **avec** `recipeId` : traitees comme la recette liee (memes regles tag/recette s'appliquent) +- Les idees **sans** `recipeId` : poids de base `1.0`, aucune regle tag/recette ne s'applique (pas de tags). Non affectees par frequencyMin/Max ni tagCooldown. Si tiree, le slot passe en `type: FREE_TEXT` avec `freeText = idea.name` et `comment = idea.comment` + +### 3.8 Pool insuffisant + +Si le pool de recettes eligibles est vide pour un slot (tout exclu par cooldown/regles/contraintes) : + +- Le slot reste `EMPTY` +- Warning dans la reponse avec le slot concerne et les raisons (quelles contraintes ont elimine le pool) + +### 3.9 Conflits de contraintes + +Cas ou les contraintes sont impossibles a satisfaire simultanement : + +| Conflit | Comportement | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| frequencyMin tag A = 10 mais seulement 8 slots non-exclus | Remplir au max possible, warning "min non atteint" | +| frequencyMin tag A + frequencyMin tag B > slots disponibles (tags mutuellement exclusifs) | Satisfaire dans l'ordre de declaration des regles, warning pour le reste | +| frequencyMax tag A = 2 et slot epingle sur tag A pour 3 slots | Les pins sont absolus, frequencyMax est depasse, warning | +| tagCooldown empecherait de satisfaire frequencyMin | Cooldown prioritaire, frequencyMin en best effort, warning | + +Le systeme ne refuse JAMAIS de generer. Il fait au mieux et rapporte les ecarts. + +--- + +## 4. Permissions + +| Action | Droit requis | +| --------------------------------------------- | ----------------------------------------------------- | +| Voir les jeux de params, regles, pins | Membre de la communaute | +| Creer / modifier / supprimer un jeu de params | MODERATOR | +| Gerer les exclusions, regles et pins | MODERATOR | +| Lancer une generation | MODERATOR | +| Remplacer un slot (re-roll) | MODERATOR | +| Verrouiller/deverrouiller un slot | Meme permission que modifier un slot (voir Feature 1) | + +--- + +## 5. Codes erreur + +| Code | Message | +| ------------ | --------------------------------------------------------- | +| MEAL_GEN_001 | Generation params not found | +| MEAL_GEN_002 | No meal plan exists (creer le plan d'abord via Feature 1) | +| MEAL_GEN_003 | Invalid rule: must have tagId OR recipeId, not both | +| MEAL_GEN_004 | Weight must be between 0.0 and 2.0 | +| MEAL_GEN_005 | Cannot have multiple default params for same community | +| MEAL_GEN_006 | Rule not found | +| MEAL_GEN_007 | Slot is excluded in this params set | +| MEAL_GEN_008 | Slot is locked | +| MEAL_GEN_009 | Frequency constraints only apply to tag rules | +| MEAL_GEN_010 | frequencyMin must be <= frequencyMax | +| MEAL_GEN_011 | tagCooldownDays only applies to tag rules | +| MEAL_GEN_012 | Slot cannot be both excluded and pinned | +| MEAL_GEN_013 | Cannot generate on an archived plan | + +--- + +## 6. UX Frontend + +### 6.1 Page parametres de generation + +- Accessible depuis la page planning (onglet ou section dediee) +- Liste des jeux de params avec badge "par defaut" sur le jeu actif +- CRUD complet : creer, editer, dupliquer, supprimer +- Pour chaque jeu : grille d'exclusion (7x2 checkboxes pour cocher les slots a exclure) + +### 6.2 Edition des regles + +- Section dans le detail d'un jeu de params +- Deux types de regles affichees separement : + +**Regles par tag** : + +- Autocomplete tag communaute +- Jauge poids 0–200% (slider visuel avec paliers : 0% exclu, 100% neutre, 200% double) +- Contrainte LUNCH/DINNER (select optionnel) +- Frequence : toggle "Pas de contrainte / Exact / Plage" + - Exact → 1 champ nombre + - Plage → 2 champs min/max (chacun optionnel) + - Select `frequencyPer` : "Par semaine" (defaut) / "Par planning entier" +- Cooldown tag : champ nombre optionnel (jours) + +**Regles par recette** : + +- Autocomplete recette communaute +- Jauge poids 0–200% +- Contrainte LUNCH/DINNER + +### 6.3 Epinglage de tags sur des slots + +- Grille 7x2 (meme layout que les exclusions) +- Chaque case : select tag optionnel (autocomplete) +- Un slot ne peut pas etre epingle ET exclu (validation visuelle immediate) +- Exemple : case "VEN/DINNER" → select "poisson" = vendredi soir, uniquement des recettes poisson + +### 6.4 Generation + +- Bouton "Generer le planning" sur la page planning (visible MODERATOR uniquement) +- Selecteur du jeu de params (pre-selectionne sur le jeu `isDefault`) +- Toggle `fillEmptyOnly` : "Remplir uniquement les slots vides" (checkbox) +- Si `fillEmptyOnly: false` et des slots non-vides et non-verrouilles existent → modal de confirmation "Les slots non verrouilles seront regeneres" +- Apres generation : affichage du resultat avec warnings si contraintes non satisfaites + +### 6.5 Bouton "Remplacer" sur chaque carte + +- Visible uniquement si un jeu de params par defaut existe +- Masque sur les slots verrouilles +- Icone refresh sur la carte du slot +- Au clic → modal de confirmation "Remplacer ce repas par une autre suggestion ?" +- Utilise le jeu de params par defaut pour le re-roll + +### 6.6 Verrouillage de slots + +- Icone cadenas sur chaque carte de slot +- Clic pour toggle verrouille/deverrouille +- Slot verrouille : cadenas ferme, style visuel distinct (bordure, opacite differente) +- Le bouton "Remplacer" disparait quand le slot est verrouille +- Meme permission que l'edition de slot (Feature 1) + +--- + +## 7. Rapport de generation + +La reponse de `POST /meal-plan/generate` inclut : + +```json +{ + "plan": { ... }, + "report": { + "slotsGenerated": 10, + "slotsSkipped": { + "excluded": 2, + "locked": 1, + "alreadyFilled": 0 + }, + "slotsEmpty": 1, + "warnings": [ + { + "type": "POOL_EXHAUSTED", + "slotDay": "THU", + "slotMealTime": "DINNER", + "reason": "All recipes excluded by cooldown and frequency constraints" + }, + { + "type": "FREQUENCY_MIN_NOT_MET", + "tagId": "uuid", + "tagName": "vegetarien", + "required": 3, + "actual": 2, + "reason": "Not enough eligible recipes with this tag" + } + ] + } +} +``` + +Types de warning : + +- `POOL_EXHAUSTED` : aucune recette eligible pour un slot +- `FREQUENCY_MIN_NOT_MET` : frequencyMin non atteint (meme apres rattrapage) +- `FREQUENCY_MAX_EXCEEDED` : frequencyMax depasse a cause d'un pin (cas rare) +- `CONFLICTING_CONSTRAINTS` : deux contraintes se contredisent + +--- + +## 8. Seed & Tests + +- Les jeux de params sont propres a chaque communaute, pas de seed global necessaire +- **Seed de test** : creer un jeu de params "Standard" avec quelques regles, exclusions et pins pour la communaute de test +- Tests unitaires algorithme : + - Poids simples (tag + recette) + - Cooldown recette + - Cooldown tag + - frequencyMax (respect strict) + - frequencyMin (passe de rattrapage) + - Exact (min == max) + - Pin de slot + - Verrouillage + - fillEmptyOnly + - Pool insuffisant + - Conflits de contraintes (warnings) +- Tests integration : endpoints CRUD params/rules/exclusions/pins + generate + replace + +--- + +## 9. Relations avec Feature 1 + +| Element Feature 1 | Utilisation Feature 2 | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `MealPlan` + `MealSlot` | La generation ecrit dans les memes slots | +| `MealSlot.locked` | Nouveau champ ajoute pour Feature 2 (mais utilisable manuellement des Feature 1) | +| `MealIdea` | Incluse dans le pool si `useIdeas: true` | +| `MealSlotType` | La generation produit `RECIPE` ou `FREE_TEXT` (idees sans recette) | +| Enums `DayOfWeek` / `MealTime` | Reutilises pour exclusions, pins et contraintes | + +Le champ `locked` sur MealSlot est ajoute des Feature 1 (migration) mais son usage principal est dans Feature 2. En Feature 1, il permet simplement de "proteger" un slot visuellement. + +--- + +## 10. Tableau recapitulatif des leviers de personnalisation + +| Levier | Granularite | Portee | Exemple | +| -------------------------- | ------------------------ | ------------ | ----------------------------------------------------------------------- | +| **Poids (weight)** | Par tag ou par recette | Probabiliste | "Plus de recettes d'ete" (tag ete = 180%) | +| **Exclusion (weight = 0)** | Par tag ou par recette | Hard | "Jamais de fondue en ete" (tag fondue = 0%) | +| **mealTimeConstraint** | Par regle | Hard | "Salades uniquement le midi" (tag salade, LUNCH) | +| **Cooldown recette** | Global (toutes recettes) | Hard | "Pas la meme recette avant 3 jours" | +| **Cooldown tag** | Par tag | Hard | "Pas de pates 2 jours de suite" (tagCooldown = 2) | +| **Frequence max** | Par tag | Hard | "Max 3 repas carnes par semaine" (frequencyMax = 3, PER_WEEK) | +| **Frequence min** | Par tag | Best effort | "Min 2 repas vegetariens par semaine" (frequencyMin = 2, PER_WEEK) | +| **Frequence exacte** | Par tag | Best effort | "Exactement 1 repas poisson par planning" (min = max = 1, PER_PLANNING) | +| **Portee frequence** | Par regle | Mode | PER_WEEK (defaut, scale selon duree) / PER_PLANNING (fixe) | +| **Exclusion de slot** | Par jour+repas | Hard | "Pas de repas mardi soir" | +| **Pin de tag sur slot** | Par jour+repas+tag | Hard | "Vendredi soir = poisson" | +| **Verrouillage** | Par slot | Hard | "Garder mon choix de dimanche midi" | +| **fillEmptyOnly** | Generation entiere | Mode | "Ne remplir que les trous" | +| **useIdeas** | Generation entiere | Pool | "Inclure les idees dans le tirage" | diff --git a/docs/features/meal-plan/SPEC_MEAL_PLAN.md b/docs/features/meal-plan/SPEC_MEAL_PLAN.md new file mode 100644 index 0000000..3dd0bd1 --- /dev/null +++ b/docs/features/meal-plan/SPEC_MEAL_PLAN.md @@ -0,0 +1,419 @@ +# Spec : Meal Plan (Feature 1 — Planning Manuel) + +## Vue d'ensemble + +Gestion manuelle d'un planning de repas par communaute. Chaque planning couvre une periode libre (dates de debut et fin configurables, duree max 31 jours). Un seul planning actif par communaute a la fois, les anciens sont archives et consultables. Les membres peuvent consulter, modifier et reorganiser les repas via drag & drop (swap). Un pool d'idees communautaire permet de stocker des suggestions de repas pour usage futur (et pour la Feature 2 — generation automatique). + +**Feature code** : `MEAL_PLAN` (non attribuee par defaut, activation admin par communaute) + +--- + +## 1. Modele de donnees + +### 1.1 Enums + +```prisma +enum DayOfWeek { + MON + TUE + WED + THU + FRI + SAT + SUN +} + +enum MealTime { + LUNCH + DINNER +} + +enum MealSlotType { + EMPTY + RECIPE + FREE_TEXT +} + +enum MealPlanStatus { + ACTIVE + ARCHIVED +} +``` + +> `DayOfWeek` n'est PAS utilise dans MealSlot (qui utilise des dates reelles). Il est conserve pour Feature 2 (patterns d'exclusion et d'epinglage recurrents dans les jeux de generation). + +### 1.2 MealPlan — N par communaute, 1 ACTIVE a la fois + +```prisma +model MealPlan { + id String @id @default(uuid()) + communityId String + startDate DateTime @db.Date // premier jour du planning + endDate DateTime @db.Date // dernier jour du planning + status MealPlanStatus @default(ACTIVE) + defaultServings Int @default(4) + editableByMembers Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + community Community @relation(fields: [communityId], references: [id]) + slots MealSlot[] + + @@index([communityId, status]) +} +``` + +**Regles** : + +- **1 plan ACTIVE par communaute**. Contrainte purement applicative — le `@@unique([communityId, status])` est supprime du schema Prisma car il interdirait le 2eme archivage. Unicite du ACTIVE garantie par transaction (archivage + creation atomiques) +- `startDate` / `endDate` : dates reelles, `startDate <= endDate`, duree max 31 jours +- `status` : `ACTIVE` = planning courant, `ARCHIVED` = consultation seule +- A la creation d'un nouveau plan, l'ancien ACTIVE passe automatiquement en ARCHIVED dans la meme transaction. Si la creation echoue, l'archivage est annule (rollback) +- Pas de contrainte de continuite : il peut y avoir des gaps entre plannings (ex: vacances) +- **Pas de chevauchement de slots `(date, mealTime)`** : deux plannings d'une meme communaute ne peuvent pas partager le meme slot. Une date peut apparaitre dans deux plannings consecutifs si les mealTimes sont differents (ex: planning 1 se termine le lundi LUNCH, planning 2 commence le lundi DINNER — valide car les slots sont distincts). Validation applicative : verifier l'absence de `(date, mealTime)` en conflit sur TOUS les plans de la communaute (ACTIVE + ARCHIVED) +- `defaultServings` : valeur par defaut utilisee a la creation des slots (min 1, max 100). La modification ulterieure n'affecte PAS les slots existants +- `editableByMembers` : si `false` (defaut), seuls les MODERATOR peuvent modifier les slots. Si `true`, tous les membres peuvent modifier. Bascule via `PATCH /meal-plan` + +### 1.3 MealSlot — N par plan (2 par jour dans la plage) + +```prisma +model MealSlot { + id String @id @default(uuid()) + planId String + date DateTime @db.Date // date reelle (ex: 2026-03-23) + mealTime MealTime + servings Int + type MealSlotType @default(EMPTY) + disabled Boolean @default(false) + locked Boolean @default(false) + recipeId String? + freeText String? // max 255 chars + comment String? // max 500 chars + updatedAt DateTime @updatedAt + updatedById String? // dernier membre ayant modifie ce slot + + plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + recipe Recipe? @relation(fields: [recipeId], references: [id]) + updatedBy User? @relation(fields: [updatedById], references: [id], onDelete: SetNull) + + @@unique([planId, date, mealTime]) + @@index([planId, date]) +} +``` + +**Regles** : + +- Hard delete en cascade quand le plan est supprime +- Contrainte unique `[planId, date, mealTime]` : un seul slot par date+repas +- `date` : date reelle dans la plage `[plan.startDate, plan.endDate]` +- `disabled` : slot grise, pas de repas prevu. Le generateur (Feature 2) le skip. Mais un membre peut manuellement y ajouter un repas → quand on set un type RECIPE ou FREE_TEXT sur un slot disabled, `disabled` repasse a `false` automatiquement +- `locked` : verrouille pour la generation (Feature 2). Utilise des Feature 1 pour l'UX (cadenas visuel). Voir SPEC_MEAL_GENERATION pour le detail +- `recipeId` : FK vers Recipe. Les recettes sont soft-deleted, donc le recipeId reste intact. L'API renvoie un flag `isDeleted` si la recette est soft-deleted. Le frontend affiche "Recette supprimee" en grise +- `type: EMPTY` reset complet : recipeId, freeText et comment sont mis a null +- `updatedById` : mis a jour a chaque modification de contenu (type, recette, texte, servings, disabled, locked). SetNull si l'utilisateur est supprime. Affiche dans le modal de detail du slot ("Modifie par X") + +### 1.4 MealIdea — Pool d'idees communautaire + +```prisma +model MealIdea { + id String @id @default(uuid()) + communityId String + name String // max 255 chars + comment String? // max 500 chars + recipeId String? // null = idee pure, non-null = lien vers recette existante + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + community Community @relation(fields: [communityId], references: [id]) + recipe Recipe? @relation(fields: [recipeId], references: [id]) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([communityId, deletedAt]) +} +``` + +**Regles** : + +- Soft delete (`deletedAt`) +- `createdById` : SetNull si l'utilisateur est supprime +- `recipeId` : optionnel, permet de lier une idee a une recette existante (pour la future Feature 2) +- Cascade hard delete quand la communaute est soft-deleted (meme logique que UserCommunityTagPreference) + +--- + +## 2. Feature flag + +- Code : `MEAL_PLAN` +- `isDefault: false` — doit etre activee par l'admin (SuperAdmin) pour chaque communaute via `POST /api/admin/communities/:communityId/features/:featureId` +- A prevoir dans le seed de test (upsert idempotent) +- Si feature non activee : la section planning n'est pas accessible cote frontend, les endpoints retournent `403` avec code `MEAL_005` + +--- + +## 3. API + +### 3.1 Middleware requis + +Un middleware `requireFeature('MEAL_PLAN')` verifie que la feature est activee pour la communaute. Applique sur tous les endpoints meal-plan et meal-ideas. + +### 3.2 Meal Plan (nested sous /api/communities/:communityId) + +``` +GET /meal-plan # Plan ACTIVE complet + slots + hasDefaultGenerationParams (memberOf). Retourne 200 { plan: null, hasDefaultGenerationParams: false } si aucun plan n'existe +GET /meal-plan/archives # Liste des plans ARCHIVED, pagine (memberOf) +GET /meal-plan/archives/:planId # Detail d'un plan archive + slots (memberOf) +DELETE /meal-plan/archives/:planId # Supprimer une archive (MODERATOR, hard delete) +POST /meal-plan # Creer un plan + slots (MODERATOR) +DELETE /meal-plan # Supprimer le plan ACTIVE + cascade slots (MODERATOR) +PATCH /meal-plan # Update defaultServings et/ou editableByMembers (MODERATOR) +PATCH /meal-plan/slots/:slotId # Update un slot (permission dynamique) +POST /meal-plan/slots/swap # Swap contenu de 2 slots (permission dynamique) +``` + +### 3.3 Meal Ideas (nested sous /api/communities/:communityId) + +``` +GET /meal-ideas # Liste paginee, searchable (memberOf) +POST /meal-ideas # Creer une idee (memberOf) +PATCH /meal-ideas/:ideaId # Modifier (createur ou MODERATOR) +DELETE /meal-ideas/:ideaId # Soft delete (createur ou MODERATOR) +``` + +--- + +## 4. Logique metier + +### 4.1 Creation d'un plan (POST /meal-plan) + +**Body** : + +```json +{ + "startDate": "2026-03-23", + "endDate": "2026-03-30", + "defaultServings": 3, + "disabledSlots": [ + { "date": "2026-03-23", "mealTime": "LUNCH" }, + { "date": "2026-03-25", "mealTime": "LUNCH" }, + { "date": "2026-03-30", "mealTime": "DINNER" } + ], + "copyDisabledFromPrevious": false +} +``` + +Quand un MODERATOR cree le plan : + +1. Valider les dates : `startDate <= endDate`, duree max 31 jours, pas de chevauchement de slots avec un autre plan. Les dates dans le passe sont autorisees (utile pour corriger une erreur ou creer une archive manuelle) +2. Si un plan ACTIVE existe → le passer en ARCHIVED +3. Creer le `MealPlan` avec `status: ACTIVE` +4. Creer les slots pour chaque date dans `[startDate, endDate]` × LUNCH/DINNER, tous `type: EMPTY`, `servings` herite du `defaultServings` +5. Si `disabledSlots` fourni → marquer ces slots comme `disabled: true` +6. Si `copyDisabledFromPrevious: true` → recuperer le pattern de disabled du plan archive le plus recent, mapper les jours de la semaine (ex: si l'ancien avait mercredi midi disabled, le nouveau aussi sur tous ses mercredis midi) +7. Si les deux sont fournis (`disabledSlots` + `copyDisabledFromPrevious: true`) → additivite : l'union des deux ensembles est appliquee +8. Si `copyDisabledFromPrevious: true` et aucune archive n'existe → flag silencieusement ignore, aucune erreur +9. Retourner le plan complet avec tous ses slots + +**Nombre de slots** : `(nombre de jours dans la plage) × 2`. Exemple : du 23 au 30 mars = 8 jours = 16 slots. + +### 4.2 Update d'un slot (PATCH /meal-plan/slots/:slotId) + +**Body possible** : + +```json +{ "type": "RECIPE", "recipeId": "uuid", "comment": "Prevoir sauce a part" } +``` + +```json +{ "type": "FREE_TEXT", "freeText": "Resto japonais", "comment": "Reserver a l'avance" } +``` + +```json +{ "type": "EMPTY" } +``` + +```json +{ "servings": 6 } +``` + +```json +{ "disabled": true } +``` + +```json +{ "locked": true } +``` + +**Regles de validation** : + +- `type: RECIPE` → `recipeId` requis, doit etre une recette de la communaute (non soft-deleted) +- `type: FREE_TEXT` → `freeText` requis, non vide, max 255 chars +- `type: EMPTY` → reset complet : recipeId, freeText, comment mis a null +- `servings` : modifiable independamment du type (min 1, max 100) +- `comment` : max 500 chars, optionnel sur tous les types (sauf EMPTY qui le reset) +- `disabled: true` → grise le slot. Si le slot avait un contenu, il est conserve mais le slot est visuellement grise +- `disabled: false` → reactive le slot +- Quand on set `type: RECIPE` ou `type: FREE_TEXT` sur un slot `disabled: true` → le slot repasse automatiquement a `disabled: false` +- Le plan doit etre ACTIVE (pas d'edition sur les archives) + +### 4.3 Swap de slots (POST /meal-plan/slots/swap) + +```json +{ "slotIdA": "uuid", "slotIdB": "uuid" } +``` + +Echange le contenu complet des deux slots : `type`, `recipeId`, `freeText`, `comment`, `servings`. Les proprietes `date`, `mealTime`, `disabled` et `locked` ne changent pas (fixes au slot). + +**Validation** : les deux slots doivent appartenir au meme plan ACTIVE. Un slot ne peut pas etre swappe avec lui-meme (`MEAL_007`). Si l'un ou l'autre des slots est `disabled`, le swap est refuse (`MEAL_013`). + +### 4.4 Recherche de recette pour un slot + +Quand un membre edite un slot et cherche une recette (cote frontend) : + +1. **Priorite 1** : recettes de la communaute (`communityId` match) +2. **Priorite 2** : recettes personnelles du membre (pas dans la communaute) + - Si selectionnee → popup "Cette recette n'est pas dans la communaute. L'ajouter ?" + - Si oui → `POST /api/recipes/:id/publish` (flow existant) → cree une copie communautaire → le slot pointe vers la copie + - Si non → le slot passe en `FREE_TEXT` avec le nom de la recette pre-rempli, le membre peut ajouter un commentaire (les autres membres n'auront pas acces a la recette originale) +3. **Fallback** : champ libre (`FREE_TEXT`) avec commentaire optionnel + +### 4.5 Archives + +- Les plans archives sont en lecture seule +- `GET /meal-plan/archives` : liste paginee, triee par `startDate` descendant +- `GET /meal-plan/archives/:planId` : detail complet d'un plan archive + ses slots +- Les slots des archives conservent leur etat au moment de l'archivage +- Pas d'edition, pas de swap, pas de drag & drop sur les archives +- `DELETE /meal-plan/archives/:planId` : hard delete d'une archive (MODERATOR). Si l'archive supprimee est la plus recente (celle utilisee pour le cooldown cross-planning), la prochaine generation le signale dans son rapport (`warning: no previous plan available for cross-planning cooldown`). Si l'archive est au milieu de la timeline, l'impact est minimal (le cooldown cross-planning utilise toujours la plus recente) + +### 4.6 Suppression + +- `DELETE /meal-plan` : supprime le plan ACTIVE + cascade hard delete des slots +- Suppression de communaute : hard delete en cascade de tous les MealPlan (ACTIVE + ARCHIVED), MealSlot et MealIdea (meme logique que UserCommunityTagPreference) + +--- + +## 5. Permissions + +| Action | Droit requis | +| -------------------------------------------------------------- | -------------------------------------------------------- | +| Voir le plan actif et les archives | Membre de la communaute | +| Creer / supprimer le plan actif | MODERATOR | +| Supprimer une archive | MODERATOR | +| Modifier `defaultServings` et `editableByMembers` | MODERATOR | +| Modifier un slot (type, recette, texte, commentaire, servings) | MODERATOR toujours. Membre si `editableByMembers = true` | +| Disable/enable un slot | MODERATOR toujours. Membre si `editableByMembers = true` | +| Lock/unlock un slot | MODERATOR toujours. Membre si `editableByMembers = true` | +| Swap de slots | MODERATOR toujours. Membre si `editableByMembers = true` | +| Voir les idees | Membre | +| Creer une idee | Membre | +| Modifier / supprimer une idee | Createur de l'idee OU MODERATOR | + +--- + +## 6. Codes erreur + +| Code | Message | +| -------- | ------------------------------------------------------------------------ | +| MEAL_001 | Plan not found | +| MEAL_002 | An active plan already exists (use creation flow which auto-archives) | +| MEAL_003 | Slot not found | +| MEAL_004 | Recipe not found in this community | +| MEAL_005 | Feature not enabled for this community | +| MEAL_006 | Idea not found | +| MEAL_007 | Cannot swap a slot with itself | +| MEAL_008 | Plan duration exceeds 31 days | +| MEAL_009 | startDate must be before or equal to endDate | +| MEAL_010 | Slot (date + mealTime) already exists in another plan for this community | +| MEAL_011 | Cannot edit an archived plan | +| MEAL_012 | Archive not found | +| MEAL_013 | Cannot swap with a disabled slot | + +--- + +## 7. Activity Log & Notifications + +- **Pas d'ActivityLog** pour les modifications de slots — le planning est une donnee collaborative "live", pas un evenement. Trop de bruit dans le feed communautaire. +- **Pas de notifications push** pour les mises a jour du planning en Feature 1. Le feed visuel suffit. + +--- + +## 8. UX Frontend + +### 8.1 Vue desktop — Grille dynamique + +- N colonnes (1 par jour dans la plage du planning) x 2 lignes (midi / soir) +- Si le planning fait plus de 7 jours → scroll horizontal ou pagination par semaine +- En-tete de colonne : jour de la semaine + date (ex: "Lun 23/03") +- Chaque carte : nom de la recette ou texte libre, badge nombre de personnes +- Clic sur une carte → drawer/modal de detail (lien recette, commentaire, servings editable) +- Slot EMPTY → affiche un "+" cliquable pour editer +- Slot disabled → grise, "+" discret pour ajouter manuellement (ce qui re-enable le slot) +- Slot locked → icone cadenas +- Recette soft-deleted → badge "Recette supprimee", style grise, lien desactive, slot modifiable +- Drag & drop entre slots → swap du contenu (bibliotheque : `@dnd-kit/core`) + +### 8.2 Vue mobile — Cartes empilees + +- Chaque jour = une carte (bloc) empilee verticalement (scroll vertical natif) +- En-tete de carte : jour de la semaine + date +- A l'interieur de chaque carte-jour : 2 sous-cartes (midi / soir) +- Slots disabled : sous-carte grisee avec "+" discret +- Navigation fluide en scroll vertical +- Meme fonctionnalites que desktop (edition, swap via drag & drop) + +### 8.3 Creation d'un planning + +- Formulaire : date de debut, date de fin, nombre de personnes par defaut +- Grille de pre-desactivation : apercu visuel du planning avec checkboxes pour desactiver des slots +- Option "Reprendre les desactivations du planning precedent" (si archive existante) +- Si un plan actif existe → message "Le planning actuel sera archive" + +### 8.4 Vue archives + +- Onglet "Archives" dans la page planning +- Liste des anciens plannings avec dates et nombre de slots remplis +- Clic → vue read-only du planning archive (meme grille, sans edition) + +### 8.5 Edition d'un slot + +- Modal avec recherche de recettes (autocomplete) + - Resultats communaute en priorite + - Resultats perso du membre en secondaire (avec indication visuelle) +- Bascule vers texte libre si besoin +- Champ commentaire toujours accessible + +### 8.6 Vue liste d'idees + +- Panel ou onglet separe dans la page planning +- Liste paginee, searchable +- Chaque idee : nom, commentaire, lien optionnel vers recette existante +- Actions : creer, editer, supprimer +- Future Feature 2 : "convertir en recette" (creer une recette depuis l'idee, puis lier via recipeId) + +--- + +## 9. Seed & Tests + +- **Feature** : upsert `MEAL_PLAN` dans le seed (idempotent, `code` unique) +- **Seeds de test** : creer un plan ACTIVE avec quelques slots remplis et disabled pour la communaute de test +- La feature n'est PAS attribuee aux communautes par defaut — attribution manuelle via admin + +--- + +## 10. Hors scope (Feature 2 — Generation automatique) + +Les elements suivants sont notes mais hors scope de cette spec : + +- `MealGenerationParams` (jeux de parametres multiples par communaute) +- Regles par tags (avec poids 0–200%), poids sur recettes specifiques +- Contraintes de frequence par tag (min/max/exact) +- Cooldown recette et cooldown tag +- Epinglage de tag sur un slot (patterns par jour de la semaine) +- Exclusion de slots pour la generation (patterns par jour de la semaine) +- Bouton "Remplacer en conservant les criteres" sur chaque carte +- Utilisation de la MealIdea list dans le generateur +- `fillEmptyOnly` mode pour la generation + +La `MealIdea` et le champ `locked` sur MealSlot sont deja concus (des Feature 1) pour etre utilisables par Feature 2 sans refactor DB. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce575bb..2efff8b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "axios": "^1.7.9", - "classnames": "^2.5.1", "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -20,8 +18,7 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.4.0", "react-router-dom": "^6.29.0", - "socket.io-client": "^4.8.3", - "usehooks-ts": "^3.1.0" + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -1085,9 +1082,9 @@ } }, "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==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1159,9 +1156,9 @@ } }, "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==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2934,6 +2931,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/autoprefixer": { @@ -2989,17 +2987,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3031,9 +3018,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -3120,6 +3107,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3269,12 +3257,6 @@ "node": ">= 6" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -3342,6 +3324,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3650,6 +3633,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3691,6 +3675,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3851,6 +3836,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3860,6 +3846,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3904,6 +3891,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3916,6 +3904,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4156,9 +4145,9 @@ } }, "node_modules/eslint-plugin-react/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==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4233,9 +4222,9 @@ } }, "node_modules/eslint/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==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4502,31 +4491,10 @@ } }, "node_modules/flatted": { - "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" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true }, "node_modules/for-each": { "version": "0.3.5", @@ -4565,6 +4533,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4610,6 +4579,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4680,6 +4650,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4704,6 +4675,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4787,6 +4759,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4861,6 +4834,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4873,6 +4847,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4888,6 +4863,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5750,12 +5726,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5845,6 +5815,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5878,6 +5849,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5887,6 +5859,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6412,9 +6385,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6455,9 +6428,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -6697,12 +6670,6 @@ "dev": true, "license": "MIT" }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7345,10 +7312,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "license": "MIT", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -7851,9 +7817,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8184,21 +8150,6 @@ "punycode": "^2.1.0" } }, - "node_modules/usehooks-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", - "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", - "license": "MIT", - "dependencies": { - "lodash.debounce": "^4.0.8" - }, - "engines": { - "node": ">=16.15.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8207,9 +8158,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8323,9 +8274,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8409,9 +8360,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index c0a1a6e..4c23e67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "axios": "^1.7.9", - "classnames": "^2.5.1", "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,8 +24,7 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.4.0", "react-router-dom": "^6.29.0", - "socket.io-client": "^4.8.3", - "usehooks-ts": "^3.1.0" + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/frontend/src/__tests__/setup/mswHandlers.ts b/frontend/src/__tests__/setup/mswHandlers.ts index 222b888..e0b9478 100644 --- a/frontend/src/__tests__/setup/mswHandlers.ts +++ b/frontend/src/__tests__/setup/mswHandlers.ts @@ -1798,6 +1798,150 @@ export const handlers = [ return HttpResponse.json(newRecipe, { status: 201 }); }), + // ===================================== + // Admin Changelog + // ===================================== + + // GET /api/admin/changelog + http.get(`${API_URL}/api/admin/changelog`, ({ request }) => { + if (!isAdminAuthenticated) { + return HttpResponse.json({ error: "ADMIN_001: Not authenticated" }, { status: 401 }); + } + + const url = new URL(request.url); + const includeDeleted = url.searchParams.get("includeDeleted") === "true"; + + const entries = [ + { + id: "admin-cl-1", + version: "1.2.0", + title: "2 nouveautes et 1 correction", + content: { + features: [{ text: "Import de recettes depuis URL" }, { text: "Notifications" }], + improvements: [], + fixes: [{ text: "Correction affichage mobile" }], + }, + publishedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + id: "admin-cl-2", + version: "1.1.0", + title: "1 nouveaute et 2 ameliorations", + content: { + features: [{ text: "Tags communautaires" }], + improvements: [{ text: "Performance" }, { text: "Gestion erreurs" }], + fixes: [], + }, + publishedAt: new Date(Date.now() - 7 * 86400000).toISOString(), + createdAt: new Date(Date.now() - 7 * 86400000).toISOString(), + updatedAt: new Date(Date.now() - 7 * 86400000).toISOString(), + deletedAt: null, + }, + { + id: "admin-cl-deleted", + version: "0.9.0", + title: "Deleted entry", + content: { + features: [{ text: "Old feature" }], + improvements: [], + fixes: [], + }, + publishedAt: new Date(Date.now() - 60 * 86400000).toISOString(), + createdAt: new Date(Date.now() - 60 * 86400000).toISOString(), + updatedAt: new Date(Date.now() - 60 * 86400000).toISOString(), + deletedAt: new Date(Date.now() - 30 * 86400000).toISOString(), + }, + ]; + + const filtered = includeDeleted ? entries : entries.filter((e) => !e.deletedAt); + + return HttpResponse.json({ + data: filtered, + pagination: { + total: filtered.length, + limit: 50, + offset: 0, + hasMore: false, + }, + }); + }), + + // POST /api/admin/changelog + http.post(`${API_URL}/api/admin/changelog`, async ({ request }) => { + if (!isAdminAuthenticated) { + return HttpResponse.json({ error: "ADMIN_001: Not authenticated" }, { status: 401 }); + } + + const body = (await request.json()) as Record; + + if (body.version === "1.2.0") { + return HttpResponse.json({ error: "CHANGELOG_002: Version already exists" }, { status: 409 }); + } + + return HttpResponse.json( + { + data: { + id: "new-cl-id", + version: body.version, + title: body.title, + content: body.content, + publishedAt: (body.publishedAt as string) || new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + }, + { status: 201 } + ); + }), + + // PATCH /api/admin/changelog/:id + http.patch(`${API_URL}/api/admin/changelog/:id`, async ({ request, params }) => { + if (!isAdminAuthenticated) { + return HttpResponse.json({ error: "ADMIN_001: Not authenticated" }, { status: 401 }); + } + + if (params.id === "not-found-id") { + return HttpResponse.json( + { error: "CHANGELOG_001: Changelog entry not found" }, + { status: 404 } + ); + } + + const body = (await request.json()) as Record; + return HttpResponse.json({ + data: { + id: params.id, + version: body.version || "1.2.0", + title: body.title || "Updated", + content: body.content || { features: [], improvements: [], fixes: [] }, + publishedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + }); + }), + + // DELETE /api/admin/changelog/:id + http.delete(`${API_URL}/api/admin/changelog/:id`, ({ params }) => { + if (!isAdminAuthenticated) { + return HttpResponse.json({ error: "ADMIN_001: Not authenticated" }, { status: 401 }); + } + + if (params.id === "not-found-id") { + return HttpResponse.json( + { error: "CHANGELOG_001: Changelog entry not found" }, + { status: 404 } + ); + } + + return HttpResponse.json({ message: "Changelog entry deleted" }); + }), + // ===================================== // Admin Activity // ===================================== @@ -1828,4 +1972,109 @@ export const handlers = [ }, }); }), + + // ===================================== + // Changelog (User) + // ===================================== + + // GET /api/changelog + http.get(`${API_URL}/api/changelog`, ({ request }) => { + if (!isUserAuthenticated) { + return HttpResponse.json({ error: "AUTH_001: Not authenticated" }, { status: 401 }); + } + + const url = new URL(request.url); + const limit = parseInt(url.searchParams.get("limit") || "10"); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + const entries = [ + { + id: "changelog-1", + version: "1.2.0", + title: "2 nouveautes et 1 correction", + content: { + features: [ + { text: "Import de recettes depuis URL" }, + { text: "Systeme de notifications" }, + ], + improvements: [], + fixes: [{ text: "Correction de l'affichage mobile" }], + }, + publishedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: "changelog-2", + version: "1.1.0", + title: "1 nouveaute et 2 ameliorations", + content: { + features: [{ text: "Tags communautaires" }], + improvements: [ + { text: "Performance amelioree" }, + { text: "Meilleure gestion des erreurs" }, + ], + fixes: [], + }, + publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "changelog-3", + version: "1.0.0", + title: "Lancement de Forest Manager", + content: { + features: [{ text: "Gestion de recettes" }, { text: "Communautes privees" }], + improvements: [], + fixes: [], + }, + publishedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + ]; + + const paged = entries.slice(offset, offset + limit); + return HttpResponse.json({ + data: paged, + pagination: { + total: entries.length, + limit, + offset, + hasMore: offset + limit < entries.length, + }, + }); + }), + + // GET /api/changelog/:id + http.get(`${API_URL}/api/changelog/:id`, ({ params }) => { + if (!isUserAuthenticated) { + return HttpResponse.json({ error: "AUTH_001: Not authenticated" }, { status: 401 }); + } + + if (params.id === "changelog-1") { + return HttpResponse.json({ + id: "changelog-1", + version: "1.2.0", + title: "2 nouveautes et 1 correction", + content: { + features: [ + { text: "Import de recettes depuis URL" }, + { text: "Systeme de notifications" }, + ], + improvements: [], + fixes: [{ text: "Correction de l'affichage mobile" }], + }, + publishedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + return HttpResponse.json( + { error: "CHANGELOG_001: Changelog entry not found" }, + { status: 404 } + ); + }), ]; diff --git a/frontend/src/__tests__/unit/hooks/useClickOutside.test.ts b/frontend/src/__tests__/unit/hooks/useClickOutside.test.ts index e10abb2..c3a58e7 100644 --- a/frontend/src/__tests__/unit/hooks/useClickOutside.test.ts +++ b/frontend/src/__tests__/unit/hooks/useClickOutside.test.ts @@ -68,4 +68,36 @@ describe("useClickOutside", () => { // Should not crash but also should not call callback (ref.current is null) expect(callback).not.toHaveBeenCalled(); }); + + it("should call callback when touching outside the ref element", () => { + const callback = vi.fn(); + const ref = createRef(); + const div = document.createElement("div"); + document.body.appendChild(div); + Object.defineProperty(ref, "current", { value: div, writable: true }); + + renderHook(() => useClickOutside(ref, callback)); + + const event = new TouchEvent("touchstart", { bubbles: true }); + document.body.dispatchEvent(event); + + expect(callback).toHaveBeenCalled(); + document.body.removeChild(div); + }); + + it("should not call callback when touching inside the ref element", () => { + const callback = vi.fn(); + const ref = createRef(); + const div = document.createElement("div"); + document.body.appendChild(div); + Object.defineProperty(ref, "current", { value: div, writable: true }); + + renderHook(() => useClickOutside(ref, callback)); + + const event = new TouchEvent("touchstart", { bubbles: true }); + div.dispatchEvent(event); + + expect(callback).not.toHaveBeenCalled(); + document.body.removeChild(div); + }); }); diff --git a/frontend/src/__tests__/unit/network/apiClient.test.ts b/frontend/src/__tests__/unit/network/apiClient.test.ts new file mode 100644 index 0000000..ea98ff8 --- /dev/null +++ b/frontend/src/__tests__/unit/network/apiClient.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { apiFetch, ApiError, handleApiError, handleApiErrorWith } from "../../../network/apiClient"; +import { UnauthorizedError, ConflictError } from "../../../errors/http_errors"; +import APIManager from "../../../network/api"; +import { http, HttpResponse } from "msw"; +import { server } from "../../setup/mswServer"; + +const BACKEND_URL = "http://localhost:3001"; + +// ========================================== +// 5a — apiFetch +// ========================================== + +describe("apiFetch", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("sends credentials: include on every request", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({}), + }); + await apiFetch("/api/test"); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ credentials: "include" }) + ); + }); + + it("prefixes URL with VITE_BACKEND_URL", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({}), + }); + await apiFetch("/api/test"); + expect(mockFetch).toHaveBeenCalledWith(`${BACKEND_URL}/api/test`, expect.anything()); + }); + + it("sends Content-Type: application/json header", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({}), + }); + await apiFetch("/api/test"); + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = options.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("reads XSRF-TOKEN cookie and injects into X-XSRF-TOKEN header", async () => { + vi.spyOn(document, "cookie", "get").mockReturnValue("XSRF-TOKEN=test-csrf-token"); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({}), + }); + await apiFetch("/api/test"); + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = options.headers as Record; + expect(headers["X-XSRF-TOKEN"]).toBe("test-csrf-token"); + }); + + it("does not crash if XSRF-TOKEN cookie is absent", async () => { + vi.spyOn(document, "cookie", "get").mockReturnValue(""); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({}), + }); + await expect(apiFetch("/api/test")).resolves.toBeDefined(); + }); + + it("throws ApiError with correct status and message on status >= 400", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: { get: () => null }, + json: async () => ({ error: "Not found" }), + }); + let caughtError: unknown; + try { + await apiFetch("/api/test"); + } catch (e) { + caughtError = e; + } + expect(caughtError).toBeInstanceOf(ApiError); + expect((caughtError as ApiError).status).toBe(404); + expect((caughtError as ApiError).message).toBe("Not found"); + }); + + it("returns { data } parsed as JSON on 2xx status", async () => { + const payload = { id: "1", name: "test" }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => payload, + }); + const result = await apiFetch("/api/test"); + expect(result).toEqual({ data: payload }); + }); + + it("returns { data: undefined } on 204 without calling .json()", async () => { + const jsonSpy = vi.fn(); + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + headers: { get: () => null }, + json: jsonSpy, + }); + const result = await apiFetch("/api/test"); + expect(result).toEqual({ data: undefined }); + expect(jsonSpy).not.toHaveBeenCalled(); + }); +}); + +// ========================================== +// 5b — handleApiError / handleApiErrorWith +// ========================================== + +describe("handleApiError", () => { + it("throws UnauthorizedError on 401", () => { + const error = new ApiError(401, "Unauthorized"); + expect(() => handleApiError(error)).toThrow(UnauthorizedError); + }); + + it("throws ConflictError on 409", () => { + const error = new ApiError(409, "Conflict"); + expect(() => handleApiError(error)).toThrow(ConflictError); + }); + + it("throws generic Error on other status with body message", () => { + const error = new ApiError(500, "Internal server error"); + let caughtError: unknown; + try { + handleApiError(error); + } catch (e) { + caughtError = e; + } + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError).not.toBeInstanceOf(UnauthorizedError); + expect(caughtError).not.toBeInstanceOf(ConflictError); + expect((caughtError as Error).message).toBe("Internal server error"); + }); + + it("throws Error with 'Network error' message if not an ApiError", () => { + const networkError = new Error("fetch failed"); + expect(() => handleApiError(networkError as unknown as ApiError)).toThrow(/Network error/); + }); +}); + +describe("handleApiErrorWith", () => { + it("applies override message on specified status", () => { + const handler = handleApiErrorWith({ 422: "Validation error" }); + const error = new ApiError(422, "some server error"); + expect(() => handler(error as never)).toThrow("Validation error"); + }); + + it("falls back to handleApiError if status not overridden", () => { + const handler = handleApiErrorWith({ 422: "Validation error" }); + const error = new ApiError(401, "Unauthorized"); + expect(() => handler(error as never)).toThrow(UnauthorizedError); + }); +}); + +// ========================================== +// 5c — removeMember 410 case +// ========================================== + +describe("APIManager.removeMember — 410 case", () => { + it("returns result without throwing on 410 status", async () => { + server.use( + http.delete(`${BACKEND_URL}/api/communities/:communityId/members/:userId`, () => + HttpResponse.json({ error: "Community deleted" }, { status: 410 }) + ) + ); + await expect(APIManager.removeMember("community-id", "user-id")).resolves.toBeDefined(); + }); +}); diff --git a/frontend/src/__tests__/unit/pages/ChangelogPage.test.tsx b/frontend/src/__tests__/unit/pages/ChangelogPage.test.tsx new file mode 100644 index 0000000..168d15b --- /dev/null +++ b/frontend/src/__tests__/unit/pages/ChangelogPage.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithUserAuth } from "../../setup/testUtils"; +import ChangelogPage from "../../../pages/ChangelogPage"; +import { setUserAuthenticated, resetAuthState } from "../../setup/mswHandlers"; + +describe("ChangelogPage", () => { + beforeEach(() => { + resetAuthState(); + setUserAuthenticated(true); + }); + + it("should show loading spinner initially", () => { + renderWithUserAuth(); + expect(document.querySelector(".loading-spinner")).toBeInTheDocument(); + }); + + it("should display page title", async () => { + renderWithUserAuth(); + + await waitFor(() => { + expect(screen.getByText("Changelog")).toBeInTheDocument(); + }); + }); + + it("should display changelog entries after loading", async () => { + renderWithUserAuth(); + + await waitFor(() => { + expect(screen.getByText("2 nouveautes et 1 correction")).toBeInTheDocument(); + expect(screen.getByText("1 nouveaute et 2 ameliorations")).toBeInTheDocument(); + expect(screen.getByText("Lancement de Forest Manager")).toBeInTheDocument(); + }); + }); + + it("should display version badges", async () => { + renderWithUserAuth(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + expect(screen.getByText("v1.1.0")).toBeInTheDocument(); + expect(screen.getByText("v1.0.0")).toBeInTheDocument(); + }); + }); + + it("should display category sections with items", async () => { + renderWithUserAuth(); + + await waitFor(() => { + // Features from v1.2.0 + expect(screen.getByText("Import de recettes depuis URL")).toBeInTheDocument(); + expect(screen.getByText("Systeme de notifications")).toBeInTheDocument(); + + // Fix from v1.2.0 + expect(screen.getByText("Correction de l'affichage mobile")).toBeInTheDocument(); + + // Improvements from v1.1.0 + expect(screen.getByText("Performance amelioree")).toBeInTheDocument(); + }); + }); + + it("should display category headers", async () => { + renderWithUserAuth(); + + await waitFor(() => { + expect(screen.getAllByText("Nouveautes").length).toBeGreaterThan(0); + expect(screen.getAllByText("Corrections").length).toBeGreaterThan(0); + expect(screen.getAllByText("Ameliorations").length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/__tests__/unit/pages/admin/AdminChangelogPage.test.tsx b/frontend/src/__tests__/unit/pages/admin/AdminChangelogPage.test.tsx new file mode 100644 index 0000000..be85940 --- /dev/null +++ b/frontend/src/__tests__/unit/pages/admin/AdminChangelogPage.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { render } from "@testing-library/react"; +import { AdminAuthProvider } from "../../../../contexts/AdminAuthContext"; +import AdminChangelogPage from "../../../../pages/admin/AdminChangelogPage"; +import { setAdminAuthenticated, resetAuthState } from "../../../setup/mswHandlers"; +import { Toaster } from "react-hot-toast"; + +function TestApp() { + return ( + + + + } /> + + + + + ); +} + +describe("AdminChangelogPage", () => { + beforeEach(() => { + resetAuthState(); + setAdminAuthenticated(true); + }); + + it("should render page title", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Changelog")).toBeInTheDocument(); + }); + }); + + it("should display changelog entries", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("2 nouveautes et 1 correction")).toBeInTheDocument(); + expect(screen.getByText("1 nouveaute et 2 ameliorations")).toBeInTheDocument(); + }); + }); + + it("should display version badges", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + expect(screen.getByText("v1.1.0")).toBeInTheDocument(); + }); + }); + + it("should show Active status badges", async () => { + render(); + + await waitFor(() => { + const activeBadges = screen.getAllByText("Active"); + expect(activeBadges.length).toBe(2); + }); + }); + + it("should not show deleted entries by default", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + }); + + expect(screen.queryByText("v0.9.0")).not.toBeInTheDocument(); + }); + + it("should show deleted entries when checkbox is toggled", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Show deleted")); + + await waitFor(() => { + expect(screen.getByText("v0.9.0")).toBeInTheDocument(); + expect(screen.getByText("Deleted")).toBeInTheDocument(); + }); + }); + + it("should open create modal when New Entry is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("New Entry")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("New Entry")); + + expect(screen.getByText("New Changelog Entry")).toBeInTheDocument(); + }); + + it("should open edit modal when Edit is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + }); + + const editButtons = screen.getAllByText("Edit"); + await user.click(editButtons[0]); + + expect(screen.getByText("Edit Changelog Entry")).toBeInTheDocument(); + }); + + it("should show delete confirmation dialog", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("v1.2.0")).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByText("Delete"); + await user.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.getByText(/Delete v1.2.0/)).toBeInTheDocument(); + }); + }); + + it("should display New Entry button", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("New Entry")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 4414f71..9a300df 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -100,6 +100,7 @@ const Sidebar = ({ onNavigate, isCompact = false, onToggleCompact }: SidebarProp const location = useLocation(); const { theme, toggleTheme } = useTheme(); const [communities, setCommunities] = useState([]); + const [latestVersion, setLatestVersion] = useState(null); const loadCommunities = useCallback(async () => { try { @@ -114,6 +115,16 @@ const Sidebar = ({ onNavigate, isCompact = false, onToggleCompact }: SidebarProp loadCommunities(); }, [location.pathname, loadCommunities]); + useEffect(() => { + APIManager.getChangelog({ limit: 1, offset: 0 }) + .then((res) => { + if (res.data.length > 0) { + setLatestVersion(res.data[0].version); + } + }) + .catch(() => {}); + }, []); + useEffect(() => { return communityEvents.subscribe(loadCommunities); }, [loadCommunities]); @@ -253,7 +264,15 @@ const Sidebar = ({ onNavigate, isCompact = false, onToggleCompact }: SidebarProp
- {!isCompact &&

Forest Manager v0.1

} + {!isCompact && ( + + Forest Manager v{latestVersion || "0.1"} + + )}
- {isCompact &&

v0.1

} + {isCompact && ( + + + + v{latestVersion || "0.1"} + + + + )}
); diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 31993b0..cb85c94 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -1,6 +1,5 @@ import { useRef } from "react"; -import cn from "classnames"; -import { useOnClickOutside } from "usehooks-ts"; +import { useClickOutside } from "../hooks/useClickOutside"; type Props = { children: React.ReactNode; disableClickOutside?: boolean; @@ -10,19 +9,15 @@ type Props = { const Modal = ({ children, disableClickOutside, className, onClose }: Props) => { const ref = useRef(null); - useOnClickOutside(ref, () => { + useClickOutside(ref, () => { if (!disableClickOutside) { onClose(); } }); - const modalClass = cn({ - "modal modal-bottom sm:modal-middle": true, - "modal-open": true, - }); return ( -
-
+
+
{children}
diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 32dea5e..13f512e 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -8,6 +8,7 @@ import { FaStar, FaUsers, FaClipboardList, + FaNewspaper, FaSignOutAlt, } from "react-icons/fa"; @@ -18,6 +19,7 @@ const adminNavItems = [ { to: "/admin/units", label: "Units", icon: FaBalanceScale }, { to: "/admin/features", label: "Features", icon: FaStar }, { to: "/admin/communities", label: "Communities", icon: FaUsers }, + { to: "/admin/changelog", label: "Changelog", icon: FaNewspaper }, { to: "/admin/activity", label: "Activity", icon: FaClipboardList }, ]; diff --git a/frontend/src/components/admin/AdminRecipeDetailModal.tsx b/frontend/src/components/admin/AdminRecipeDetailModal.tsx index 6a1388c..ebbf9ca 100644 --- a/frontend/src/components/admin/AdminRecipeDetailModal.tsx +++ b/frontend/src/components/admin/AdminRecipeDetailModal.tsx @@ -3,6 +3,7 @@ import { AdminRecipeDetail, AdminRecipeUpdateInput } from "../../models/admin"; import APIManager from "../../network/api"; import { useConfirm } from "../../hooks/useConfirm"; import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; interface AdminRecipeDetailModalProps { recipeId: string; @@ -68,7 +69,7 @@ const AdminRecipeDetailModal = ({ setEditing(false); onRecipeChanged(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to update recipe"); + toastError(err, "Failed to update recipe"); } finally { setSaving(false); } @@ -89,7 +90,7 @@ const AdminRecipeDetailModal = ({ onClose(); onRecipeChanged(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete recipe"); + toastError(err, "Failed to delete recipe"); } }; diff --git a/frontend/src/components/communities/CommunityTagsList.tsx b/frontend/src/components/communities/CommunityTagsList.tsx index 60ee747..f746104 100644 --- a/frontend/src/components/communities/CommunityTagsList.tsx +++ b/frontend/src/components/communities/CommunityTagsList.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { FaCheck, FaTimes, FaEdit, FaTrash } from "react-icons/fa"; import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; import { CommunityTag } from "../../models/tag"; import APIManager from "../../network/api"; import { useConfirm } from "../../hooks/useConfirm"; @@ -69,7 +70,7 @@ const CommunityTagsList = ({ communityId }: CommunityTagsListProps) => { setModalOpen(false); loadTags(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to save tag"); + toastError(err, "Failed to save tag"); } finally { setSaving(false); } @@ -89,7 +90,7 @@ const CommunityTagsList = ({ communityId }: CommunityTagsListProps) => { toast.success("Tag deleted"); loadTags(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete tag"); + toastError(err, "Failed to delete tag"); } finally { setActionLoading(null); } @@ -102,7 +103,7 @@ const CommunityTagsList = ({ communityId }: CommunityTagsListProps) => { toast.success("Tag approved"); loadTags(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to approve tag"); + toastError(err, "Failed to approve tag"); } finally { setActionLoading(null); } @@ -123,7 +124,7 @@ const CommunityTagsList = ({ communityId }: CommunityTagsListProps) => { toast.success("Tag rejected"); loadTags(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to reject tag"); + toastError(err, "Failed to reject tag"); } finally { setActionLoading(null); } diff --git a/frontend/src/components/mealPlan/CreatePlanModal.tsx b/frontend/src/components/mealPlan/CreatePlanModal.tsx new file mode 100644 index 0000000..b0a7d31 --- /dev/null +++ b/frontend/src/components/mealPlan/CreatePlanModal.tsx @@ -0,0 +1,311 @@ +import { useState, useMemo } from "react"; +import { FaTimes, FaArchive } from "react-icons/fa"; +import APIManager from "../../network/api"; +import { MealPlan, MealTime, CreateMealPlanInput } from "../../models/mealPlan"; + +interface Props { + communityId: string; + existingPlan: MealPlan | null; + onCreated: (plan: MealPlan) => void; + onClose: () => void; +} + +const DAYS_OF_WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function formatDateInput(date: Date): string { + return date.toISOString().split("T")[0]; +} + +function addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +function getDaysBetween(start: Date, end: Date): Date[] { + const days: Date[] = []; + const current = new Date(start); + while (current <= end) { + days.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + return days; +} + +const CreatePlanModal = ({ communityId, existingPlan, onCreated, onClose }: Props) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Default: start tomorrow, end in 7 days + const defaultStart = addDays(today, 1); + const defaultEnd = addDays(today, 7); + + const [startDate, setStartDate] = useState(formatDateInput(defaultStart)); + const [endDate, setEndDate] = useState(formatDateInput(defaultEnd)); + const [defaultServings, setDefaultServings] = useState(4); + const [disabledSlots, setDisabledSlots] = useState>(new Set()); + const [copyFromPrevious, setCopyFromPrevious] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Calculate days for preview + const previewDays = useMemo(() => { + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime()) || start > end) return []; + // Limit to 31 days + const maxEnd = addDays(start, 30); + const actualEnd = end > maxEnd ? maxEnd : end; + return getDaysBetween(start, actualEnd); + }, [startDate, endDate]); + + const durationDays = previewDays.length; + const totalSlots = durationDays * 2; + const disabledCount = disabledSlots.size; + + // Toggle slot disabled state + const toggleSlot = (date: string, mealTime: MealTime) => { + const key = `${date}-${mealTime}`; + const newSet = new Set(disabledSlots); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + setDisabledSlots(newSet); + }; + + const isSlotDisabled = (date: string, mealTime: MealTime) => { + return disabledSlots.has(`${date}-${mealTime}`); + }; + + // Copy from previous plan + const handleCopyFromPrevious = () => { + if (!existingPlan) return; + const newSet = new Set(); + + // Get disabled slots from existing plan and apply to new dates + const existingDisabled = existingPlan.slots.filter((s) => s.disabled); + for (const slot of existingDisabled) { + const slotDate = new Date(slot.date); + const dayOfWeek = slotDate.getDay(); // 0=Sun, 1=Mon, ... + + // Find matching days in new plan + for (const day of previewDays) { + if (day.getDay() === dayOfWeek) { + newSet.add(`${formatDateInput(day)}-${slot.mealTime}`); + } + } + } + + setDisabledSlots(newSet); + setCopyFromPrevious(true); + }; + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + + try { + const input: CreateMealPlanInput = { + startDate, + endDate, + defaultServings, + disabledSlots: Array.from(disabledSlots).map((key) => { + const lastDash = key.lastIndexOf("-"); + const date = key.substring(0, lastDash); + const mealTime = key.substring(lastDash + 1) as MealTime; + return { date, mealTime }; + }), + copyDisabledFromPrevious: false, // We handle this client-side + }; + + const response = await APIManager.createMealPlan(communityId, input); + onCreated(response.plan); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create plan"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + +

Create Meal Plan

+ + {/* Warning if existing plan */} + {existingPlan && ( +
+ + + The current plan ({new Date(existingPlan.startDate).toLocaleDateString()} -{" "} + {new Date(existingPlan.endDate).toLocaleDateString()}) will be archived. + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Form */} +
+
+ + setStartDate(e.target.value)} + min={formatDateInput(today)} + /> +
+ +
+ + setEndDate(e.target.value)} + min={startDate} + /> +
+ +
+ + setDefaultServings(Math.max(1, parseInt(e.target.value) || 1))} + min={1} + max={100} + /> +
+
+ + {/* Duration info */} +
+ Duration: {durationDays} day{durationDays !== 1 ? "s" : ""} ({totalSlots} slots + {disabledCount > 0 && `, ${disabledCount} disabled`}) +
+ + {/* Copy from previous option */} + {existingPlan && previewDays.length > 0 && ( +
+ +
+ )} + + {/* Preview grid */} + {previewDays.length > 0 && ( +
+

Preview (click to disable/enable slots)

+
+ + + + + ))} + + + + + + {previewDays.map((day) => { + const dateStr = formatDateInput(day); + const disabled = isSlotDisabled(dateStr, "LUNCH"); + return ( + + ); + })} + + + + {previewDays.map((day) => { + const dateStr = formatDateInput(day); + const disabled = isSlotDisabled(dateStr, "DINNER"); + return ( + + ); + })} + + +
+ {previewDays.map((day) => ( + +
+ {DAYS_OF_WEEK[day.getDay() === 0 ? 6 : day.getDay() - 1]} +
+
+ {day.getDate()}/{day.getMonth() + 1} +
+
Lunch + toggleSlot(dateStr, "LUNCH")} + title={disabled ? "Click to enable" : "Click to disable"} + /> +
Dinner + toggleSlot(dateStr, "DINNER")} + title={disabled ? "Click to enable" : "Click to disable"} + /> +
+
+

+ Unchecked slots will be marked as disabled (e.g., for restaurant nights). +

+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +}; + +export default CreatePlanModal; diff --git a/frontend/src/components/mealPlan/ExclusionPinGrid.tsx b/frontend/src/components/mealPlan/ExclusionPinGrid.tsx new file mode 100644 index 0000000..0c1c28a --- /dev/null +++ b/frontend/src/components/mealPlan/ExclusionPinGrid.tsx @@ -0,0 +1,388 @@ +import { useState, useEffect } from "react"; +import { FaBan, FaThumbtack, FaSearch } from "react-icons/fa"; +import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; +import APIManager from "../../network/api"; +import { useIsMobile } from "../../hooks/useIsMobile"; +import { DayOfWeek, MealTime, MealSlotExclusion, MealSlotPin } from "../../models/mealPlan"; +import { TagSearchResult } from "../../models/recipe"; + +interface Props { + communityId: string; + paramsId: string; + exclusions: MealSlotExclusion[]; + slotPins: MealSlotPin[]; + isModerator: boolean; + onExclusionsUpdated: (exclusions: MealSlotExclusion[]) => void; + onPinsUpdated: (pins: MealSlotPin[]) => void; +} + +const DAYS: { key: DayOfWeek; label: string; short: string }[] = [ + { key: "MON", label: "Monday", short: "Mon" }, + { key: "TUE", label: "Tuesday", short: "Tue" }, + { key: "WED", label: "Wednesday", short: "Wed" }, + { key: "THU", label: "Thursday", short: "Thu" }, + { key: "FRI", label: "Friday", short: "Fri" }, + { key: "SAT", label: "Saturday", short: "Sat" }, + { key: "SUN", label: "Sunday", short: "Sun" }, +]; + +const MEALS: { key: MealTime; label: string }[] = [ + { key: "LUNCH", label: "Lunch" }, + { key: "DINNER", label: "Dinner" }, +]; + +const ExclusionPinGrid = ({ + communityId, + paramsId, + exclusions, + slotPins, + isModerator, + onExclusionsUpdated, + onPinsUpdated, +}: Props) => { + const isMobile = useIsMobile(); + const [savingExclusions, setSavingExclusions] = useState(false); + const [savingPins, setSavingPins] = useState(false); + // Pin editing state + const [editingPin, setEditingPin] = useState<{ day: DayOfWeek; mealTime: MealTime } | null>(null); + const [pinSearch, setPinSearch] = useState(""); + const [pinResults, setPinResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + // Build exclusion set for quick lookup + const exclusionSet = new Set(exclusions.map((e) => `${e.day}:${e.mealTime}`)); + + // Build pin map for quick lookup + const pinMap = new Map(slotPins.map((p) => [`${p.day}:${p.mealTime}`, p])); + + // Tag search for pins + useEffect(() => { + if (!pinSearch || pinSearch.length < 2) { + setPinResults([]); + return; + } + const timer = setTimeout(async () => { + setIsSearching(true); + try { + const results = await APIManager.searchTags(pinSearch, 20, communityId); + setPinResults(results); + } catch { + setPinResults([]); + } finally { + setIsSearching(false); + } + }, 300); + return () => clearTimeout(timer); + }, [pinSearch, communityId]); + + const toggleExclusion = async (day: DayOfWeek, mealTime: MealTime) => { + if (!isModerator) return; + const key = `${day}:${mealTime}`; + const isExcluded = exclusionSet.has(key); + + // Cannot exclude a pinned slot + if (!isExcluded && pinMap.has(key)) { + toast.error("Cannot exclude a pinned slot. Remove the pin first."); + return; + } + + setSavingExclusions(true); + try { + let newExclusions: { day: string; mealTime: string }[]; + if (isExcluded) { + newExclusions = exclusions + .filter((e) => !(e.day === day && e.mealTime === mealTime)) + .map((e) => ({ day: e.day, mealTime: e.mealTime })); + } else { + newExclusions = [ + ...exclusions.map((e) => ({ day: e.day, mealTime: e.mealTime })), + { day, mealTime }, + ]; + } + const result = await APIManager.setMealExclusions(communityId, paramsId, newExclusions); + onExclusionsUpdated(result.data); + } catch (err) { + toastError(err, "Failed to update exclusions"); + } finally { + setSavingExclusions(false); + } + }; + + const handlePinSelect = async (day: DayOfWeek, mealTime: MealTime, tag: TagSearchResult) => { + setSavingPins(true); + try { + const newPins = [ + ...slotPins + .filter((p) => !(p.day === day && p.mealTime === mealTime)) + .map((p) => ({ day: p.day, mealTime: p.mealTime, tagId: p.tagId })), + { day, mealTime, tagId: tag.id }, + ]; + const result = await APIManager.setMealPins(communityId, paramsId, newPins); + onPinsUpdated(result.data); + setEditingPin(null); + setPinSearch(""); + setPinResults([]); + } catch (err) { + toastError(err, "Failed to update pins"); + } finally { + setSavingPins(false); + } + }; + + const removePin = async (day: DayOfWeek, mealTime: MealTime) => { + setSavingPins(true); + try { + const newPins = slotPins + .filter((p) => !(p.day === day && p.mealTime === mealTime)) + .map((p) => ({ day: p.day, mealTime: p.mealTime, tagId: p.tagId })); + const result = await APIManager.setMealPins(communityId, paramsId, newPins); + onPinsUpdated(result.data); + } catch (err) { + toastError(err, "Failed to remove pin"); + } finally { + setSavingPins(false); + } + }; + + // Render pin cell content (shared between mobile and desktop) + const renderPinCell = (day: DayOfWeek, mealKey: MealTime) => { + const key = `${day}:${mealKey}`; + const isExcluded = exclusionSet.has(key); + const pin = pinMap.get(key); + const isEditingThis = editingPin?.day === day && editingPin?.mealTime === mealKey; + + if (isExcluded) return --; + + if (isEditingThis) { + return ( +
+
+ setPinSearch(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === "Escape") { + setEditingPin(null); + setPinSearch(""); + setPinResults([]); + } + }} + /> + {isSearching && ( + + )} +
+ {pinResults.length > 0 && ( +
+ {pinResults.map((tag) => ( + + ))} +
+ )} +
+ ); + } + + if (pin) { + return ( +
+ {pin.tag.name} + {isModerator && ( + + )} +
+ ); + } + + if (isModerator) { + return ( + + ); + } + + return -; + }; + + // Mobile layout: vertical cards per day + if (isMobile) { + return ( +
+
+

+ + Exclusions & Pins + {(savingExclusions || savingPins) && ( + + )} +

+

+ Configure exclusions and tag pins for each slot. +

+
+ {DAYS.map((day) => ( +
+
{day.label}
+ {MEALS.map((meal) => { + const key = `${day.key}:${meal.key}`; + const isExcluded = exclusionSet.has(key); + const isPinned = pinMap.has(key); + return ( +
+ {meal.label} +
+ +
{renderPinCell(day.key, meal.key)}
+
+
+ ); + })} +
+ ))} +
+
+
+ ); + } + + // Desktop layout: tables + return ( +
+ {/* Exclusions Grid */} +
+

+ + Exclusions + {savingExclusions && } +

+

+ Check slots where the community doesn't eat together. These slots will be skipped during + generation. +

+
+ + + + + ))} + + + + {MEALS.map((meal) => ( + + + {DAYS.map((day) => { + const key = `${day.key}:${meal.key}`; + const isExcluded = exclusionSet.has(key); + const isPinned = pinMap.has(key); + return ( + + ); + })} + + ))} + +
+ {DAYS.map((d) => ( + + {d.short} +
{meal.label} + toggleExclusion(day.key, meal.key)} + disabled={!isModerator || savingExclusions || isPinned} + title={ + isPinned + ? "Remove pin first" + : isExcluded + ? "Click to include" + : "Click to exclude" + } + /> +
+
+
+ + {/* Pins Grid */} +
+

+ + Slot Pins + {savingPins && } +

+

+ Pin a tag to a slot to restrict generation to recipes with that tag (e.g., "Fish" on + Friday dinner). +

+
+ + + + + ))} + + + + {MEALS.map((meal) => ( + + + {DAYS.map((day) => ( + + ))} + + ))} + +
+ {DAYS.map((d) => ( + + {d.short} +
{meal.label} + {renderPinCell(day.key, meal.key)} +
+
+
+
+ ); +}; + +export default ExclusionPinGrid; diff --git a/frontend/src/components/mealPlan/GenerateModal.tsx b/frontend/src/components/mealPlan/GenerateModal.tsx new file mode 100644 index 0000000..5928aab --- /dev/null +++ b/frontend/src/components/mealPlan/GenerateModal.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect } from "react"; +import { FaMagic, FaExclamationTriangle, FaInfoCircle } from "react-icons/fa"; +import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; +import APIManager from "../../network/api"; +import { MealPlan, MealGenerationParamsListItem, GenerationReport } from "../../models/mealPlan"; + +interface Props { + communityId: string; + plan: MealPlan; + onGenerated: (plan: MealPlan, report: GenerationReport) => void; + onClose: () => void; +} + +const GenerateModal = ({ communityId, plan, onGenerated, onClose }: Props) => { + const [paramsList, setParamsList] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedParamsId, setSelectedParamsId] = useState(""); + const [fillEmptyOnly, setFillEmptyOnly] = useState(true); + const [generating, setGenerating] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + useEffect(() => { + APIManager.listMealGenerationParams(communityId) + .then((res) => { + setParamsList(res.data); + const defaultParams = res.data.find((p) => p.isDefault); + if (defaultParams) { + setSelectedParamsId(defaultParams.id); + } else if (res.data.length > 0) { + setSelectedParamsId(res.data[0].id); + } + }) + .catch((err) => toastError(err, "Failed to load params")) + .finally(() => setLoading(false)); + }, [communityId]); + + const nonLockedNonEmptySlots = plan.slots.filter( + (s) => !s.locked && !s.disabled && s.type !== "EMPTY" + ); + + const handleGenerate = () => { + if (!fillEmptyOnly && nonLockedNonEmptySlots.length > 0 && !showConfirm) { + setShowConfirm(true); + return; + } + doGenerate(); + }; + + const doGenerate = async () => { + if (!selectedParamsId) return; + setGenerating(true); + try { + const result = await APIManager.generateMealPlan( + communityId, + selectedParamsId, + fillEmptyOnly + ); + toast.success(`${result.report.slotsGenerated} slots generated`); + onGenerated(result.plan, result.report); + } catch (err) { + toastError(err, "Generation failed"); + } finally { + setGenerating(false); + } + }; + + return ( +
+
+

+ + Generate Meal Plan +

+ + {loading ? ( +
+ +
+ ) : paramsList.length === 0 ? ( +
+ + No generation params found. Create one in the Generation tab first. +
+ ) : showConfirm ? ( +
+
+ +
+

Overwrite existing slots?

+

+ {nonLockedNonEmptySlots.length} non-locked slot(s) with content will be + regenerated. Locked slots are preserved. +

+
+
+
+ + +
+
+ ) : ( +
+ {/* Params selector */} +
+ + +
+ + {/* fillEmptyOnly toggle */} +
+ +
+ + {!fillEmptyOnly && nonLockedNonEmptySlots.length > 0 && ( +
+ + + {nonLockedNonEmptySlots.length} non-locked slot(s) will be regenerated + +
+ )} + +
+ + +
+
+ )} + + {/* Close for no-params state */} + {!loading && paramsList.length === 0 && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default GenerateModal; diff --git a/frontend/src/components/mealPlan/GenerationParamsPanel.tsx b/frontend/src/components/mealPlan/GenerationParamsPanel.tsx new file mode 100644 index 0000000..e36c95b --- /dev/null +++ b/frontend/src/components/mealPlan/GenerationParamsPanel.tsx @@ -0,0 +1,364 @@ +import { useState } from "react"; +import { FaPlus, FaEdit, FaTrash, FaStar, FaCog, FaArrowLeft } from "react-icons/fa"; +import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; +import APIManager from "../../network/api"; +import { useAsyncData } from "../../hooks/useAsyncData"; +import { MealGenerationParamsListItem, MealGenerationParams } from "../../models/mealPlan"; +import ParamsFormModal from "./ParamsFormModal"; +import ExclusionPinGrid from "./ExclusionPinGrid"; +import RulesEditor from "./RulesEditor"; + +function toListItem(p: MealGenerationParams): MealGenerationParamsListItem { + return { + id: p.id, + name: p.name, + description: p.description, + cooldownDays: p.cooldownDays, + useIdeas: p.useIdeas, + isDefault: p.isDefault, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }; +} + +interface Props { + communityId: string; + isModerator: boolean; +} + +const GenerationParamsPanel = ({ communityId, isModerator }: Props) => { + const [showForm, setShowForm] = useState(false); + const [editingParams, setEditingParams] = useState(null); + const [selectedParamsId, setSelectedParamsId] = useState(null); + const [selectedDetail, setSelectedDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const { + data: paramsList, + setData: setParamsList, + isLoading, + error, + } = useAsyncData( + () => APIManager.listMealGenerationParams(communityId).then((r) => r.data), + [communityId] + ); + + const loadDetail = async (paramsId: string) => { + setLoadingDetail(true); + try { + const detail = await APIManager.getMealGenerationParams(communityId, paramsId); + setSelectedDetail(detail); + setSelectedParamsId(paramsId); + } catch (err) { + toastError(err, "Failed to load details"); + } finally { + setLoadingDetail(false); + } + }; + + const handleCreate = async (data: { + name: string; + description: string | null; + cooldownDays: number; + useIdeas: boolean; + isDefault: boolean; + }) => { + try { + const created = await APIManager.createMealGenerationParams(communityId, data); + // Si isDefault, mettre a jour la liste + const item = toListItem(created); + if (created.isDefault && paramsList) { + setParamsList( + paramsList.map((p) => (p.isDefault ? { ...p, isDefault: false } : p)).concat(item) + ); + } else { + setParamsList([...(paramsList || []), item]); + } + setShowForm(false); + toast.success("Parameters created"); + } catch (err) { + toastError(err, "Failed to create"); + } + }; + + const handleUpdate = async (data: { + name: string; + description: string | null; + cooldownDays: number; + useIdeas: boolean; + isDefault: boolean; + }) => { + if (!editingParams) return; + try { + const updated = await APIManager.updateMealGenerationParams( + communityId, + editingParams.id, + data + ); + setParamsList( + (paramsList || []).map((p) => { + if (p.id === updated.id) return toListItem(updated); + // Si le updated est devenu default, enlever isDefault aux autres + if (updated.isDefault && p.isDefault) return { ...p, isDefault: false }; + return p; + }) + ); + // Refresh detail si c'est le meme + if (selectedParamsId === editingParams.id) { + setSelectedDetail(updated); + } + setEditingParams(null); + toast.success("Parameters updated"); + } catch (err) { + toastError(err, "Failed to update"); + } + }; + + const handleDelete = async (paramsId: string) => { + if (!confirm("Delete this parameter set? This cannot be undone.")) return; + setDeletingId(paramsId); + try { + await APIManager.deleteMealGenerationParams(communityId, paramsId); + setParamsList((paramsList || []).filter((p) => p.id !== paramsId)); + if (selectedParamsId === paramsId) { + setSelectedParamsId(null); + setSelectedDetail(null); + } + toast.success("Parameters deleted"); + } catch (err) { + toastError(err, "Failed to delete"); + } finally { + setDeletingId(null); + } + }; + + const handleExclusionsUpdated = (exclusions: MealGenerationParams["exclusions"]) => { + if (selectedDetail) { + setSelectedDetail({ ...selectedDetail, exclusions }); + } + }; + + const handlePinsUpdated = (slotPins: MealGenerationParams["slotPins"]) => { + if (selectedDetail) { + setSelectedDetail({ ...selectedDetail, slotPins }); + } + }; + + const handleRulesUpdated = (rules: MealGenerationParams["rules"]) => { + if (selectedDetail) { + setSelectedDetail({ ...selectedDetail, rules }); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return
{error}
; + } + + // Detail view + if (selectedParamsId && selectedDetail) { + return ( +
+
+ +
+
+

{selectedDetail.name}

+ {selectedDetail.isDefault && ( + + + Default + + )} +
+ {selectedDetail.description && ( +

{selectedDetail.description}

+ )} +
+ {isModerator && ( + + )} +
+ + {/* Settings summary */} +
+
+
Cooldown
+
{selectedDetail.cooldownDays}d
+
+
+
Use Ideas
+
{selectedDetail.useIdeas ? "Yes" : "No"}
+
+
+
Rules
+
{selectedDetail.rules.length}
+
+
+ + {/* Exclusion & Pin grids */} + + + {/* Rules editor */} +
+ +
+ ); + } + + // List view + return ( +
+
+

+ + Generation Parameters +

+ {isModerator && ( + + )} +
+ + {!paramsList || paramsList.length === 0 ? ( +
+ +

No generation parameters yet.

+ {isModerator && ( +

Create a set to configure automatic generation.

+ )} +
+ ) : ( +
+ {paramsList.map((params) => ( +
loadDetail(params.id)} + > +
+
+
+

{params.name}

+ {params.isDefault && ( + + + Default + + )} +
+ {isModerator && ( +
+ + +
+ )} +
+ {params.description && ( +

{params.description}

+ )} +
+ Cooldown: {params.cooldownDays}d + Ideas: {params.useIdeas ? "Yes" : "No"} +
+
+
+ ))} +
+ )} + + {loadingDetail && ( +
+ +
+ )} + + {/* Create/Edit modal */} + {(showForm || editingParams) && ( + { + setShowForm(false); + setEditingParams(null); + }} + /> + )} +
+ ); +}; + +export default GenerationParamsPanel; diff --git a/frontend/src/components/mealPlan/GenerationReportPanel.tsx b/frontend/src/components/mealPlan/GenerationReportPanel.tsx new file mode 100644 index 0000000..8b3026a --- /dev/null +++ b/frontend/src/components/mealPlan/GenerationReportPanel.tsx @@ -0,0 +1,105 @@ +import { FaCheckCircle, FaExclamationTriangle, FaBan, FaInfoCircle, FaTimes } from "react-icons/fa"; +import { GenerationReport } from "../../models/mealPlan"; + +interface Props { + report: GenerationReport; + onDismiss: () => void; +} + +const warningLabels: Record = { + POOL_EXHAUSTED: "Pool exhausted", + FREQUENCY_MIN_NOT_MET: "Frequency min not met", + FREQUENCY_MAX_EXCEEDED: "Frequency max exceeded", + CONFLICTING_CONSTRAINTS: "Conflicting constraints", +}; + +const GenerationReportPanel = ({ report, onDismiss }: Props) => { + const hasWarnings = report.warnings.length > 0; + const totalSkipped = + report.slotsSkipped.excluded + report.slotsSkipped.locked + report.slotsSkipped.alreadyFilled; + + return ( +
+
+
+ {hasWarnings ? ( + + ) : ( + + )} + Generation complete +
+ +
+ + {/* Stats */} +
+
+ + {report.slotsGenerated} generated +
+ {totalSkipped > 0 && ( +
+ + + {totalSkipped} skipped + + ({report.slotsSkipped.excluded > 0 && `${report.slotsSkipped.excluded} excluded`} + {report.slotsSkipped.excluded > 0 && report.slotsSkipped.locked > 0 && ", "} + {report.slotsSkipped.locked > 0 && `${report.slotsSkipped.locked} locked`} + {(report.slotsSkipped.excluded > 0 || report.slotsSkipped.locked > 0) && + report.slotsSkipped.alreadyFilled > 0 && + ", "} + {report.slotsSkipped.alreadyFilled > 0 && + `${report.slotsSkipped.alreadyFilled} already filled`} + ) + + +
+ )} + {report.slotsEmpty > 0 && ( +
+ + {report.slotsEmpty} left empty +
+ )} +
+ + {/* Warnings */} + {hasWarnings && ( +
+

Warnings:

+
    + {report.warnings.map((w, i) => ( +
  • + +
    + {warningLabels[w.type] || w.type} + {w.tagName && ( + {w.tagName} + )} + {w.required !== undefined && w.actual !== undefined && ( + + ({w.actual}/{w.required}) + + )} +

    {w.reason}

    +
    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default GenerationReportPanel; diff --git a/frontend/src/components/mealPlan/MealIdeasPanel.tsx b/frontend/src/components/mealPlan/MealIdeasPanel.tsx new file mode 100644 index 0000000..f0041ee --- /dev/null +++ b/frontend/src/components/mealPlan/MealIdeasPanel.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect } from "react"; +import { FaPlus, FaEdit, FaTrash, FaSearch, FaLink } from "react-icons/fa"; +import toast from "react-hot-toast"; +import { toastError } from "../../utils/toastError"; +import APIManager from "../../network/api"; +import { useAsyncData } from "../../hooks/useAsyncData"; +import { MealIdea, MealIdeaInput } from "../../models/mealPlan"; +import { Link } from "react-router-dom"; + +interface Props { + communityId: string; +} + +const MealIdeasPanel = ({ communityId }: Props) => { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [showForm, setShowForm] = useState(false); + const [editingIdea, setEditingIdea] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + const { + data: ideas, + setData: setIdeas, + isLoading, + error, + } = useAsyncData( + () => + APIManager.getMealIdeas(communityId, { + search: debouncedSearch || undefined, + limit: 50, + }).then((r) => r.data), + [communityId, debouncedSearch] + ); + + const handleCreateIdea = async (input: MealIdeaInput) => { + try { + const newIdea = await APIManager.createMealIdea(communityId, input); + setIdeas([newIdea, ...(ideas || [])]); + setShowForm(false); + toast.success("Idea added"); + } catch (err) { + toastError(err, "Failed to create idea"); + } + }; + + const handleUpdateIdea = async (ideaId: string, input: Partial) => { + try { + const updatedIdea = await APIManager.updateMealIdea(communityId, ideaId, input); + setIdeas(ideas?.map((i) => (i.id === ideaId ? updatedIdea : i)) || null); + setEditingIdea(null); + toast.success("Idea updated"); + } catch (err) { + toastError(err, "Failed to update idea"); + } + }; + + const handleDeleteIdea = async (ideaId: string) => { + if (!confirm("Delete this idea?")) return; + + setDeletingId(ideaId); + try { + await APIManager.deleteMealIdea(communityId, ideaId); + setIdeas(ideas?.filter((i) => i.id !== ideaId) || null); + toast.success("Idea deleted"); + } catch (err) { + toastError(err, "Failed to delete idea"); + } finally { + setDeletingId(null); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Header with search and add button */} +
+
+ setSearch(e.target.value)} + /> + +
+ +
+ + {/* Ideas list */} + {ideas && ideas.length > 0 ? ( +
+ {ideas.map((idea) => ( + setEditingIdea(idea)} + onDelete={() => handleDeleteIdea(idea.id)} + /> + ))} +
+ ) : ( +
+

No meal ideas yet.

+

Add ideas for future meals to help with planning.

+
+ )} + + {/* Create/Edit form modal */} + {(showForm || editingIdea) && ( + + editingIdea ? handleUpdateIdea(editingIdea.id, input) : handleCreateIdea(input) + } + onClose={() => { + setShowForm(false); + setEditingIdea(null); + }} + /> + )} +
+ ); +}; + +// Idea card component +interface IdeaCardProps { + idea: MealIdea; + isDeleting: boolean; + onEdit: () => void; + onDelete: () => void; +} + +const IdeaCard = ({ idea, isDeleting, onEdit, onDelete }: IdeaCardProps) => { + return ( +
+
+
+

{idea.name}

+ {idea.comment &&

{idea.comment}

} + {idea.recipe && ( + + + {idea.recipe.title} + + )} + {idea.createdBy && ( +

Added by {idea.createdBy.username}

+ )} +
+
+ + +
+
+
+ ); +}; + +// Idea form modal +interface IdeaFormModalProps { + idea: MealIdea | null; + onSubmit: (input: MealIdeaInput) => void; + onClose: () => void; +} + +const IdeaFormModal = ({ idea, onSubmit, onClose }: IdeaFormModalProps) => { + const [name, setName] = useState(idea?.name || ""); + const [comment, setComment] = useState(idea?.comment || ""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!name.trim()) return; + setIsSubmitting(true); + await onSubmit({ name: name.trim(), comment: comment.trim() || undefined }); + setIsSubmitting(false); + }; + + return ( +
+
+

{idea ? "Edit Idea" : "Add Idea"}

+ +
+ + setName(e.target.value)} + placeholder="e.g., Grandma's pasta recipe" + maxLength={255} + autoFocus + /> +
+ +
+ +