Deuxième volet backend du tutoriel guidé, sur le socle de #42/#44 : permet à un admin de créer/éditer/supprimer les scénarios que `GET /reference/tours` sert au moteur front. `/admin/api/tours` (agrégateur `admin.routes.ts`, tout derrière `requireAdmin`) : - `GET /` — tous les scénarios (activés **et** désactivés) + étapes ordonnées. - `GET /targets` — le catalogue `TourTarget` pour le sélecteur de cible de l'éditeur. - `POST /` — crée un scénario vide (`key` + `name`) ; 409 `TOUR_KEY_ALREADY_IN_USE` (nouveau code 4025) sur une clé prise. - `PATCH /:id` — édite `name`/`enabled` **et remplace toute la liste d'étapes en bloc** (delete-all puis recreate avec `order` = index), même convention que `updateRecipe` pour les étapes d'une recette. `key` immuable. 404 `TOUR_NOT_FOUND` (id inconnu), 404 `TOUR_TARGET_NOT_FOUND` (nouveau code 4054, `targetKey` d'une étape hors catalogue — vérifié avant d'écrire, les étapes réelles restent intactes si la requête est rejetée). - `DELETE /:id` — 204 ; les étapes et les `TourProgress` des utilisateurs cascadent. Tests Mocha (`admin-tours.test.ts`) : 401 sans session admin sur chaque route ; list (activés+désactivés) ; targets ; create→patch pour remplir les étapes ; remplacement en bloc ; 409 clé dupliquée ; 404 cible inconnue (sans toucher aux étapes existantes) ; 404 id inconnu ; delete + cascade vérifiée ; un scénario désactivé disparaît de `/reference/tours` mais reste dans la liste admin. Refs #44 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
5.9 KiB
TypeScript
104 lines
5.9 KiB
TypeScript
/**
|
||
* Enumeration of every business/domain error code the API can return.
|
||
*
|
||
* This is the single source of truth for error identification across the
|
||
* whole monorepo: `apps/api` throws errors carrying one of these codes,
|
||
* and `apps/web` maps each code to a localized, user-facing label (see
|
||
* `apps/web/src/services/error-message.service.ts`, backed by i18next
|
||
* locale files under `apps/web/src/locales/`). Neither side should ever
|
||
* hardcode a raw error value that the other side has to guess at — always
|
||
* reference `ErrorCode.XXX`, never a bare number/string.
|
||
*
|
||
* Numeric values (not string codes): grouped by category so the number
|
||
* itself hints at the kind of failure, similar in spirit to HTTP status
|
||
* code families —
|
||
* - `4000`–`4099`: request validation
|
||
* - `4010`–`4019`: authentication
|
||
* - `4020`–`4029`: conflicting/invalid state transition
|
||
* - `4030`–`4039`: authorization (caller authenticated, but not allowed to)
|
||
* - `4040`–`4049`: not found
|
||
* - `5000`–`5099`: internal/unexpected
|
||
*
|
||
* When adding a new failure case in the API:
|
||
* 1. Add a new member here, in the right range, with the next free number.
|
||
* 2. Throw it via `HttpError` (`@batch-cooking/express-tools`).
|
||
* 3. Add its translation key to every locale file under
|
||
* `apps/web/src/locales` (one `translation.json` per language).
|
||
*/
|
||
export enum ErrorCode {
|
||
/** Request body/query failed zod schema validation. */
|
||
VALIDATION_ERROR = 4000,
|
||
/** Signup attempted with an email that already has a profile. */
|
||
EMAIL_ALREADY_IN_USE = 4001,
|
||
/** Login failed — wrong email or wrong password (never say which). */
|
||
INVALID_CREDENTIALS = 4010,
|
||
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
||
NOT_AUTHENTICATED = 4011,
|
||
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
|
||
ALREADY_HAS_HOUSE = 4020,
|
||
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
||
RECIPE_IN_USE = 4021,
|
||
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||
RECIPE_ALREADY_IMPORTED = 4022,
|
||
/** `POST /admin/api/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */
|
||
RETRAIN_ALREADY_RUNNING = 4023,
|
||
|
||
/** `POST /planning/items/leftover` asked to assign more portions than the source item's leftover pool still has available (`computeAvailableLeftover`, revalidated server-side). */
|
||
LEFTOVER_QUANTITY_EXCEEDED = 4024,
|
||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||
NOT_HOUSE_ADMIN = 4030,
|
||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||
NOT_RECIPE_AUTHOR = 4031,
|
||
/** No route/resource matches the request. */
|
||
NOT_FOUND = 4040,
|
||
/** The profile making the request has no household yet (`houseId` is `null`). */
|
||
HOUSE_NOT_FOUND = 4041,
|
||
/** A `dietId` was given that doesn't match any reference `Diet` row. */
|
||
DIET_NOT_FOUND = 4042,
|
||
/** One or more `allergyIds` don't match any reference `Allergy` row. */
|
||
ALLERGY_NOT_FOUND = 4043,
|
||
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
|
||
INVITE_CODE_NOT_FOUND = 4044,
|
||
/** `GET /recipes/:id`, `PATCH /recipes/:id` or `DELETE /recipes/:id` given an id that doesn't match any recipe. */
|
||
RECIPE_NOT_FOUND = 4045,
|
||
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
||
INGREDIENT_NOT_FOUND = 4046,
|
||
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
|
||
PLANNING_ITEM_NOT_FOUND = 4047,
|
||
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
|
||
UNIT_NOT_FOUND = 4048,
|
||
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
||
SOURCE_NOT_FOUND = 4049,
|
||
/** `POST /recipes/:id/steps/:stepId/corrections` given a `stepId` that doesn't belong to a recipe visible to the caller. */
|
||
STEP_NOT_FOUND = 4050,
|
||
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
|
||
TECH_STEP_NOT_FOUND = 4051,
|
||
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
|
||
UTENSIL_NOT_FOUND = 4052,
|
||
/** `POST /profile/tours/:tourKey/complete` (or an admin tour route) named a tour scenario key/id that doesn't match a `TourScenario` (and, for the public route, an *enabled* one). */
|
||
TOUR_NOT_FOUND = 4053,
|
||
/** An admin tour scenario step referenced a `targetKey` that doesn't match any seeded `TourTarget`. */
|
||
TOUR_TARGET_NOT_FOUND = 4054,
|
||
/** `POST /admin/api/tours` was given a `key` already used by another scenario. */
|
||
TOUR_KEY_ALREADY_IN_USE = 4025,
|
||
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
|
||
INVALID_CORRECTION_SPAN = 4002,
|
||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||
INTERNAL_ERROR = 5000,
|
||
}
|
||
|
||
/**
|
||
* Shape of every JSON error body the API returns, whatever the failure.
|
||
* Kept intentionally small and stable: `code` is what clients should
|
||
* branch on, `message` is a human-readable (English, developer-facing)
|
||
* description useful for logs/debugging — never shown to end users as-is,
|
||
* since end-user-facing text is localized client-side from `code`.
|
||
*/
|
||
export interface ApiErrorResponse {
|
||
/** Machine-readable error identifier — see {@link ErrorCode}. */
|
||
code: ErrorCode;
|
||
/** Developer-facing description (English). Not localized, not for UI display. */
|
||
message: string;
|
||
/** Present only for VALIDATION_ERROR: per-field error messages from zod. */
|
||
details?: Record<string, string[] | undefined>;
|
||
}
|