โ† Presentations
SPEC ยท Watch History

Feature Specification: Watch History

๐Ÿ“„ Type: Spec ๐ŸŸก Status: Draft ๐Ÿ‘ค Author: nguyenhuuca ๐Ÿ“… Date: 2026-05-31 ๐Ÿ”– Version: 1.0

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

ConditionHTTPBody
New entry created201ResultObjectInfo<WatchHistoryDto>
Existing entry updated (re-watch)200ResultObjectInfo<WatchHistoryDto>
videoId not found in video_sources200ResultObjectInfo<WatchHistoryDto> with videoId: null โ€” silent, recorded with null video_id

Response โ€” Error

HTTPCodeCondition
401UNAUTHORIZEDMissing or invalid JWT
500INTERNAL_ERRORUnexpected DB or server failure
Note: Invalid 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)

ConditionHTTP
Entry deleted204
Entry not found (idempotent)204
videoId param missing400
Unauthenticated401

DELETE /watch-history/all

Clear all history entries for the authenticated user. Auth: Required (JWT Bearer) ยท Rate limit: None

ConditionHTTP
All entries deleted (or none existed)204
Unauthenticated401

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; becomes NULL if video is deleted (ON DELETE SET NULL)
  • source_video_id โ€” immutable copy of the original video ID; used for uniqueness and delete-by-videoId operations
  • watched_at โ€” updated on re-watch (upsert); drives sort order
โ„น๏ธ
No Changes to Existing Tables

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

FieldRuleBehaviour on violation
videoId (POST body)Not null, Long400 if missing
videoId (DELETE param)Not null, Long400 if missing
videoId valueMust be positive200 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

PathComponentChange
/history (side nav)HistoryPage.jsxReplace 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

ComponentFilePurpose
HistoryPagewebapp/src/pages/HistoryPage.jsxLists all watch history entries, newest first
WatchedBadgewebapp/src/components/video/WatchedBadge.jsxOverlay 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

OperationTargetNotes
GET /watch-history (history page load)< 500 ms p99Index on (user_id, watched_at DESC) covers this query
All other endpointsNo specific targetUpsert + 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

CacheImpact
VideoCacheImplNo impact โ€” watch history does not read from or write to video metadata cache
StatsCacheImplNo 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-history returns entries sorted by watched_at DESC
  • GET /watch-history/ids returns correct set of source_video_id values
  • 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 verify passes with coverage gate met
  • npm run test passes

๐Ÿ“œ Version History

VersionDateAuthorChange
1.02026-05-31nguyenhuucaInitial draft