terry-staging #16
504
.unipi/docs/specs/2026-06-01-user-favorites-design.md
Normal file
504
.unipi/docs/specs/2026-06-01-user-favorites-design.md
Normal file
@@ -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 <wallet-jwt>
|
||||
```
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user