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 CONFLICTupsert + 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_historytable (BIGINT IDENTITY PK) - Backend: entity, repository (native upsert + bulk evict), service, controller, DTOs
- Frontend:
watchHistory.jsAPI 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.sqlCREATE 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 for202605010001).
Phase 2: Entity (0.5 day)¶
- [ ] 2.1 Create
api/.../entity/WatchHistory.java(extendsBaseDomain, mirroringVideoSource) @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id— BIGINT, matchesUser/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_idin the upsert is the verifiedVideoSource.idornullwhen the id is unknown;source_video_idis 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/videoIdmay 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(interfaceWatchHistoryServiceinservice/, per project pattern) - Resolve user:
SecurityContextHolder.getContext().getAuthentication().getDetails()→UserDetailDto→getId(). 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:recordWatchmust not throw — resolvevideo_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@Validis 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 CONFLICTmakes the insert atomic against the unique constraint, and a singleDELETE … 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.javaFollow@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)CommentControllerstyle (constructor injection,ResultObjectInfo/ResultListInfobuilders,ResultStatus.SUCCESS). - [ ] 6.2 Confirm
/v1/funny-app/watch-history/**is not in theJWTAuthenticationFilterwhitelist (must require auth).
Phase 7: Backend Tests (1 day)¶
- [ ] 7.1
WatchHistoryServiceImplTest - new → entry created; re-watch →
watched_atupdated, count unchanged - at cap 500 + new video →
evictOverCaptrims to 500 - at cap 500 + re-watch → no eviction
- unknown videoId → saved with
video_id = null, no throw removeEntrynon-existent → no-op;clearAllremoves 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
recordWatchcalls (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; bumpapi/.coverage-threshold(+1%).
Phase 8: Frontend API module (0.5 day)¶
- [ ] 8.1 Create
webapp/src/api/watchHistory.js(followwebapp/src/api/comments.js+client.jspattern)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.js—useWatchedIds()(['watchHistory','ids'],staleTime: Infinity, returnsSet<Long>) anduseWatchHistoryList()(['watchHistory','list']); bothenabledonly 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 whenisWatched. - [ ] 10.2 Wire into
VideoSwiper.jsx: callrecordWatch(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.jsx—useWatchHistoryList(), newest-first grid; each card: poster, title, relativewatchedAt, 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— addactiveNav === 'history' ? <HistoryPage /> :before theactiveNav !== '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) - [ ]
/historylists 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¶
- Revert
AppShell.jsx(restore ComingSoon catch-all) andVideoSwiper.jsx(remove record call + badge). DROP TABLE watch_history;(or rollback the Liquibase changeset).- 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-opusfrommain
Before PR¶
- [ ]
mvn verifypasses (coverage gate met) - [ ]
npm run lintandnpm run testpass - [ ] Concurrency tests (EC-8, EC-9) green
- [ ] Fire-and-forget confirmed: POST does not block playback
Before Merge¶
- [ ] Code review approved
- [ ]
api/.coverage-thresholdbumped - [ ] 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) |