Skip to content

Plan: Watch History (Opus)

Overview

Status: Draft Author: nguyenhuuca Date: 2026-06-01 Beads Issue: N/A Related PRD: PRD-watch-history-opus Related ADR: ADR-0014: Watch History Design (Opus) Related Spec: Spec: Watch History (Opus)

Note: Generated with Claude Opus 4.8 as a model-comparison exercise against plan-watch-history.md. Same feature, independently derived. The key implementation divergences from the sibling plan are: BIGINT IDENTITY PK (not UUID), and a native ON CONFLICT upsert + single bulk-eviction query inside one transaction so the concurrency cases (EC-8, EC-9) hold by construction rather than relying on find-then-insert.

Objective

Implement Watch History end-to-end: fire-and-forget auto-record on play, /history page replacing ComingSoon, a "watched" badge on video cards, and delete/clear operations — all scoped to the authenticated user, with a hard 500-entry cap enforced atomically.

Scope

In Scope

  • Liquibase migration: watch_history table (BIGINT IDENTITY PK)
  • Backend: entity, repository (native upsert + bulk evict), service, controller, DTOs
  • Frontend: watchHistory.js API module, React Query hooks, WatchedBadge, HistoryPage, AppShell wiring

Out of Scope

  • Resume playback (stored watch position)
  • History-based recommendations
  • Guest (unauthenticated) history
  • Minimum watch-duration gating (v2 — PRD open question)

Technical Approach

Architecture

[VideoSwiper — on play event]
        ↓ fire-and-forget (no await), 30/min rate-limited
[api/watchHistory.js → POST /v1/funny-app/watch-history]
[WatchHistoryController]
[WatchHistoryService.recordWatch — @Transactional]
        ├── native UPSERT ON CONFLICT (user_id, source_video_id)
        └── if row count > 500 → bulk DELETE oldest down to 500   (same tx)
[WatchHistoryRepository]
[PostgreSQL: watch_history]

[Page load] → GET /watch-history/ids → useWatchedIds → Set<Long>
[VideoSwiper card → WatchedBadge — Set.has(video.id)]   (0 extra calls)

[AppShell activeNav === 'history'] → HistoryPage → GET /watch-history

Key Decisions (from ADR-0014 + Spec)

Decision Choice Divergence from sibling plan
Primary key BIGINT IDENTITY (matches users/video_sources) sibling used UUID
Recording trigger Frontend fire-and-forget POST on play same
Upsert Native INSERT … ON CONFLICT (user_id, source_video_id) DO UPDATE SET watched_at = now() sibling used find-then-insert (race on EC-9)
Cap eviction Single bulk DELETE … WHERE id NOT IN (SELECT … LIMIT 500) style, same transaction as upsert sibling deleted one oldest row, non-atomic note
Re-watch Upsert moves entry to top same
Invalid videoId 200, recorded with video_id = null, source_video_id = videoId same
Ownership user_id from JWT subject only — no client userId same intent, made explicit
Rate limit @RateLimited(permit = 30) on POST sibling 30 too

Migration naming

Latest existing migration is 202605010001-init_permissions.sql. New file: 202606010001-create-watch-history-table.sql, registered in db.changelog-master.yaml.

AppShell change

The history nav currently falls through to <ComingSoon page="history" />. Add a condition before the activeNav !== 'home' catch-all to render <HistoryPage />.


Implementation Steps

Phase 1: Database Migration (0.5 day)

  • [ ] 1.1 Create api/src/main/resources/db/changelog/sql/202606010001-create-watch-history-table.sql
    CREATE TABLE watch_history (
        id              BIGINT      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        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);
    
  • [ ] 1.2 Register the changeset in db.changelog-master.yaml (follow existing <include file=...>/yaml pattern used for 202605010001).

Phase 2: Entity (0.5 day)

  • [ ] 2.1 Create api/.../entity/WatchHistory.java (extends BaseDomain, mirroring VideoSource)
  • @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long idBIGINT, matches User/VideoSource
  • @ManyToOne(fetch = LAZY) @JoinColumn(name="user_id") User user
  • @ManyToOne(fetch = LAZY) @JoinColumn(name="video_id") VideoSource video (nullable)
  • @Column(name="source_video_id", nullable=false, updatable=false) Long sourceVideoId
  • @Column(name="watched_at", nullable=false) Instant watchedAt
  • @Table(name="watch_history", uniqueConstraints=@UniqueConstraint(name="uq_watch_history_user_video", columnNames={"user_id","source_video_id"}))
  • Lombok per project style (@Getter @Setter @SuperBuilder @NoArgsConstructor @AllArgsConstructor)

Phase 3: Repository (0.5 day)

  • [ ] 3.1 Create api/.../repo/WatchHistoryRepository.java extends JpaRepository<WatchHistory, Long>
    // Badges — page load
    @Query("SELECT w.sourceVideoId FROM WatchHistory w WHERE w.user.id = :userId")
    List<Long> findSourceVideoIdsByUserId(Long userId);
    
    // List — newest first; JOIN FETCH video to avoid N+1
    @Query("SELECT w FROM WatchHistory w LEFT JOIN FETCH w.video " +
           "WHERE w.user.id = :userId ORDER BY w.watchedAt DESC")
    List<WatchHistory> findHistoryByUserId(Long userId);
    
    // Native atomic upsert — single statement, satisfies EC-9
    @Modifying
    @Query(value = """
        INSERT INTO watch_history (user_id, video_id, source_video_id, watched_at)
        VALUES (:userId, :videoId, :sourceVideoId, now())
        ON CONFLICT (user_id, source_video_id)
        DO UPDATE SET watched_at = now(), video_id = EXCLUDED.video_id
        """, nativeQuery = true)
    void upsert(Long userId, Long videoId, Long sourceVideoId);
    
    // Bulk eviction — keep newest 500, delete the rest in one statement (EC-3, EC-8)
    @Modifying
    @Query(value = """
        DELETE FROM watch_history
        WHERE user_id = :userId
          AND id NOT IN (
            SELECT id FROM watch_history
            WHERE user_id = :userId
            ORDER BY watched_at DESC
            LIMIT 500
          )
        """, nativeQuery = true)
    void evictOverCap(Long userId);
    
    @Modifying
    void deleteByUserIdAndSourceVideoId(Long userId, Long sourceVideoId);
    
    @Modifying
    void deleteByUserId(Long userId);
    

    video_id in the upsert is the verified VideoSource.id or null when the id is unknown; source_video_id is always the input id.

Phase 4: DTOs (0.5 day)

  • [ ] 4.1 Create in api/.../dto/:
  • WatchHistoryDto{ Long id, Long sourceVideoId, Long videoId, String title, String poster, Instant watchedAt } (id/videoId may be null per Rule 3)
  • WatchHistoryIdsDto{ List<Long> videoIds }
  • RecordWatchRequest{ @NotNull @Positive Long videoId }

Phase 5: Service (1 day) — the correctness core

  • [ ] 5.1 Create api/.../service/impl/WatchHistoryServiceImpl.java (interface WatchHistoryService in service/, per project pattern)
  • Resolve user: SecurityContextHolder.getContext().getAuthentication().getDetails()UserDetailDtogetId(). Never read a userId from the request.
  • [ ] 5.2 Methods:
    @Transactional(readOnly = true)
    WatchHistoryIdsDto getWatchedIds();          // findSourceVideoIdsByUserId(currentUserId)
    
    @Transactional(readOnly = true)
    List<WatchHistoryDto> getHistory();          // findHistoryByUserId → map (null-safe title/poster)
    
    @Transactional
    void recordWatch(Long videoId);
    // 1. Resolve videoId → VideoSource id (or null if not found). source_video_id = videoId.
    // 2. repo.upsert(userId, resolvedVideoId, videoId)   // atomic, no duplicate (EC-9)
    // 3. repo.evictOverCap(userId)                       // bulk, same tx (EC-3, EC-8)
    
    @Transactional
    void removeEntry(Long videoId);              // deleteByUserIdAndSourceVideoId — idempotent (EC-5)
    
    @Transactional
    void clearAll();                             // deleteByUserId
    
  • [ ] 5.3 Unknown videoId: recordWatch must not throw — resolve video_id = null, persist silently, return 200 path (EC-2).
  • [ ] 5.4 Contract validation via Contract.require(videoId != null && videoId > 0, ...) for the service-layer guard (controller @Valid is the first gate).

Why native upsert + bulk evict: a find-then-insert approach races under two concurrent new-video plays (sibling plan's EC-8/EC-9 gap). ON CONFLICT makes the insert atomic against the unique constraint, and a single DELETE … NOT IN (… LIMIT 500) is self-healing — after any interleaving the user converges to ≤ 500. Both run in one @Transactional.

Phase 6: Controller (0.5 day)

  • [ ] 6.1 Create api/.../web/WatchHistoryController.java
    @RestController
    @RequestMapping(AppConstant.API.BASE_URL + "/watch-history")   // /v1/funny-app/watch-history (verified)
    // No @AuditLog — per-play POST volume would flood the audit log (Spec)
    
    @GetMapping("/ids")   ResponseEntity<ResultObjectInfo<WatchHistoryIdsDto>>  (200)
    @GetMapping           ResponseEntity<ResultListInfo<WatchHistoryDto>>       (200)
    
    @PostMapping  @RateLimited(permit = 30)
                          ResponseEntity<ResultObjectInfo<WatchHistoryDto>>     (201 new / 200 upsert)
    
    @DeleteMapping        (@RequestParam Long videoId)  ResponseEntity<Void>    (204 idempotent)
    @DeleteMapping("/all")                               ResponseEntity<Void>    (204)
    
    Follow CommentController style (constructor injection, ResultObjectInfo/ResultListInfo builders, ResultStatus.SUCCESS).
  • [ ] 6.2 Confirm /v1/funny-app/watch-history/** is not in the JWTAuthenticationFilter whitelist (must require auth).

Phase 7: Backend Tests (1 day)

  • [ ] 7.1 WatchHistoryServiceImplTest
  • new → entry created; re-watch → watched_at updated, count unchanged
  • at cap 500 + new video → evictOverCap trims to 500
  • at cap 500 + re-watch → no eviction
  • unknown videoId → saved with video_id = null, no throw
  • removeEntry non-existent → no-op; clearAll removes only current user's rows
  • [ ] 7.2 WatchHistoryControllerTest (MockMvc + security context)
  • unauthenticated any endpoint → 401
  • POST new → 201; POST same → 200; one DB row
  • POST > 30/min → 429
  • DELETE non-existent → 204; DELETE /all then GET → empty
  • [ ] 7.3 Concurrency test (EC-8/EC-9): two parallel recordWatch calls (same user) for (a) the same new video → exactly one row; (b) two different new videos at cap → final count ≤ 500.
  • [ ] 7.4 mvn verify; bump api/.coverage-threshold (+1%).

Phase 8: Frontend API module (0.5 day)

  • [ ] 8.1 Create webapp/src/api/watchHistory.js (follow webapp/src/api/comments.js + client.js pattern)
    import { api } from './client'
    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'),
    }
    

Phase 9: Frontend hooks (0.5 day)

  • [ ] 9.1 webapp/src/hooks/useWatchHistory.jsuseWatchedIds() (['watchHistory','ids'], staleTime: Infinity, returns Set<Long>) and useWatchHistoryList() (['watchHistory','list']); both enabled only when authenticated.
  • [ ] 9.2 webapp/src/hooks/useRecordWatch.js — fire-and-forget: watchHistoryApi.record(id).catch(()=>{}) then invalidate ['watchHistory','ids']. Never awaited.

Phase 10: WatchedBadge + VideoSwiper (0.5 day)

  • [ ] 10.1 webapp/src/components/video/WatchedBadge.jsx — small overlay (eye/checkmark + "Watched"), renders only when isWatched.
  • [ ] 10.2 Wire into VideoSwiper.jsx: call recordWatch(video.id) on play (fire-and-forget); render <WatchedBadge isWatched={watchedIds.has(video.id)} />. Guard badge behind auth (render null if not logged in).

Phase 11: HistoryPage + AppShell (1 day)

  • [ ] 11.1 webapp/src/pages/HistoryPage.jsxuseWatchHistoryList(), newest-first grid; each card: poster, title, relative watchedAt, remove button; videoId: null → "Video unavailable" placeholder; "Clear all" → confirm dialog → clearAll() → invalidate; empty state + loading skeleton.
  • [ ] 11.2 webapp/src/components/layout/AppShell.jsx — add activeNav === 'history' ? <HistoryPage /> : before the activeNav !== 'home' ComingSoon catch-all.

Files to Create / Modify

File Action Description
api/.../db/changelog/sql/202606010001-create-watch-history-table.sql Create Migration (BIGINT IDENTITY)
api/.../db/changelog/db.changelog-master.yaml Modify Register changeset
api/.../entity/WatchHistory.java Create JPA entity (Long id)
api/.../repo/WatchHistoryRepository.java Create Native upsert + bulk evict
api/.../dto/WatchHistoryDto.java Create Response DTO
api/.../dto/WatchHistoryIdsDto.java Create Response DTO
api/.../dto/RecordWatchRequest.java Create Request DTO
api/.../service/WatchHistoryService.java Create Service interface
api/.../service/impl/WatchHistoryServiceImpl.java Create Service impl (tx upsert+evict)
api/.../web/WatchHistoryController.java Create REST controller
webapp/src/api/watchHistory.js Create API module
webapp/src/hooks/useWatchHistory.js Create React Query hooks
webapp/src/hooks/useRecordWatch.js Create Fire-and-forget helper
webapp/src/components/video/WatchedBadge.jsx Create Watched indicator
webapp/src/components/video/VideoSwiper.jsx Modify Fire record + badge
webapp/src/pages/HistoryPage.jsx Create History list page
webapp/src/components/layout/AppShell.jsx Modify Replace ComingSoon for history

Testing Strategy

Unit / Integration

Component Cases
recordWatch new (201), re-watch (200, no dup), evict at cap, unknown videoId silent-null
evictOverCap 501 new → trims to 500; re-watch at 500 → no trim
concurrency same new video ×2 → 1 row (EC-9); two new videos at cap → ≤500 (EC-8)
removeEntry/clearAll idempotent delete; per-user isolation
controller 401 unauth, 429 over-limit, 204 idempotent delete

Manual

  • [ ] Play → badge appears; refresh → badge persists (via /ids)
  • [ ] /history lists video with relative timestamp; re-watch moves to top
  • [ ] Delete single → gone + badge clears; Clear all → confirm → empty
  • [ ] Deleted source video → "Video unavailable", no crash
  • [ ] POST does not block playback (network throttle)

Dependency Graph

Backend (sequential):
P1 (Migration)
  └── P2 (Entity)
        └── P3 (Repository)
              ├── P4 (DTOs) ──┐
              └── P5 (Service) ┘── P6 (Controller) ── P7 (BE Tests)

Frontend (starts once P6 API contract is fixed):
P8 (API Module)
  └── P9 (Hooks)
        ├── P10 (Badge + Swiper)
        └── P11 (HistoryPage + AppShell)

P10 and P11 run in parallel. FE can start from P8 as soon as P6 is done, without waiting for P7.


Rollback Plan

  1. Revert AppShell.jsx (restore ComingSoon catch-all) and VideoSwiper.jsx (remove record call + badge).
  2. DROP TABLE watch_history; (or rollback the Liquibase changeset).
  3. Remove new backend/frontend files. Fully additive — no existing data affected.

Risks

Risk Mitigation
Upsert race on concurrent same-video plays Native ON CONFLICT makes insert atomic against the unique key (EC-9)
Cap exceeded under concurrent new plays Bulk DELETE … NOT IN (… LIMIT 500) is self-healing; runs in the upsert transaction (EC-8)
N+1 loading video per history row LEFT JOIN FETCH w.video in the list query
source_video_id cleared on update @Column(updatable=false); upsert never touches it
Badge shown to logged-out users Guard render behind auth context
Garbage rows from bogus videoId (silent-null) Accepted per spec; 30/min rate limit is the only abuse guard — monitor null-video_id row growth

Checklist

Before Starting

  • [ ] PRD/ADR/Spec approved
  • [ ] Branch feat/watch-history-opus from main

Before PR

  • [ ] mvn verify passes (coverage gate met)
  • [ ] npm run lint and npm run test pass
  • [ ] Concurrency tests (EC-8, EC-9) green
  • [ ] Fire-and-forget confirmed: POST does not block playback

Before Merge

  • [ ] Code review approved
  • [ ] api/.coverage-threshold bumped
  • [ ] Migration reviewed

Beads (run if bd is configured)

bd create --title="Watch History (Opus)" --type=feature --priority=2
bd create --title="[BE-1] Migration: watch_history (BIGINT IDENTITY)" --type=task
bd create --title="[BE-2] WatchHistory entity" --type=task
bd create --title="[BE-3] Repository: native upsert + bulk evict" --type=task
bd create --title="[BE-4] DTOs" --type=task
bd create --title="[BE-5] Service: tx upsert+evict, JWT ownership" --type=task
bd create --title="[BE-6] Controller (@RateLimited 30)" --type=task
bd create --title="[BE-7] Tests incl. concurrency EC-8/EC-9" --type=task
bd create --title="[FE-1] api/watchHistory.js" --type=task
bd create --title="[FE-2] hooks: useWatchHistory + useRecordWatch" --type=task
bd create --title="[FE-3] WatchedBadge + VideoSwiper" --type=task
bd create --title="[FE-4] HistoryPage + AppShell" --type=task
# Deps: BE-2→BE-1, BE-3→BE-2, BE-5→BE-3+BE-4, BE-6→BE-5, BE-7→BE-6
#       FE-2→FE-1, FE-3→FE-2, FE-4→FE-2

Progress Log

Date Update
2026-06-01 Plan created from spec-watch-history-opus + ADR-0014 (Opus 4.8, model comparison)