## Context The buyer center renders product and shop favorites from `MOCK_FAVORITES`; product and store detail pages toggle a local boolean that resets on navigation or reload. Catalog and store data are already live, authentication is customer-scoped, and the fixed-data adapter must continue implementing the full shared client surface. ## Goals / Non-Goals **Goals:** - Persist one customer favorite for a product or shop and enforce ownership and uniqueness in Postgres. - Support efficient buyer-center lists, previews/counts, and detail-page favorite state without browser joins or N+1 API calls. - Make add/remove safe to retry and keep live and fixed adapters behaviorally compatible. **Non-Goals:** - Folders, notes, sharing, alerts, recommendation signals, merchant analytics, or fixture-data migration. ## Decisions ### Model two explicit nullable targets in one table `favorites` contains `id`, `user_id`, nullable `product_id`, nullable `shop_id`, and `created_at`. A check constraint requires exactly one target. Two partial unique indexes enforce `(user_id, product_id)` and `(user_id, shop_id)` uniqueness; foreign keys cascade on target or user deletion. This is preferred to a polymorphic `target_type/target_id`, which cannot enforce both target foreign keys. Separate product/shop tables would duplicate ownership, timestamps, repositories, and list composition for no stronger invariant. ### Use idempotent target-resource mutations Authenticated customer routes are: - `GET /api/favorites?kind=product|shop&target_id=&page=&per_page=` - `PUT /api/favorites/products/{product_id}` and matching `DELETE` - `PUT /api/favorites/shops/{shop_id}` and matching `DELETE` `PUT` returns the existing favorite when already present. `DELETE` succeeds with no body even when absent. Target-specific paths prevent malformed mixed-target request bodies and make detail-page retry behavior deterministic. ### Return paginated, hydrated discriminated unions The shared `Favorite` type is a `product` or `shop` discriminated union containing favorite metadata and a current public target summary. Product summaries include IDs/slug/localized name/image and lowest active SKU price/currency; shop summaries include the public shop profile fields required by the current card. SQL aggregates the product price in the list query, avoiding one API call per favorite. Only published products belonging to active shops and active shops themselves appear in lists or can be added. An unavailable target's row remains dormant and reappears if the target is republished; physical target deletion cascades the favorite. `total` counts only visible rows. ### Treat favorite state as authenticated UI state Product and store detail load a filtered favorite query when signed in, preserve the return URL when sign-in is required, and disable the control while a mutation is in flight. Buyer-center pages use paginated favorite responses for tabs, previews, and counts. The `favorites` domain is added to live picks and the fixed adapter implements identical methods over its fixture-backed state. ## Risks / Trade-offs - Dormant rows are invisible while a target is unavailable. This preserves user intent across temporary unpublishing but means stored-row count can exceed API `total`. - Hydrated summaries couple this read model to catalog/shop presentation fields; the benefit is bounded query count and a stable buyer-center contract. - Idempotent delete cannot tell the UI whether a row previously existed; the UI only needs the resulting unfavorited state.