diff --git a/.agents/skills/building-blocks/SKILL.md b/.agents/skills/building-blocks/SKILL.md index 0fe041e..c8e610f 100644 --- a/.agents/skills/building-blocks/SKILL.md +++ b/.agents/skills/building-blocks/SKILL.md @@ -15,12 +15,13 @@ Read the rule file for each block you are about to implement. The summaries belo ### Data Fetching -| Block | Layer | Summary | File | -| ----------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| `mutation-hook` | `providers/` | Server write via `useMutation` with domain error translation and cache invalidation. Returns `[handler, isPending]`. | `rules/mutation-hook.md` | -| `query-options-factory` | `lib/api/` | Reusable `queryOptions()` factories — no hooks, no `useQuery`. Hook composition belongs in `providers/`. | `rules/query-options-factory.md` | -| `query-keys-factory` | `lib/api/` | Hierarchical `as const` key tuples per resource. Single source of truth for cache invalidation. | `rules/query-keys-factory.md` | -| `dto-model` | `lib/api/` | TypeScript interfaces mirroring the raw API wire format. No transformations. | `rules/dto-model.md` | +| Block | Layer | Summary | File | +| -------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `mutation-options-factory` | `lib/api/` | Reusable `mutationOptions()` factories with domain error translation. Composed by `mutation-hook` in `providers/`. | `rules/mutation-options-factory.md` | +| `mutation-hook` | `providers/` | Server write via `useMutation` composed from `mutation-options-factory`, with cache invalidation. Returns `[handler, isPending]`. | `rules/mutation-hook.md` | +| `query-options-factory` | `lib/api/` | Reusable `queryOptions()` factories — no hooks, no `useQuery`. Hook composition belongs in `providers/`. | `rules/query-options-factory.md` | +| `query-keys-factory` | `lib/api/` | Hierarchical `as const` key tuples per resource. Single source of truth for cache invalidation. | `rules/query-keys-factory.md` | +| `dto-model` | `lib/api/` | TypeScript interfaces mirroring the raw API wire format. No transformations. | `rules/dto-model.md` | ### State Management diff --git a/.agents/skills/building-blocks/rules/mutation-hook.md b/.agents/skills/building-blocks/rules/mutation-hook.md index 55cb393..8b27ac3 100644 --- a/.agents/skills/building-blocks/rules/mutation-hook.md +++ b/.agents/skills/building-blocks/rules/mutation-hook.md @@ -1,87 +1,50 @@ --- title: Mutation Hook category: Data Fetching -layer: lib/api/ -composedWith: query-keys-factory +layer: providers/ +composedWith: mutation-options-factory, query-keys-factory --- ## Mutation Hook -Server write operations wrapped in `useMutation` with domain error translation and cache invalidation via `query-keys-factory`. Returns a `[handler, isPending]` tuple: the handler encapsulates the async call + error mapping. +Server write hook composed from `mutation-options-factory`. Spreads the factory options and adds e.g. cache invalidation or custom error handling. Returns a `[handler, isPending]` tuple. Lives in `providers/` — the data access gateway for the feature slice. ### Constraints -- Error classes are co-located with the hook — they're reusable across feature slices, so they live with the mutation definition. -- Keep the handler's error mapping exhaustive: catch API errors, translate to typed domain errors, fall back to `UnknownError`. Components should never see raw HTTP errors. -- Invalidation belongs here — the mutation knows what resource it wrote to, so it owns cache coherence via `query-keys-factory`. -- ALWAYS use `use` prefix in names — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`. +- ALWAYS use `use` prefix — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts` +- Compose from a `mutation-options-factory` via spread +- Invalidation belongs here — the hook knows what queries to invalidate via `query-keys-factory` +- When the consumer needs to extend `onSuccess` (e.g., close a modal after mutation), spread and chain the factory's callbacks ### Example ```tsx +// src/features/carts/providers/use-add-to-cart-mutation.ts import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { httpService } from "@/lib/http"; // project HTTP client -import { Logger } from "@/lib/logger"; -import { UnknownError } from "@/lib/types/unknown-error"; -import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; // query-keys-factory +import { addToCartMutationOptions } from "@/lib/api/carts/add-to-cart/add-to-cart-mutation"; +import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; -interface AddToCartPayload { - productId: number; - quantity?: number; -} - -interface AddToCartDto { - cartId: number; - payload: AddToCartPayload; -} - -export const useAddToCartMutation = () => { +export const useAddToCartMutation = (cartId: number) => { const queryClient = useQueryClient(); - const { mutateAsync, isPending } = useMutation({ - mutationFn: (body) => - httpService.put(`carts/${body.cartId}`, { - productId: body.payload.productId, - quantity: body.payload.quantity, - }), - onSuccess: (_data, variables) => { + const { mutateAsync, isPending } = useMutation({ + ...addToCartMutationOptions, + onSuccess: () => { queryClient.invalidateQueries({ - queryKey: cartQueryKeys.detail(variables.cartId), + queryKey: cartQueryKeys.detail(cartId), }); }, }); - const handler = async (cartId: number, payload: AddToCartPayload) => { - try { - return await mutateAsync({ cartId, payload }); - } catch (e) { - Logger.error("Failed to add item to cart", e as Error); - - if (httpService.isError(e) && e.message === "Unknown product") { - throw new UnknownProductError(); - } - if (httpService.isError(e) && e.message === "Product not available") { - throw new ProductNotAvailableError(); - } - - throw new UnknownError(); - } + const handler = async (payload: AddToCartPayload) => { + return mutateAsync({ cartId, payload }); }; return [handler, isPending] as const; }; +``` -export class UnknownProductError extends Error { - constructor() { - super("Unknown product"); - this.name = "UnknownProductError"; - } -} +### References -export class ProductNotAvailableError extends Error { - constructor() { - super("Product not available"); - this.name = "ProductNotAvailableError"; - } -} -``` +- `rules/mutation-options-factory.md` — raw factory pattern in `lib/api/` +- `rules/query-keys-factory.md` — cache key structure for invalidation diff --git a/.agents/skills/building-blocks/rules/mutation-options-factory.md b/.agents/skills/building-blocks/rules/mutation-options-factory.md new file mode 100644 index 0000000..822f296 --- /dev/null +++ b/.agents/skills/building-blocks/rules/mutation-options-factory.md @@ -0,0 +1,87 @@ +--- +title: Mutation Options Factory +category: Data Fetching +layer: lib/api/ +composedWith: mutation-hook +--- + +## Mutation Options Factory + +Reusable `mutationOptions()` factory for a single API write operation. Contains `mutationFn` with domain error translation and logging. + +### Constraints + +- NEVER use `use` prefix — these are factories, not hooks. Name: `xxxMutationOptions`. File: `xxx-mutation.ts` +- One factory per API endpoint. Co-locate with related query options in the same resource directory +- Co-locate mutation types (payload & dto) and error classes with the factory. Map API errors exhaustively in `mutationFn` — translate, log, fall back to `UnknownError` + +### Example + +```tsx +// src/lib/api/carts/add-to-cart/add-to-cart-mutation.ts +import { mutationOptions } from "@tanstack/react-query"; +import { httpService } from "@/lib/http"; +import { Logger } from "@/lib/logger"; +import { UnknownError } from "@/lib/types/unknown-error"; + +interface AddToCartPayload { + productId: number; + quantity?: number; +} + +interface AddToCartDto { + cartId: number; + payload: AddToCartPayload; +} + +export const addToCartMutationOptions = mutationOptions({ + mutationFn: async (body: AddToCartDto): Promise => { + try { + await httpService.put( + `carts/${body.cartId}`, + body.payload + ); + } catch (e) { + Logger.error("Failed to add item to cart", e as Error); + if (httpService.isError(e) && e.message === "Unknown product") { + throw new UnknownProductError(); + } + if (httpService.isError(e) && e.message === "Product not available") { + throw new ProductNotAvailableError(); + } + throw new UnknownError(); + } + }, +}); + +export class UnknownProductError extends Error { + constructor() { + super("Unknown product"); + this.name = "UnknownProductError"; + } +} + +export class ProductNotAvailableError extends Error { + constructor() { + super("Product not available"); + this.name = "ProductNotAvailableError"; + } +} +``` + +```tsx +// ❌ Wrong — invalidation in factory +export const addToCartMutationOptions = mutationOptions({ + mutationFn: async (body: AddToCartDto): Promise => { + await httpService.put(`carts/${body.cartId}`, body.payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cartQueryKeys.all }); + }, +}); +``` + +### References + +- `@src/lib/api/` — resource-organized API directory +- `rules/mutation-hook.md` — composing hook pattern in `providers/` diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 3ad2eb9..b6c273e 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -10,7 +10,7 @@ paths: - `src/features/` — feature modules using feature slice architecture - `src/lib/` — shared utilities, components, HTTP client, i18n, routing, theme - . `src/lib/api/` — centralized API layer: queryOptions factories, mutations, DTOs by resource + . `src/lib/api/` — centralized API layer: queryOptions factories, mutationOptions factories, DTOs by resource - `src/lib/permissions/` — permission gating utilities. Available permissions: `@/features/authv2/models/permissions` - `src/pages/` — route-level page components composing features - `src/app/` — app-level config (App.tsx, Providers.tsx) diff --git a/docs/architecture.md b/docs/architecture.md index 176f839..82b34d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,12 +73,12 @@ Each feature follows feature slice architecture patterns with four layers: **File naming in `src/lib/api/`:** -| Type | Suffix | Example | -| ------------- | ---------------- | ------------------------- | -| Query | `-query.ts` | `cart-products-query.ts` | -| Mutation hook | `-mutation.ts` | `add-to-cart-mutation.ts` | -| DTO interface | `-dto.ts` | `cart-product-dto.ts` | -| Query keys | `-query-keys.ts` | `cart-query-keys.ts` | +| Type | Suffix | Example | +| ---------------- | ---------------- | ------------------------- | +| Query options | `-query.ts` | `cart-products-query.ts` | +| Mutation options | `-mutation.ts` | `add-to-cart-mutation.ts` | +| DTO interface | `-dto.ts` | `cart-product-dto.ts` | +| Query keys | `-query-keys.ts` | `cart-query-keys.ts` | ## Key Patterns diff --git a/src/features/carts/providers/use-add-to-cart-mutation.ts b/src/features/carts/providers/use-add-to-cart-mutation.ts index 8c78095..aae25f2 100644 --- a/src/features/carts/providers/use-add-to-cart-mutation.ts +++ b/src/features/carts/providers/use-add-to-cart-mutation.ts @@ -1,5 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; +import { + addToCartMutationOptions, + type AddToCartPayload, +} from "@/lib/api/carts/{cart-id}/add-to-cart-mutation"; + export { - useAddToCartMutation, UnknownProductError, ProductNotAvailableError, } from "@/lib/api/carts/{cart-id}/add-to-cart-mutation"; + +export const useAddToCartMutation = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation({ + ...addToCartMutationOptions, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all }); + }, + }); + + const handler = (cartId: string, payload: AddToCartPayload) => + mutateAsync({ cartId, payload }); + + return [handler, isPending] as const; +}; diff --git a/src/features/carts/providers/use-clear-cart-mutation.ts b/src/features/carts/providers/use-clear-cart-mutation.ts index 7f4702b..c2ad216 100644 --- a/src/features/carts/providers/use-clear-cart-mutation.ts +++ b/src/features/carts/providers/use-clear-cart-mutation.ts @@ -1 +1,17 @@ -export { useClearCartMutation } from "@/lib/api/carts/{cart-id}/clear-cart-mutation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; +import { clearCartMutationOptions } from "@/lib/api/carts/{cart-id}/clear-cart-mutation"; + +export const useClearCartMutation = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation({ + ...clearCartMutationOptions, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all }); + }, + }); + + return [mutateAsync, isPending] as const; +}; diff --git a/src/features/carts/providers/use-purchase-mutation.ts b/src/features/carts/providers/use-purchase-mutation.ts index dfc7310..bcae855 100644 --- a/src/features/carts/providers/use-purchase-mutation.ts +++ b/src/features/carts/providers/use-purchase-mutation.ts @@ -1 +1,11 @@ -export { usePurchaseMutation } from "@/lib/api/carts/{cart-id}/purchase-mutation"; +import { useMutation } from "@tanstack/react-query"; + +import { purchaseMutationOptions } from "@/lib/api/carts/{cart-id}/purchase-mutation"; + +export const usePurchaseMutation = () => { + const { mutateAsync, isPending } = useMutation({ + ...purchaseMutationOptions, + }); + + return [mutateAsync, isPending] as const; +}; diff --git a/src/features/marketing/providers/use-rate-product-mutation.ts b/src/features/marketing/providers/use-rate-product-mutation.ts index 6752fa9..dcb123d 100644 --- a/src/features/marketing/providers/use-rate-product-mutation.ts +++ b/src/features/marketing/providers/use-rate-product-mutation.ts @@ -1 +1,59 @@ -export { useRateProductMutation } from "@/lib/api/marketing/{product-id}/use-rate-product-mutation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys"; +import type { MarketingProductDto } from "@/lib/api/marketing/{product-id}/marketing-product-dto"; +import { + rateProductMutationOptions, + type RateProductDto, +} from "@/lib/api/marketing/{product-id}/rate-product-mutation"; + +interface MutationContext { + previous: MarketingProductDto | undefined; + productId: string; +} + +export const useRateProductMutation = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation< + MarketingProductDto, + Error, + RateProductDto, + MutationContext + >({ + ...rateProductMutationOptions, + onMutate: async ({ productId, rating }) => { + const queryKey = marketingQueryKeys.product(productId); + await queryClient.cancelQueries({ queryKey }); + + const previous = queryClient.getQueryData(queryKey); + + if (previous) { + const { rate: oldRate, count: oldCount } = previous.rating; + const newCount = oldCount + 1; + const newRate = + Math.round(((oldRate * oldCount + rating) / newCount) * 100) / 100; + + queryClient.setQueryData(queryKey, { + ...previous, + rating: { rate: newRate, count: newCount }, + }); + } + + return { previous, productId }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData( + marketingQueryKeys.product(context.productId), + context.previous + ); + } + }, + }); + + const handler = (productId: string, rating: number) => + mutateAsync({ productId, rating }); + + return [handler, isPending] as const; +}; diff --git a/src/lib/api/carts/{cart-id}/add-to-cart-mutation.ts b/src/lib/api/carts/{cart-id}/add-to-cart-mutation.ts index 8981846..ce760da 100644 --- a/src/lib/api/carts/{cart-id}/add-to-cart-mutation.ts +++ b/src/lib/api/carts/{cart-id}/add-to-cart-mutation.ts @@ -1,51 +1,41 @@ -import { useMutation } from "@tanstack/react-query"; +import { mutationOptions } from "@tanstack/react-query"; import { httpService } from "@/lib/http"; import { Logger } from "@/lib/logger"; import { UnknownError } from "@/lib/types/unknown-error"; -interface AddToCartPayload { +export interface AddToCartPayload { productId: string; quantity?: number; } -interface AddToCartDto { +export interface AddToCartDto { cartId: string; payload: AddToCartPayload; } -export const useAddToCartMutation = () => { - const { mutateAsync, isPending } = useMutation({ - mutationFn: (body) => - httpService.put(`carts/${body.cartId}`, { - productId: body.payload.productId, - quantity: body.payload.quantity, - }), - }); - - const handler = async (cartId: string, payload: AddToCartPayload) => { +export const addToCartMutationOptions = mutationOptions({ + mutationFn: async (body: AddToCartDto): Promise => { try { - return await mutateAsync({ cartId, payload }); + await httpService.put( + `carts/${body.cartId}`, + body.payload + ); } catch (e) { Logger.error( "An error occurred during adding an item to the cart", e as Error ); - if (httpService.isError(e) && e.message === "Unknown product") { throw new UnknownProductError(); } - if (httpService.isError(e) && e.message === "Product not available") { throw new ProductNotAvailableError(); } - throw new UnknownError(); } - }; - - return [handler, isPending] as const; -}; + }, +}); export class UnknownProductError extends Error { constructor() { diff --git a/src/lib/api/carts/{cart-id}/clear-cart-mutation.ts b/src/lib/api/carts/{cart-id}/clear-cart-mutation.ts index 1260265..df1ef0d 100644 --- a/src/lib/api/carts/{cart-id}/clear-cart-mutation.ts +++ b/src/lib/api/carts/{cart-id}/clear-cart-mutation.ts @@ -1,34 +1,20 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { mutationOptions } from "@tanstack/react-query"; -import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; import { httpService } from "@/lib/http"; import { Logger } from "@/lib/logger"; +import { UnknownError } from "@/lib/types/unknown-error"; -interface IClearCartValues { +interface ClearCartDto { cartId: string; } -export const useClearCartMutation = () => { - const queryClient = useQueryClient(); - const { mutateAsync, isPending } = useMutation< - void, - unknown, - IClearCartValues - >({ - mutationFn: (body) => httpService.delete(`carts/${body.cartId}`), - }); - - const handler = (body: IClearCartValues) => { - return mutateAsync(body) - .then(async () => { - await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all }); - }) - .catch((e) => { - Logger.error("An error occurred during clearing the cart", e as Error); - - throw e; - }); - }; - - return [handler, isPending] as const; -}; +export const clearCartMutationOptions = mutationOptions({ + mutationFn: async (body: ClearCartDto): Promise => { + try { + await httpService.delete(`carts/${body.cartId}`); + } catch (e) { + Logger.error("An error occurred during clearing the cart", e as Error); + throw new UnknownError(); + } + }, +}); diff --git a/src/lib/api/carts/{cart-id}/purchase-mutation.ts b/src/lib/api/carts/{cart-id}/purchase-mutation.ts index 4e8ba55..a69b9b1 100644 --- a/src/lib/api/carts/{cart-id}/purchase-mutation.ts +++ b/src/lib/api/carts/{cart-id}/purchase-mutation.ts @@ -1,18 +1,8 @@ -import { useState } from "react"; +import { mutationOptions } from "@tanstack/react-query"; -// todo: use msw to mock this feature -export const usePurchaseMutation = () => { - const [isLoading, setIsLoading] = useState(false); - - const purchase = async () => { - return new Promise((resolve) => { - setIsLoading(true); - setTimeout(() => { - resolve(); - setIsLoading(false); - }, 400); - }); - }; - - return [purchase, isLoading] as const; -}; +// AIDEV-NOTE: stub implementation — replace mutationFn with real HTTP call once MSW mock is ready +export const purchaseMutationOptions = mutationOptions({ + mutationFn: async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }, +}); diff --git a/src/lib/api/marketing/{product-id}/rate-product-mutation.ts b/src/lib/api/marketing/{product-id}/rate-product-mutation.ts new file mode 100644 index 0000000..cebc85c --- /dev/null +++ b/src/lib/api/marketing/{product-id}/rate-product-mutation.ts @@ -0,0 +1,29 @@ +import { mutationOptions } from "@tanstack/react-query"; + +import { httpService } from "@/lib/http"; +import { Logger } from "@/lib/logger"; +import { UnknownError } from "@/lib/types/unknown-error"; + +import type { MarketingProductDto } from "./marketing-product-dto"; + +export interface RateProductDto { + productId: string; + rating: number; +} + +export const rateProductMutationOptions = mutationOptions({ + mutationFn: async ({ + productId, + rating, + }: RateProductDto): Promise => { + try { + return await httpService.patch( + `marketing/products/${productId}/rate`, + { rating } + ); + } catch (e) { + Logger.error("An error occurred during rating the product", e as Error); + throw new UnknownError(); + } + }, +}); diff --git a/src/lib/api/marketing/{product-id}/use-rate-product-mutation.ts b/src/lib/api/marketing/{product-id}/use-rate-product-mutation.ts deleted file mode 100644 index d9f71b3..0000000 --- a/src/lib/api/marketing/{product-id}/use-rate-product-mutation.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys"; -import { httpService } from "@/lib/http"; -import { Logger } from "@/lib/logger"; -import { UnknownError } from "@/lib/types/unknown-error"; - -import type { MarketingProductDto } from "./marketing-product-dto"; - -interface RateProductDto { - productId: string; - rating: number; -} - -interface MutationContext { - previous: MarketingProductDto | undefined; - productId: string; -} - -export const useRateProductMutation = () => { - const queryClient = useQueryClient(); - - const { mutateAsync, isPending } = useMutation< - MarketingProductDto, - unknown, - RateProductDto, - MutationContext - >({ - mutationFn: ({ productId, rating }) => - httpService.patch( - `marketing/products/${productId}/rate`, - { rating } - ), - onMutate: async ({ productId, rating }) => { - const queryKey = marketingQueryKeys.product(productId); - await queryClient.cancelQueries({ queryKey }); - - const previous = queryClient.getQueryData(queryKey); - - if (previous) { - const { rate: oldRate, count: oldCount } = previous.rating; - const newCount = oldCount + 1; - const newRate = - Math.round(((oldRate * oldCount + rating) / newCount) * 100) / 100; - - queryClient.setQueryData(queryKey, { - ...previous, - rating: { rate: newRate, count: newCount }, - }); - } - - return { previous, productId }; - }, - onError: (_err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData( - marketingQueryKeys.product(context.productId), - context.previous - ); - } - }, - }); - - const handler = async (productId: string, rating: number) => { - try { - return await mutateAsync({ productId, rating }); - } catch (e) { - Logger.error("An error occurred during rating the product", e as Error); - throw new UnknownError(); - } - }; - - return [handler, isPending] as const; -};