From df200053574d6421ad707376f30674fb369f9266 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:14:10 +0800 Subject: [PATCH] docs: design user favorites --- .../specs/2026-06-01-user-favorites-design.md | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 .unipi/docs/specs/2026-06-01-user-favorites-design.md diff --git a/.unipi/docs/specs/2026-06-01-user-favorites-design.md b/.unipi/docs/specs/2026-06-01-user-favorites-design.md new file mode 100644 index 0000000..6165a70 --- /dev/null +++ b/.unipi/docs/specs/2026-06-01-user-favorites-design.md @@ -0,0 +1,504 @@ +--- +title: "User Favorites" +type: brainstorm +date: 2026-06-01 +--- + +# User Favorites + +## Problem Statement + +ARK Library needs a real user-level favorites feature tied to wallet login. Users should be able to save resources for later, see their own saved resources on `/favorites`, and use favorites as a personal library rather than just incrementing a public counter. + +The current implementation only has a public `POST /api/resources/{id}/favorite` counter endpoint. It does not know who favorited a resource, does not prevent duplicate favorites, and cannot power a "My Favorites" page. The frontend `/favorites` page is currently a placeholder. + +This feature should support user-bound favorites while preserving existing popularity/favorite count behavior for rankings and admin metrics. + +## Context + +Existing backend/frontend facts: + +- Backend `resources.favorite_count` exists and is used in popularity ordering/admin stats. +- Backend currently exposes `POST /api/resources/{id}/favorite` with `{ add: true/false }`, but it is unauthenticated and only changes a global counter. +- Backend wallet auth exists through `/api/auth/wallet/nonce`, `/api/auth/wallet/verify`, and `/api/auth/wallet/me`. +- Wallet login is being designed separately in `2026-06-01-china-friendly-wallet-login-design.md`. +- Frontend `src/pages/Favorites/index.tsx` is a "Coming Soon" page. +- Resource list endpoints return paginated public resources and support filters such as `q`, `category`, and `sort`. + +Product decisions from brainstorming: + +- Favorites are user-level and keyed by wallet address. +- Favorite target is only `resources.id`, not posts, collections, or arbitrary entities. +- Favorite buttons appear both on resource cards/lists and resource detail/post pages. +- If an unauthenticated user clicks favorite, the wallet login modal opens; after successful login, the original favorite action completes automatically. +- Favorites page supports sortable, filterable, searchable favorites. +- Sort options: favorited time, resource published time, and hot/popular. +- Favorites page supports category filter and keyword search. +- If a favorited resource later becomes unavailable, the favorites page still shows it as unavailable and lets the user remove it. +- Existing favorite counts should be preserved as historical heat rather than reset. + +## Chosen Approach + +Use **user favorites + batch favorite state + favorites-page query API**. + +Backend adds an authenticated `user_favorites` table and `/api/me/favorites` endpoints. Frontend adds a shared favorite state layer, reusable favorite button, batch status lookup for lists, and a real `/favorites` page. + +The old unauthenticated favorite counter endpoint should be deprecated or changed so public users cannot freely mutate `favorite_count` without a wallet identity. + +## Why This Approach + +This approach balances user experience, backend clarity, and future extensibility. + +Accepted trade-offs: + +- A batch-state endpoint is added so list pages can show filled/unfilled hearts without N requests. +- Favorites page gets its own query API because it needs wallet scoping, sort, category filter, search, pagination, and unavailable-resource handling. +- Favorite counts remain materialized for ranking/admin performance, but backend must maintain them consistently when user favorites change. +- A pending favorite action must survive the wallet-login modal flow so users do not need to click favorite twice. + +Rejected alternatives: + +1. **Global counter only** + - Rejected because it cannot power "My Favorites" and can be spammed. + +2. **Minimal add/remove/list only** + - Rejected because resource lists would not know current favorite state efficiently. + +3. **Collections/folders** + - Rejected as out of scope. The current need is simple resource saving, not multi-folder organization. + +4. **Polymorphic favorites (`target_type`, `target_id`)** + - Rejected because only `resources.id` is needed now. Simpler schema is easier to index and reason about. + +5. **Reset all historical favorite counts** + - Rejected because current counts may already contribute to heat/ranking. Preserve them as historical base values. + +## Design + +### Backend Data Model + +Add a wallet-scoped favorites table: + +```sql +CREATE TABLE user_favorites ( + wallet_address TEXT NOT NULL, + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (wallet_address, resource_id) +); + +CREATE INDEX idx_user_favorites_wallet_created + ON user_favorites (wallet_address, created_at DESC); + +CREATE INDEX idx_user_favorites_resource + ON user_favorites (resource_id); +``` + +Wallet addresses should be stored in canonical checksum form if the backend already normalizes wallet auth to checksum addresses. Queries should compare using the same normalized representation. + +To preserve historical favorites, add a base count field or equivalent migration strategy: + +```sql +ALTER TABLE resources + ADD COLUMN favorite_base_count INT NOT NULL DEFAULT 0; + +UPDATE resources SET favorite_base_count = favorite_count; +``` + +Then define the visible favorite count as: + +```text +visibleFavoriteCount = favorite_base_count + count(user_favorites for resource) +``` + +Implementation options: + +1. Keep `resources.favorite_count` materialized and update it on add/remove: + - Migration sets `favorite_base_count = favorite_count`. + - `favorite_count` starts as the existing historical value. + - Each new user favorite increments/decrements `favorite_count` exactly once. + - Fast reads, but backend must maintain consistency carefully. + +2. Compute `favorite_base_count + COUNT(user_favorites)` in queries: + - Most accurate by construction. + - May require careful indexing or view/materialization for popular sorting. + +Recommended: keep `resources.favorite_count` materialized for existing popularity/admin queries, but make all future changes go through authenticated user-favorite endpoints. Add a periodic or admin-only consistency check later if needed. + +### Backend API Contract + +All `/api/me/favorites*` endpoints require wallet JWT: + +```http +Authorization: Bearer +``` + +The backend identifies the wallet address from the JWT, not from request body. + +#### List current user's favorites + +```http +GET /api/me/favorites?sort=favorited_at&page=1&limit=24&category=project-ppt&q=ark&includeUnavailable=true +``` + +Query params: + +| Param | Values | Default | Notes | +|---|---|---|---| +| `page` | positive integer | `1` | Page number | +| `limit` | `1..100` | `24` | Page size | +| `sort` | `favorited_at`, `published_at`, `hot` | `favorited_at` | `hot` uses popularity score | +| `category` | category slug | none | Optional category filter | +| `q` | text | none | Search title/description/tag/body as appropriate | +| `includeUnavailable` | `true`, `false` | `true` | Whether to include unpublished/private/deleted-like resources still referenced by favorites | +| `lang` | UI language code | optional | Category display language, matching existing resources endpoints | + +Response: + +```json +{ + "items": [ + { + "favoritedAt": "2026-06-01T12:00:00Z", + "resource": { + "id": "uuid", + "title": "ARK resource title", + "description": "...", + "type": "video", + "language": "zh-TW", + "categoryId": 1, + "categorySlug": "project-ppt", + "categoryName": "項目資料(PPT)", + "coverImage": "/uploads/cover.png", + "fileUrl": "/uploads/file.pdf", + "previewUrl": "/uploads/preview.mp4", + "externalUrl": null, + "isDownloadable": true, + "isRecommended": false, + "publishedAt": "2026-05-01T12:00:00Z", + "updatedAt": "2026-05-02T12:00:00Z", + "tags": ["官方推薦"], + "favoriteCount": 12, + "availability": "available" + } + } + ], + "page": 1, + "limit": 24, + "total": 1 +} +``` + +Unavailable resources should return enough metadata for the favorites page to show the item and allow removal. Suggested shape: + +```json +{ + "favoritedAt": "2026-06-01T12:00:00Z", + "resource": { + "id": "uuid", + "title": "Previously favorited resource", + "categoryName": "...", + "updatedAt": "2026-05-02T12:00:00Z", + "favoriteCount": 12, + "availability": "unavailable", + "unavailableReason": "unpublished" + } +} +``` + +For `sort=hot`, use the same general popularity concept as existing popular resources, for example: + +```sql +(download_count + favorite_count + share_count) DESC, updated_at DESC +``` + +For `sort=published_at`: + +```sql +published_at DESC NULLS LAST, updated_at DESC +``` + +For `sort=favorited_at`: + +```sql +user_favorites.created_at DESC +``` + +#### Batch favorite status + +```http +GET /api/me/favorites/ids?resourceIds=id1,id2,id3 +``` + +Returns which of the provided resource IDs are favorited by the authenticated wallet. + +Response: + +```json +{ + "ids": ["id1", "id3"] +} +``` + +Rules: + +- `resourceIds` may be comma-separated. +- Backend should cap number of IDs, e.g. max 100. +- Unknown IDs are ignored. +- Requires wallet JWT. + +#### Add favorite + +```http +POST /api/me/favorites/{resourceId} +``` + +Response: + +```json +{ + "ok": true, + "resourceId": "uuid", + "favorited": true, + "favoritedAt": "2026-06-01T12:00:00Z", + "favoriteCount": 13 +} +``` + +Rules: + +- Requires wallet JWT. +- Idempotent: if already favorited, return success without double incrementing count. +- Should allow favoriting only existing resources. +- Product preference: favoriting unavailable/private resources from public UI should not normally happen; backend may reject unavailable resources for new favorites with `404` or `409`. + +#### Remove favorite + +```http +DELETE /api/me/favorites/{resourceId} +``` + +Response: + +```json +{ + "ok": true, + "resourceId": "uuid", + "favorited": false, + "favoriteCount": 12 +} +``` + +Rules: + +- Requires wallet JWT. +- Idempotent: if not favorited, return success without decrementing count. +- If resource is unavailable but favorite row exists, removal should still work. + +#### Legacy counter endpoint + +Existing endpoint: + +```http +POST /api/resources/{id}/favorite +``` + +Should be deprecated for public use. Options: + +1. Return `410 Gone` or `405 Method Not Allowed` once the new feature ships. +2. Keep it temporarily but route authenticated requests to `POST/DELETE /api/me/favorites/{resourceId}` semantics. +3. Keep only for backwards compatibility during deploy, then remove from docs. + +Recommended: deprecate it in docs and stop frontend usage. Do not allow unauthenticated clients to mutate user-visible favorite counts. + +### Frontend Components and State + +Add a shared favorites layer: + +1. **Favorites API client** + - `listFavorites(params, token)` + - `getFavoriteIds(resourceIds, token)` + - `addFavorite(resourceId, token)` + - `removeFavorite(resourceId, token)` + +2. **Favorites state/provider or hook** + - Tracks favorite IDs for currently visible resources. + - Provides `isFavorite(resourceId)`. + - Provides `toggleFavorite(resourceId)`. + - Handles pending actions while wallet login is in progress. + - Clears state on wallet logout. + +3. **FavoriteButton** + - Reusable heart button for cards and detail pages. + - Supports states: idle, favorited, loading, disabled/unavailable. + - Has localized accessible labels: + - Add to favorites + - Remove from favorites + - Login to favorite + +4. **Favorites page** + - Replaces Coming Soon placeholder. + - Shows list/grid of favorited resources. + - Supports sort tabs/dropdown: favorited time, published time, hot. + - Supports category filter. + - Supports search input scoped to current user's favorites. + - Shows empty states: + - Not logged in: prompt to connect wallet. + - Logged in but no favorites: prompt to browse resources. + - Filter/search no results: prompt to clear filters. + - Shows unavailable items with clear badge and remove action. + +### Frontend Data Flow + +#### Resource list pages + +```text +Resource list endpoint returns items +↓ +If wallet logged in, call /api/me/favorites/ids with visible resource IDs +↓ +FavoriteButton receives favorited state +↓ +User toggles favorite +↓ +Optimistically update UI +↓ +POST/DELETE backend +↓ +On success, reconcile favoriteCount if returned +↓ +On failure, rollback and show error +``` + +#### Unauthenticated favorite click + +```text +User clicks FavoriteButton while logged out +↓ +Store pending action: { type: "favorite", resourceId } +↓ +Open wallet login modal +↓ +Wallet login succeeds +↓ +Run pending favorite action with new token +↓ +Update button state and count +``` + +If login is cancelled, the pending action is cleared and no favorite is added. + +#### Favorites page + +```text +User opens /favorites +↓ +If logged out, show login prompt +↓ +If logged in, call /api/me/favorites with sort/filter/search/page +↓ +Render resources with favorited=true +↓ +Removing an item updates list immediately +``` + +### Localization + +New UI copy must be added to all supported locale files: + +- `zh-CN` +- `en` +- `ko` +- `ja` +- `vi` +- `id` +- `ms` + +Suggested keys: + +- `favoriteAdd` +- `favoriteRemove` +- `favoriteLoginRequired` +- `favoriteAdded` +- `favoriteRemoved` +- `favoritesEmptyTitle` +- `favoritesEmptyDesc` +- `favoritesFilterAllCategories` +- `favoritesSortFavoritedAt` +- `favoritesSortPublishedAt` +- `favoritesSortHot` +- `favoritesSearchPlaceholder` +- `favoritesUnavailable` +- `favoritesClearFilters` + +### Error Handling + +- `401` from favorites API: clear wallet session or prompt re-login. +- `404` add favorite: resource no longer available; show message and refresh list. +- Network error during toggle: rollback optimistic state and show retryable error. +- Login cancelled after favorite click: do nothing and keep resource un-favorited. +- Batch favorite IDs fails on list pages: leave buttons unfilled but clickable; clicking can still prompt login or retry. +- Remove unavailable favorite fails: keep item visible and show retryable error. + +### Testing + +Frontend tests should cover: + +- FavoriteButton renders add/remove/loading states. +- Unauthenticated click opens wallet login and completes pending favorite after login. +- Toggle favorite performs optimistic update and rollback on error. +- Batch favorite IDs marks visible resources correctly. +- Favorites page handles logged-out, empty, results, unavailable, filtered, and error states. +- Logout clears favorite state. + +Backend tests should cover: + +- Add favorite creates exactly one row and increments count once. +- Re-adding existing favorite is idempotent and does not double-count. +- Remove favorite deletes row and decrements count once. +- Removing missing favorite is idempotent and does not decrement. +- Batch IDs returns only IDs favorited by the current wallet. +- Favorites list respects wallet scoping, sort, category, search, pagination, and includeUnavailable. +- Legacy public counter endpoint no longer allows unauthenticated count manipulation. + +## Implementation Checklist + +- [ ] Confirm backend wallet JWT middleware can protect `/api/me/*` routes. +- [ ] Add backend migration for `user_favorites` and favorite count preservation. +- [ ] Decide exact count maintenance strategy: materialized `resources.favorite_count` vs computed count. +- [ ] Add `GET /api/me/favorites` with sort/filter/search/pagination/unavailable support. +- [ ] Add `GET /api/me/favorites/ids` batch status endpoint. +- [ ] Add `POST /api/me/favorites/{resourceId}` idempotent add endpoint. +- [ ] Add `DELETE /api/me/favorites/{resourceId}` idempotent remove endpoint. +- [ ] Deprecate or disable unauthenticated `POST /api/resources/{id}/favorite`. +- [ ] Update backend API docs for favorites and legacy endpoint behavior. +- [ ] Add frontend favorites API client. +- [ ] Add frontend favorites state/hook with pending post-login action support. +- [ ] Add reusable `FavoriteButton` component. +- [ ] Add favorite buttons to resource cards/list components. +- [ ] Add favorite button to detail/post page UI. +- [ ] Replace `/favorites` Coming Soon page with real favorites list UI. +- [ ] Add sorting, category filter, and scoped search to favorites page. +- [ ] Add unavailable-resource display and remove action. +- [ ] Add localized copy for all supported languages. +- [ ] Add frontend tests for favorite button, pending login action, batch state, and favorites page states. +- [ ] Add backend tests for add/remove/list/batch/count/deprecated endpoint behavior. + +## Open Questions + +1. Should unavailable resources expose title/category only, or also old cover/description if still present in the database? +2. Should newly adding a favorite be allowed for draft/private resources if a logged-in user somehow knows the ID? Recommendation: no. +3. Should favorite counts update immediately in all visible lists after toggle, or only the clicked card? Recommendation: clicked card immediately; other instances can update when state is shared. +4. Should wallet address casing be stored as checksum exactly or lowercase canonical form? It must match wallet auth claims consistently. +5. Should the legacy public favorite endpoint be removed immediately or kept temporarily during deploy for backwards compatibility? + +## Out of Scope + +- Wallet login implementation details. +- TokenPocket/RainbowKit login flows. +- Collections/folders for favorites. +- Sharing favorites publicly. +- Admin editing of user favorites. +- Import/export of favorites. +- Notifications when favorited resources update. +- Translating backend-returned resource content.