batchCooking/apps/web/src/api/client.ts
Nicolas 3abb7217dd
All checks were successful
CI / lint (push) Successful in 2m59s
CI / build (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m46s
CI / e2e (push) Successful in 9m33s
CI / test (push) Successful in 17m20s
CI / build-android (push) Has been skipped
feat(mobile): proxie le telechargement de l'APK via l'API (pas de CORS)
Suite au fix de /releases/latest (precedent), l'utilisateur voulait un
lien qui declenche le telechargement du .apk directement, sans quitter
l'appli web. Verifie en direct : fetch("https://git.dev.kyuno.fr/api/...")
depuis le navigateur echoue avec "Failed to fetch" (confirme via un essai
en mode no-cors : la requete reseau aboutit bien, seule la reponse est
bloquee) — Forgejo n'envoie aucun header CORS sur son API Releases,
publique ou non. Ca ne concerne pas que la page d'accueil : le hook de
verification de mise a jour (#66, useAppUpdateCheck) faisait exactement le
meme fetch cross-origin depuis la WebView native, et echouait donc tout
aussi silencieusement en production sans que personne ne puisse s'en
rendre compte (le hook avale toute erreur reseau par design).

Ajoute un petit module apps/api/src/modules/mobile/ (2 routes publiques,
aucune ne touche la base de donnees) :
- GET /mobile/latest-release -> { version } (proxy JSON, remplace le fetch
  direct de useAppUpdateCheck)
- GET /mobile/download -> redirection 302 vers l'asset .apk reel de la
  derniere release (ou vers la page /releases/latest en repli si
  Forgejo est injoignable ou qu'aucun .apk n'est encore attache)

Cote frontend : LandingPage.tsx et UpdateBanner.tsx pointent maintenant
vers ce proxy (`${API_BASE_URL}/mobile/download`, target="_blank") au lieu
d'une URL Forgejo directe — un clic, telechargement immediat, jamais de
navigation hors de l'appli. useAppUpdateCheck.ts appelle desormais
apiClient.getLatestMobileVersion() au lieu d'un fetch direct.

Verifie en local (sans Postgres, ces routes n'en ont pas besoin) contre
les vraies donnees Forgejo :
  GET /mobile/latest-release -> {"version":"v0.2.0"}
  GET /mobile/download -> 302, Location vers le vrai .apk signe

pnpm lint / tsc (api + web) / pnpm --filter web build : tous verts. Le
scenario Cypress "offers the Android app download" echoue encore en local
(meme souci pre-existant deja documente sur la PR precedente, confirme
independant du code par CI reel) ; mis a jour pour asserter le nouveau
href relatif.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:40:06 +02:00

516 lines
22 KiB
TypeScript

import {
type AddPlanningItemInput,
type AllergyView,
type ApiErrorResponse,
type AssignLeftoverInput,
type AuthResponse,
type BrowsableSourceItemView,
type CreateRecipeInput,
type DietView,
ErrorCode,
type HouseView,
type IngredientView,
type LoginInput,
type OptimizedCookingPlanView,
type PlanningItemView,
type PlanningView,
type PreferencesView,
type RecipeImportDraftView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type SafeUserProfile,
type ShoppingListView,
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
type ThemePreference,
type TourScenarioView,
type UnitView,
type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared";
import { noopTokenStore, type TokenStore } from "./token-store";
/**
* Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`).
* Defaults to `""` (same origin as the page) — correct for the production
* Docker image, where the API serves this very frontend build (see
* apps/api/Dockerfile), so a relative path already reaches it. Native dev
* (`pnpm dev:web`) overrides this via `VITE_API_URL=http://localhost:3000`
* in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
* are on different origins there.
*/
export const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
/**
* The token store {@link ApiClient} consults on every request. Defaults to
* the browser no-op (the httpOnly cookie carries the session); the native
* shell replaces it via {@link setTokenStore} with a persistent,
* `@capacitor/preferences`-backed store. Module-level singleton, same shape
* as {@link API_BASE_URL} and the exported `apiClient`.
*/
let activeTokenStore: TokenStore = noopTokenStore;
/**
* Swaps in a non-browser {@link TokenStore}. Called once at startup by the
* Capacitor bootstrap (issue #39, later phase) when
* `Capacitor.isNativePlatform()` — never by the web build, which keeps the
* no-op and its cookie.
*/
export function setTokenStore(store: TokenStore): void {
activeTokenStore = store;
}
/**
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
* status. Carries the same {@link ErrorCode} the API returned, so callers
* can branch on `error.code` (and UI code can look up its label via
* `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
*/
export class ApiError extends Error {
/** HTTP status code of the failed response. */
public readonly status: number;
/** Machine-readable error code — see {@link ErrorCode}. */
public readonly code: ErrorCode;
/** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
public readonly fieldErrors?: Record<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "ApiError";
this.status = status;
this.code = body.code;
this.fieldErrors = body.details;
}
}
/**
* Thin fetch wrapper around the auth endpoints. A class (rather than plain
* functions) so it reads as a cohesive service and stays easy to extend
* (e.g. swapping the transport, adding request interceptors) without
* touching every call site. Used as a single shared instance (`apiClient`,
* exported below) — it's stateless, so there's no reason for more than one.
*/
export class ApiClient {
/**
* Performs a JSON request against the API and returns the parsed body.
*
* @throws {ApiError} if the response status is not in the 2xx range.
*/
private async _request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
try {
// `null` for the browser (no-op store — the cookie below is the
// session); a real JWT only in the native shell, where the cookie
// can't travel. See `token-store.ts`.
const token = await activeTokenStore.get();
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
// Required for the httpOnly session cookie to be sent/received — the
// API and the web app run on different origins.
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
// Fallback for a response that couldn't even be parsed as JSON — no
// hardcoded string, always the real enum member.
throw new ApiError(
response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
// 204 No Content (e.g. logout) has no body to parse.
if (response.status === 204) {
return undefined as TResponseBody;
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller already handles/surfaces API failures
// its own way (an `ApiError` catch, a `.catch()` chain — see
// `error-message.service.ts`), this is just the one place the
// fetch/`await` itself has to sit inside a try/catch per the repo's
// convention.
throw err;
}
}
/**
* Creates a profile and starts a session — no household yet, that's an
* optional step of the onboarding wizard. Persists the returned token via
* the active {@link TokenStore} (no-op in the browser, where the httpOnly
* cookie is what actually keeps the session).
*/
public async signup(input: SignupInput): Promise<SafeUserProfile> {
const { token, ...profile } = await this._request<AuthResponse>("/auth/signup", {
method: "POST",
body: JSON.stringify(input),
});
await activeTokenStore.set(token);
return profile;
}
/** Verifies credentials and starts a session — see {@link signup} re: the token. */
public async login(input: LoginInput): Promise<SafeUserProfile> {
const { token, ...profile } = await this._request<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(input),
});
await activeTokenStore.set(token);
return profile;
}
/** Ends the current session, and drops any stored token. */
public async logout(): Promise<void> {
await this._request("/auth/logout", { method: "POST" });
await activeTokenStore.clear();
}
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<SafeUserProfile> {
return this._request("/auth/me");
}
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. Drops any stored token on success. */
public async deleteAccount(password: string): Promise<void> {
await this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
await activeTokenStore.clear();
}
/**
* Fetches the current user's household's planning covering `date`
* (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if
* there isn't one for that week yet.
*/
public getPlanningForWeek(date: string): Promise<PlanningView | null> {
return this._request(`/planning?date=${date}`);
}
/**
* Adds a recipe to one (day, meal) slot of the household's planning for
* the week containing `input.date`, creating that week's planning on the
* fly if it doesn't exist yet — rejects with `HOUSE_NOT_FOUND` (no
* household) or `RECIPE_NOT_FOUND` (the recipe isn't visible to the
* caller).
*/
public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> {
return this._request("/planning/items", { method: "POST", body: JSON.stringify(input) });
}
/** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */
public removePlanningItem(id: number): Promise<void> {
return this._request(`/planning/items/${id}`, { method: "DELETE" });
}
/** Throws away planning item `id`'s whole remaining leftover pool (`discardedPortions += available`) — idempotent, returns the updated item. Rejects with `PLANNING_ITEM_NOT_FOUND`. */
public discardLeftover(id: number): Promise<PlanningItemView> {
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
}
/** Serves `portions` of a source item's leftover pool on a slot, creating a "leftovers" planning item (same recipe as the source). Rejects with `LEFTOVER_QUANTITY_EXCEEDED` (over the pool) or `PLANNING_ITEM_NOT_FOUND`. */
public assignLeftover(input: AssignLeftoverInput): Promise<PlanningItemView> {
return this._request("/planning/items/leftover", {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
* `formatDateOnly`) — every ingredient across that week's planned
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
* `null`: no household or nothing planned that week both come back as a
* normal list with an empty `items` array.
*/
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
return this._request(`/shopping-list?date=${date}`);
}
/**
* Fetches the current user's household's optimized cooking plan for the
* week covering `date` (`YYYY-MM-DD`) — every recipe planned that week
* reorganized into ordered phases (shared prep pooled, passive cooks
* floated into the background). Like {@link getShoppingListForWeek} and
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
* household or nothing planned both come back as a normal plan with
* empty `recipes`/`phases`.
*/
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
return this._request(`/cooking-session?date=${date}`);
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this._request("/reference/diets");
}
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
public getAllergies(): Promise<AllergyView[]> {
return this._request("/reference/allergies");
}
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
public getIngredients(): Promise<IngredientView[]> {
return this._request("/reference/ingredients");
}
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
public getUnits(): Promise<UnitView[]> {
return this._request("/reference/units");
}
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
public getTechSteps(): Promise<TechStepView[]> {
return this._request("/reference/tech-steps");
}
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
public getUtensils(): Promise<UtensilView[]> {
return this._request("/reference/utensils");
}
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources");
}
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
public browseSource(
sourceKey: string,
params: { query?: string; cursor?: string } = {},
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const search = new URLSearchParams();
if (params.query) search.set("query", params.query);
if (params.cursor) search.set("cursor", params.cursor);
const queryString = search.toString();
return this._request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
}
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
return this._request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
}
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
public importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
): Promise<RecipeView> {
return this._request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
* optionally narrowed further — `search` (name substring),
* `suitableForHousehold` (the planning recipe picker's "convient à tout
* le foyer" toggle), `ingredientIds`/`dietIds` (that same picker's
* ingredient/regime filters — a recipe must carry *every* id listed).
*/
public listRecipes(
tab: RecipeTab,
filters: {
search?: string;
suitableForHousehold?: boolean;
ingredientIds?: number[];
dietIds?: number[];
} = {},
): Promise<RecipeSummaryView[]> {
const params = new URLSearchParams({ tab });
if (filters.search) params.set("search", filters.search);
if (filters.suitableForHousehold) params.set("suitableForHousehold", "true");
for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id));
for (const id of filters.dietIds ?? []) params.append("dietIds", String(id));
return this._request(`/recipes?${params.toString()}`);
}
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
public getRecipe(id: number): Promise<RecipeView> {
return this._request(`/recipes/${id}`);
}
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
return this._request("/recipes", { method: "POST", body: JSON.stringify(input) });
}
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
return this._request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
}
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
public deleteRecipe(id: number): Promise<void> {
return this._request(`/recipes/${id}`, { method: "DELETE" });
}
/** Favorites a recipe for the current user — idempotent. */
public addFavoriteRecipe(id: number): Promise<void> {
return this._request(`/recipes/${id}/favorite`, { method: "POST" });
}
/** Unfavorites a recipe for the current user — idempotent. */
public removeFavoriteRecipe(id: number): Promise<void> {
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
}
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
public submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
): Promise<SubmitTechStepCorrectionResult> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Every correction submitted so far for `stepId`, most recent first. */
public getTechStepCorrections(
recipeId: number,
stepId: number,
): Promise<StepTechStepCorrectionView[]> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
}
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> {
return this._request("/house/current");
}
/** Renames the current user's household. */
public renameHouse(name: string): Promise<HouseView> {
return this._request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
}
/** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */
public createHouse(name: string): Promise<HouseView> {
return this._request("/house", { method: "POST", body: JSON.stringify({ name }) });
}
/** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */
public joinHouse(inviteCode: string): Promise<HouseView> {
return this._request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) });
}
/** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */
public leaveHouse(): Promise<void> {
return this._request("/house/leave", { method: "POST" });
}
/** Deletes the current user's household outright — every member loses it. Admin-only. */
public deleteHouse(): Promise<void> {
return this._request("/house/current", { method: "DELETE" });
}
/** Removes one specific member from the current user's household. Admin-only. */
public removeHouseMember(memberId: number): Promise<HouseView> {
return this._request(`/house/members/${memberId}`, { method: "DELETE" });
}
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
public getHouseSourceIds(): Promise<number[]> {
return this._request("/house/current/sources");
}
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
return this._request("/house/current/sources", {
method: "PATCH",
body: JSON.stringify({ sourceIds }),
});
}
/** Sets (or clears, with `null`) the current user's dietary regime. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
return this._request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
}
/** Fetches the current user's selected allergen ids. */
public getAllergyIds(): Promise<number[]> {
return this._request("/profile/allergies");
}
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */
public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
return this._request("/profile/allergies", {
method: "PATCH",
body: JSON.stringify({ allergyIds }),
});
}
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
public getDislikedIngredientIds(): Promise<number[]> {
return this._request("/profile/disliked-ingredients");
}
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
return this._request("/profile/disliked-ingredients", {
method: "PATCH",
body: JSON.stringify({ dislikedIngredientIds }),
});
}
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
public getPreferences(): Promise<PreferencesView> {
return this._request("/preferences");
}
/** Sets the current user's theme preference. */
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
}
/** Every enabled guided-tour scenario with its steps — public reference data (issue #42/#44). */
public getTours(): Promise<TourScenarioView[]> {
return this._request("/reference/tours");
}
/** The `key`s of the tour scenarios the current user has already seen (completed or skipped). */
public async getCompletedTourKeys(): Promise<string[]> {
const { completedTourKeys } = await this._request<{ completedTourKeys: string[] }>(
"/profile/tours",
);
return completedTourKeys;
}
/** Marks a tour scenario as seen — `skipped` distinguishes a dismissal from a full run (analytics only; both stop the auto-trigger). Idempotent. */
public completeTour(tourKey: string, skipped: boolean): Promise<void> {
return this._request(`/profile/tours/${encodeURIComponent(tourKey)}/complete`, {
method: "POST",
body: JSON.stringify({ skipped }),
});
}
/**
* The latest tagged Android release's version (e.g. `"v1.4.2"`) — used by
* {@link useAppUpdateCheck}. Proxied through this API rather than fetched
* directly from Forgejo: Forgejo's Releases API sends no CORS headers, so
* a browser-side `fetch()` to it fails outright. Public — no session
* required.
*/
public getLatestMobileVersion(): Promise<{ version: string }> {
return this._request("/mobile/latest-release");
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */
export const apiClient = new ApiClient();