I Overview
What Watch History does and how it is scoped.
๐ฏ Summary
When a logged-in user plays any video, the system automatically records it in their personal watch history. Users can view their history on a dedicated page (sorted newest first), see a "watched" indicator on video cards, and delete individual entries or clear all history. History is private and scoped to the authenticated user.
๐ Related Documents
- Related PRD: PRD-watch-history
- Related ADR: ADR-0013: Watch History Design
โ Open Questions Resolved
- โ min-watch-duration threshold โ deferred to v2 (fire-and-forget on play start, no timer)
- โ cap strategy โ auto-evict oldest (not hard reject)
- โ re-watch โ upsert (update
watched_at, move to top)
II Business Rules
Five invariants governing history behaviour.
๐ Rule 1 โ 500-entry cap Cap
Each user may have at most 500 watch history entries. When a new entry would exceed this cap, the oldest entry (by watched_at) is automatically deleted before the new one is inserted. No error is returned to the caller.
๐ Rule 2 โ One entry per video Upsert
A video can appear at most once per user in watch history. Re-watching the same video updates the existing entry's watched_at timestamp (upsert) โ it does not create a duplicate.
๐ Rule 3 โ Survives video deletion Persist
Watch history entries persist even if the source video is deleted. The video_id foreign key becomes NULL on video deletion; the entry remains with source_video_id intact. The UI must handle video_id: null gracefully.
๐ Rule 4 โ Strictly private Privacy
Watch history is strictly private. A user can only read, record, or delete their own history. No cross-user access is permitted.
๐ Rule 5 โ Fire-and-forget Async
Recording a watch event is fire-and-forget. The frontend does not await the response. A failed POST does not affect video playback or surface an error to the user.
III Functional Requirements
FR-1 through FR-7.
Auto-record on play FR-1
The system must accept a POST /watch-history request when a user plays a video. If the video is already in the user's history, update watched_at to now (upsert). If it is a new entry and the user has 500 entries, delete the oldest before inserting.
History IDs endpoint FR-2
The system must expose GET /watch-history/ids returning the list of source_video_id values for the authenticated user. Used by the frontend for client-side watched-indicator lookup.
History list endpoint FR-3
The system must expose GET /watch-history returning full history entries for the authenticated user, sorted by watched_at DESC.
Watched indicator on video cards FR-4
Each video card must display a visual indicator if the video's ID is present in the user's history. The frontend must derive this from the GET /watch-history/ids response using a client-side Set.has() lookup โ no per-card API call.
Delete single entry FR-5
The system must accept DELETE /watch-history?videoId=X to remove a single entry. If the entry does not exist, return 204 (idempotent).
Clear all history FR-6
The system must accept DELETE /watch-history/all to remove all entries for the authenticated user. The frontend must show a confirmation dialog before calling this endpoint.
History page FR-7
The /history route must replace the current ComingSoon placeholder with a real HistoryPage component displaying all history entries.
IV API Changes
Five new endpoints under /v1/funny-app/watch-history.
GET /watch-history/ids
Returns all watched video IDs for the current user. Used at page load for client-side card indicators. Auth: Required (JWT Bearer) ยท Rate limit: None
Response โ Success (200)
{
"status": "SUCCESS",
"data": {
"videoIds": [123, 456, 789]
}
}
Uses ResultObjectInfo<WatchHistoryIdsDto>.
GET /watch-history
Returns full history list, newest first. Auth: Required (JWT Bearer) ยท Rate limit: None
Response โ Success (200)
{
"status": "SUCCESS",
"data": [
{
"id": "uuid",
"sourceVideoId": 123,
"videoId": 123,
"title": "Funny Cat Video",
"poster": "https://...",
"watchedAt": "2026-05-31T10:00:00Z"
}
],
"total": 42
}
Uses ResultListInfo<WatchHistoryDto>. videoId may be null if source video was deleted โ title and poster will also be null in that case.
POST /watch-history
Record or update a watch event. Fire-and-forget โ frontend does not await. Auth: Required (JWT Bearer) ยท Rate limit: 30 requests/minute/user (@RateLimited(permit = 30))
Request
{
"videoId": 123
}
Response โ Success
| Condition | HTTP | Body |
|---|---|---|
| New entry created | 201 | ResultObjectInfo<WatchHistoryDto> |
| Existing entry updated (re-watch) | 200 | ResultObjectInfo<WatchHistoryDto> |
videoId not found in video_sources | 200 | ResultObjectInfo<WatchHistoryDto> with videoId: null โ silent, recorded with null video_id |
Response โ Error
| HTTP | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED | Missing or invalid JWT |
| 500 | INTERNAL_ERROR | Unexpected DB or server failure |
videoId is NOT a 404. It is silently recorded with video_id = null and source_video_id = videoId. This supports the fire-and-forget contract โ the frontend never inspects the response.DELETE /watch-history
Remove a single history entry by video ID. Auth: Required (JWT Bearer) ยท Rate limit: None ยท Query param: ?videoId=X (Long, required)
| Condition | HTTP |
|---|---|
| Entry deleted | 204 |
| Entry not found (idempotent) | 204 |
videoId param missing | 400 |
| Unauthenticated | 401 |
DELETE /watch-history/all
Clear all history entries for the authenticated user. Auth: Required (JWT Bearer) ยท Rate limit: None
| Condition | HTTP |
|---|---|
| All entries deleted (or none existed) | 204 |
| Unauthenticated | 401 |
V Database Changes
One new table; no changes to existing tables.
๐๏ธ New Table โ watch_history
-- Migration: api/src/main/resources/db/changelog/sql/202605310002-create-watch-history-table.sql
CREATE TABLE watch_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video_id BIGINT REFERENCES video_sources(id) ON DELETE SET NULL,
source_video_id BIGINT NOT NULL,
watched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_watch_history_user_video UNIQUE (user_id, source_video_id)
);
CREATE INDEX idx_watch_history_user_watched ON watch_history(user_id, watched_at DESC);
๐ Column Notes
video_idโ nullable FK; becomesNULLif video is deleted (ON DELETE SET NULL)source_video_idโ immutable copy of the original video ID; used for uniqueness and delete-by-videoId operationswatched_atโ updated on re-watch (upsert); drives sort order
VI Security Requirements
Authentication, authorization, validation, and audit posture.
๐ Authentication
All five endpoints require a valid JWT Bearer token. The JWTAuthenticationFilter enforces this for any path not in the whitelist. /v1/funny-app/watch-history/** must NOT be added to the whitelist.
๐ก๏ธ Authorization
User identity is extracted in the service layer via SecurityContextHolder.getContext().getAuthentication().getDetails() โ UserDetailDto. All queries are scoped to WHERE user_id = currentUser.getId(). No cross-user access is possible.
โ Data Validation
| Field | Rule | Behaviour on violation |
|---|---|---|
videoId (POST body) | Not null, Long | 400 if missing |
videoId (DELETE param) | Not null, Long | 400 if missing |
videoId value | Must be positive | 200 silent ignore if not found in DB |
๐ Sensitive Data
No sensitive fields. @AuditLog is NOT applied to WatchHistoryController โ POST volume would generate excessive audit log noise.
VII Frontend Changes
Routes, components, API module, and React Query state.
๐งญ Modified Routes
| Path | Component | Change |
|---|---|---|
/history (side nav) | HistoryPage.jsx | Replace ComingSoon with real component |
How to replace: In AppShell.jsx, the history nav key currently renders <ComingSoon page="history" />. Change to render <HistoryPage /> when activeNav === 'history'.
๐งฉ New Components
| Component | File | Purpose |
|---|---|---|
HistoryPage | webapp/src/pages/HistoryPage.jsx | Lists all watch history entries, newest first |
WatchedBadge | webapp/src/components/video/WatchedBadge.jsx | Overlay indicator on watched video cards |
๐ New API Module โ webapp/src/api/watchHistory.js
export const watchHistoryApi = {
ids: () => api.get('/watch-history/ids'),
list: () => api.get('/watch-history'),
record: (videoId) => api.post('/watch-history', { videoId }), // fire-and-forget
remove: (videoId) => api.delete(`/watch-history?videoId=${videoId}`),
clearAll: () => api.delete('/watch-history/all'),
}
record() must be called without await. Errors must be silently swallowed.
๐ React Query Keys & State
// Page-load fetch โ cached for entire session
['watchHistory', 'ids'] โ GET /watch-history/ids โ Set<Long>
// History page fetch
['watchHistory', 'list'] โ GET /watch-history
// Invalidate after any mutation
queryClient.invalidateQueries(['watchHistory', 'ids'])
queryClient.invalidateQueries(['watchHistory', 'list'])
Watched indicator per card: watchedIds.has(video.id) โ O(1), no extra API call.
Fire-and-forget pattern
// On video play event โ do NOT await
watchHistoryApi.record(video.id).catch(() => {})
// Then invalidate to keep indicator in sync
queryClient.invalidateQueries(['watchHistory', 'ids'])
VIII Non-Functional & Caching
Performance targets, availability, and cache impact.
โก Performance
| Operation | Target | Notes |
|---|---|---|
GET /watch-history (history page load) | < 500 ms p99 | Index on (user_id, watched_at DESC) covers this query |
| All other endpoints | No specific target | Upsert + delete are single-row operations; expected < 50 ms |
๐ข Availability
No degradation to video playback. POST is fire-and-forget โ a timeout or 5xx does not surface to the user.
๐๏ธ Caching Impact
| Cache | Impact |
|---|---|
VideoCacheImpl | No impact โ watch history does not read from or write to video metadata cache |
StatsCacheImpl | No impact โ watch history is separate from aggregate view stats (VideoAccessStats) |
VideoAccessStats tracks aggregate hit counts per video (not per-user). Watch history is per-user. These are independent systems.IX Edge Cases
EC-1 through EC-7.
Re-watch same video (same tab) EC-1
Condition: User plays video A, then plays video A again in the same session.
Expected: Single entry in history. watched_at updated to latest play time. Entry appears at top of list.
Two tabs play same video simultaneously EC-2
Condition: User has two tabs open and plays the same video at the same moment.
Expected: UPSERT ON CONFLICT (user_id, source_video_id) DO UPDATE SET watched_at = now() handles concurrency. Exactly one entry exists. watched_at reflects whichever write lands last. No error.
At exactly 500 entries, new video played EC-3
Condition: User has 500 entries. Plays a new (not-yet-watched) video.
Expected: Oldest entry (min watched_at) is deleted. New entry is inserted. Total remains 500. No error returned to frontend.
At exactly 500 entries, re-watching existing EC-4
Condition: User has 500 entries. Re-watches a video already in history.
Expected: Upsert updates watched_at only. No deletion. Total remains 500.
Delete entry that does not exist EC-5
Condition: DELETE /watch-history?videoId=999 where no entry exists for this user + videoId.
Expected: 204 โ idempotent, no error.
Source video deleted while in history EC-6
Condition: Admin deletes a VideoSource record. One or more users have it in watch history.
Expected: video_id โ NULL via ON DELETE SET NULL. source_video_id unchanged. GET /watch-history returns the entry with videoId: null, title: null, poster: null. Frontend renders "Video no longer available" placeholder โ does not crash.
Clear all history โ confirmation EC-7
Condition: User clicks "Clear all" button.
Expected: Frontend shows confirmation dialog ("Are you sure? This cannot be undone."). Only on confirm does it call DELETE /watch-history/all. On cancel, no API call is made.
X Acceptance Criteria
Definition of done.
โ Criteria
- Playing any video automatically creates or updates a history entry (no manual action required)
GET /watch-historyreturns entries sorted bywatched_atDESCGET /watch-history/idsreturns correct set ofsource_video_idvalues- Video cards show watched indicator for videos in history โ zero extra API calls per card
- Re-watching a video moves it to the top of the history list
- 500th entry: success; 501st (new video): oldest entry evicted, total stays 500
- Re-watching at cap 500 does NOT trigger eviction (upsert, not insert)
- Delete non-existent entry returns 204 (idempotent)
- Clear all requires frontend confirmation dialog before API call
- Deleted video appears as "Video no longer available" in history โ frontend does not crash
- Two concurrent POSTs for same video result in exactly one history entry
- POST /watch-history does not block or delay video playback
- Unauthenticated requests to any endpoint return 401
mvn verifypasses with coverage gate metnpm run testpasses
๐ Version History
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-05-31 | nguyenhuuca | Initial draft |