feat: Added GET /api/v1/creator/dashboard returning video_count, techni…

- "backend/routers/creator_dashboard.py"
- "backend/schemas.py"
- "backend/main.py"
- "alembic/versions/016_add_users_and_invite_codes.py"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-04 00:09:19 +00:00
parent 8417f0e9e0
commit 0fc0df1d29
14 changed files with 809 additions and 34 deletions

View file

@ -60,6 +60,7 @@ Nineteen milestones complete. Phase 2 foundations are in place. M019 delivered c
- **Creator dashboard shell** — Protected /creator/* routes with sidebar nav (Dashboard, Settings). Profile edit and password change forms. Code-split with React.lazy.
- **Consent infrastructure** — Per-video consent toggles (allow_embed, allow_search, allow_kb, allow_download, allow_remix) with versioned audit trail. VideoConsent and ConsentAuditLog models with Alembic migration 017. 5 API endpoints with ownership verification and admin bypass.
- **Web media player** — Custom video player page at `/watch/:videoId` with HLS playback (lazy-loaded hls.js), speed controls (0.52x), volume, seek, fullscreen, keyboard shortcuts, and synchronized transcript sidebar with binary search active segment detection and auto-scroll. Technique page key moment timestamps link directly to the watch page. Video + transcript API endpoints with creator info.
- **LightRAG graph-enhanced retrieval** — Running as chrysopedia-lightrag service on port 9621. Uses DGX Sparks for LLM (entity extraction, summarization), Ollama nomic-embed-text for embeddings, Qdrant for vector storage, NetworkX for graph storage. 12 music production entity types configured. Exposed via REST API at /documents/text (ingest) and /query (retrieval with local/global/mix/hybrid modes).
- **Modular API client** — Frontend API layer split from single 945-line file into 10 domain modules (client.ts, search.ts, techniques.ts, creators.ts, topics.ts, stats.ts, reports.ts, admin-pipeline.ts, admin-techniques.ts, auth.ts) with shared request helper and barrel index.ts.
@ -97,3 +98,4 @@ Nineteen milestones complete. Phase 2 foundations are in place. M019 delivered c
| M017 | Creator Profile Page — Hero, Stats, Featured Technique & Admin Editing | ✅ Complete |
| M018 | Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap | ✅ Complete |
| M019 | Foundations — Auth, Consent & LightRAG | ✅ Complete |
| M020 | Core Experiences — Player, Impersonation & Knowledge Routing | 🔄 Active |

View file

@ -6,7 +6,7 @@ Creators can log in, see analytics, play video in a custom player, and manage co
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | [A] Web Media Player MVP | high | — | | Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar |
| S01 | [A] Web Media Player MVP | high | — | | Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar |
| S02 | [A] Creator Dashboard with Real Analytics | medium | — | ⬜ | Dashboard shows upload count, technique pages generated, search impressions, content library |
| S03 | [A] Consent Dashboard UI | low | — | ⬜ | Creator can toggle per-video consent settings (KB, AI training, shorts, embedding) through the dashboard |
| S04 | [A] Admin Impersonation | high | — | ⬜ | Admin clicks View As next to any creator → sees the site as that creator with amber warning banner. Read-only. Full audit log. |

View file

@ -0,0 +1,119 @@
---
id: S01
parent: M020
milestone: M020
provides:
- GET /videos/{video_id} endpoint with creator info
- GET /videos/{video_id}/transcript endpoint with ordered segments
- VideoPlayer component with HLS support
- useMediaSync hook for shared playback state
- TranscriptSidebar with binary search and auto-scroll
- /watch/:videoId route (lazy-loaded)
- Clickable timestamp links on TechniquePage key moments
requires:
[]
affects:
- S07
key_files:
- backend/routers/videos.py
- backend/schemas.py
- backend/tests/test_video_detail.py
- frontend/src/hooks/useMediaSync.ts
- frontend/src/components/VideoPlayer.tsx
- frontend/src/components/PlayerControls.tsx
- frontend/src/components/TranscriptSidebar.tsx
- frontend/src/pages/WatchPage.tsx
- frontend/src/api/videos.ts
- frontend/src/App.tsx
- frontend/src/pages/TechniquePage.tsx
- frontend/src/App.css
key_decisions:
- hls.js lazy-loaded via dynamic import to keep main bundle small
- TranscriptSidebar uses binary search for O(log n) active segment detection
- Transcript fetch failure is non-blocking — player works without sidebar
- TranscriptSidebar uses button elements for semantic click targets with keyboard accessibility
- selectinload for creator eager-loading on video detail endpoint
patterns_established:
- useMediaSync hook pattern for sharing playback state between player and transcript components
- Three-path video source detection: HLS via hls.js → Safari native HLS → direct mp4
- Lazy-loaded page routes with React.lazy + Suspense for code-splitting heavy pages
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M020/slices/S01/tasks/T01-SUMMARY.md
- .gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md
- .gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T23:52:33.308Z
blocker_discovered: false
---
# S01: [A] Web Media Player MVP
**Custom video player page with HLS playback, speed controls, synchronized transcript sidebar, and clickable timestamp links from technique pages.**
## What Happened
Three tasks delivered the full media player vertical slice — backend API, player infrastructure, and composed watch experience.
**T01 (Backend):** Added `GET /videos/{video_id}` with eager-loaded creator info and `GET /videos/{video_id}/transcript` with segment_index ordering. Both return 404 for missing videos. 5 integration tests cover success, 404, and empty-segments cases. Schemas: `SourceVideoDetail` (extends SourceVideoRead with creator_name/creator_slug/video_url) and `TranscriptForPlayerResponse`.
**T02 (Player Infrastructure):** Installed hls.js. Built `useMediaSync` hook that shares playback state (currentTime, duration, isPlaying, playbackRate) between player and transcript via video element event listeners. `VideoPlayer` component handles three source paths: HLS via lazy-loaded hls.js, Safari native HLS, and direct mp4. Null src renders a "Video not available" placeholder. `PlayerControls` provides play/pause, seek bar, time display (MM:SS), 6-speed options (0.52x), volume + mute, fullscreen, and keyboard shortcuts (Space, arrows, Up/Down).
**T03 (Watch Experience):** Created API client (`videos.ts`) with TypeScript interfaces. `TranscriptSidebar` uses O(log n) binary search for active segment detection, auto-scrolls active segment into view, and click-to-seek. `WatchPage` composes all components with CSS grid layout (sidebar beside player on desktop, below on mobile). Route `/watch/:videoId` is lazy-loaded via React.lazy — builds into a separate 10.71 KB chunk. `?t=` query param sets initial seek position with NaN/negative clamping. Updated `TechniquePage` to wrap key moment timestamps in Links to `/watch/:videoId?t=X`. hls.js code-splits into its own 524 KB chunk (not in main bundle).
## Verification
- `cd frontend && npx tsc --noEmit` — zero type errors ✅
- `cd frontend && npm run build` — clean production build, WatchPage chunk (10.71 KB) and hls.js chunk (523.81 KB) both code-split ✅
- Backend tests (5/5 pass): test_get_video_detail_success, test_get_video_detail_404, test_get_transcript_success, test_get_transcript_404, test_get_transcript_empty ✅
- Binary search in TranscriptSidebar confirmed via code inspection ✅
- WatchPage lazy-loaded in App.tsx ✓, route at /watch/:videoId ✓
- TechniquePage timestamps link to /watch/:videoId?t=X ✓
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Minor: React 18 strict ref typing required RefObject cast at JSX site. TS2532 strict array indexing fix in binary search for tsc -b mode.
## Known Limitations
video_url is always null for now — no HLS/mp4 URLs in the database yet. Player renders "Video not available" placeholder until video hosting is configured. Container deployment not yet rebuilt with new code (test file not in container image).
## Follow-ups
- Configure video hosting (HLS transcoding pipeline or direct mp4 storage) so video_url is populated
- Add GET /review/moments/{id} equivalent for videos if single-resource fetching becomes needed beyond the list+filter pattern
- Consider deterministic Qdrant UUIDs for re-indexing (KNOWLEDGE.md deferred item)
## Files Created/Modified
- `backend/routers/videos.py` — Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints
- `backend/schemas.py` — Added SourceVideoDetail and TranscriptForPlayerResponse schemas
- `backend/tests/test_video_detail.py` — New: 5 integration tests for video detail and transcript endpoints
- `frontend/src/hooks/useMediaSync.ts` — New: shared playback state hook for video player and transcript sync
- `frontend/src/components/VideoPlayer.tsx` — New: HLS/mp4 video player with lazy-loaded hls.js
- `frontend/src/components/PlayerControls.tsx` — New: custom controls with speed, volume, seek, fullscreen, keyboard shortcuts
- `frontend/src/components/TranscriptSidebar.tsx` — New: synced transcript sidebar with binary search and auto-scroll
- `frontend/src/pages/WatchPage.tsx` — New: composed watch page with responsive grid layout
- `frontend/src/api/videos.ts` — New: API client for video detail and transcript endpoints
- `frontend/src/App.tsx` — Added lazy-loaded /watch/:videoId route
- `frontend/src/pages/TechniquePage.tsx` — Wrapped key moment timestamps in Links to /watch/:videoId?t=X
- `frontend/src/App.css` — Added player, controls, transcript sidebar, and watch page responsive styles
- `frontend/package.json` — Added hls.js dependency

View file

@ -0,0 +1,69 @@
# S01: [A] Web Media Player MVP — UAT
**Milestone:** M020
**Written:** 2026-04-03T23:52:33.309Z
# S01 UAT: Web Media Player MVP
## Preconditions
- Chrysopedia running on ub01:8096 with rebuilt containers (backend + frontend)
- At least one video with transcript segments in the database
- Browser at desktop width (≥1024px) and mobile width (≤768px)
## Test Cases
### TC1: Video Detail API
1. `curl http://ub01:8096/api/v1/videos/{valid_video_id}` → 200, response includes `id`, `filename`, `creator_name`, `creator_slug`, `video_url`
2. `curl http://ub01:8096/api/v1/videos/00000000-0000-0000-0000-000000000000` → 404
### TC2: Transcript API
3. `curl http://ub01:8096/api/v1/videos/{valid_video_id}/transcript` → 200, `segments` array ordered by `segment_index`, `total` matches array length
4. `curl http://ub01:8096/api/v1/videos/00000000-0000-0000-0000-000000000000/transcript` → 404
5. For a video with no segments → 200, `segments: []`, `total: 0`
### TC3: Watch Page Navigation
6. Navigate to `/watch/{valid_video_id}` → page loads with video player area and transcript sidebar
7. Navigate to `/watch/invalid-uuid` → error state displayed (not a crash/blank page)
8. Navigate to `/watch/{valid_video_id}?t=30` → page loads, player area shows (if video_url null: "Video not available" placeholder)
9. Navigate to `/watch/{valid_video_id}?t=abc` → page loads normally, no crash (t clamped to 0)
10. Navigate to `/watch/{valid_video_id}?t=-5` → page loads normally, no crash (t clamped to 0)
### TC4: Video Player States
11. When `video_url` is null → "Video not available" placeholder with explanatory text
12. When `video_url` is an .m3u8 URL → hls.js loads and attaches to video element
13. When `video_url` is an .mp4 URL → native video playback
14. Player controls visible: play/pause, seek bar, time display, speed selector, volume, fullscreen
### TC5: Player Controls
15. Click play/pause → toggles playback state, icon changes
16. Click speed options → 0.5x, 0.75x, 1x, 1.25x, 1.5x, 2x all selectable, active state highlighted
17. Drag seek bar → currentTime updates, transcript follows
18. Volume slider → adjusts volume 01, mute button toggles mute
19. Fullscreen button → enters fullscreen mode
20. Keyboard: Space → play/pause, Left/Right arrows → ±5s seek, Up/Down → ±0.1 volume
### TC6: Transcript Sidebar
21. Transcript segments listed with MM:SS timestamps and text
22. Active segment highlighted with cyan left border as video plays
23. Active segment auto-scrolls into view
24. Click any segment → video seeks to that segment's start_time
25. Empty segments → "No transcript available" message
### TC7: Responsive Layout
26. Desktop (≥1024px): video player on left, transcript sidebar on right (CSS grid 2-column)
27. Mobile (≤768px): transcript below video player (single column)
28. Player controls responsive at mobile widths
### TC8: Technique Page Timestamp Links
29. Navigate to any technique page with key moments → timestamps show as cyan links
30. Click a timestamp → navigates to `/watch/{source_video_id}?t={start_time}`
31. Key moments without `source_video_id` → timestamp shown as plain text (not a link)
### TC9: Code Splitting
32. Open Network tab, navigate to homepage → no hls.js or WatchPage chunks loaded
33. Navigate to `/watch/:videoId` → WatchPage chunk (~10 KB) loads on demand
34. hls.js chunk (~524 KB) loads only when a video with HLS source starts playing
### TC10: Build Integrity
35. `cd frontend && npx tsc --noEmit` → zero errors
36. `cd frontend && npm run build` → clean build, WatchPage and hls.js in separate chunks

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M020/S01/T03",
"timestamp": 1775260215349,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 817,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 104,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,58 @@
# S02: [A] Creator Dashboard with Real Analytics
**Goal:** Build analytics queries and dashboard UI showing creator impact metrics
**Goal:** Creator dashboard shows real analytics (upload count, technique pages generated, search impressions, content library) from an authenticated backend endpoint.
**Demo:** After this: Dashboard shows upload count, technique pages generated, search impressions, content library
## Tasks
- [x] **T01: Added GET /api/v1/creator/dashboard returning video_count, technique_count, key_moment_count, search_impressions, techniques list, and videos list for the authenticated creator** — Create `GET /api/v1/creator/dashboard` behind `get_current_user`. Returns a single JSON payload with:
- `video_count`: COUNT of source_videos for this creator
- `technique_count`: COUNT of technique_pages for this creator
- `key_moment_count`: COUNT of key_moments via JOIN through source_videos
- `search_impressions`: COUNT of SearchLog rows where LOWER(query) matches any of the creator's technique page titles (case-insensitive ILIKE)
- `techniques`: list of technique pages (title, slug, topic_category, created_at, key_moment_count)
- `videos`: list of source videos (filename, processing_status, created_at)
The User model has `creator_id` FK which may be null — return 404 with clear message if user has no linked creator.
Steps:
1. Create `backend/routers/creator_dashboard.py` with `router = APIRouter(prefix="/creator", tags=["creator-dashboard"])`
2. Add Pydantic response schemas in `backend/schemas.py`: `CreatorDashboardStats`, `CreatorDashboardTechnique`, `CreatorDashboardVideo`, `CreatorDashboardResponse`
3. Implement the endpoint: resolve creator from `user.creator_id`, run count queries, compute search impressions via SearchLog JOIN, assemble technique and video lists
4. Register router in `backend/main.py`
5. Test with curl against running API
Search impressions query approach: SELECT COUNT(DISTINCT sl.id) FROM search_log sl WHERE EXISTS (SELECT 1 FROM technique_pages tp WHERE tp.creator_id = :creator_id AND LOWER(sl.query) = LOWER(tp.title)). This gives exact title matches — sufficient for MVP. Can be expanded to ILIKE partial matching later.
Constraints:
- Use `Depends(get_current_user)` from `backend/auth.py`
- Use `AsyncSession` from `database.get_session`
- Follow existing patterns from `backend/routers/creators.py` for count subqueries
- Logger: `logging.getLogger('chrysopedia.creator_dashboard')`
- Estimate: 45m
- Files: backend/routers/creator_dashboard.py, backend/schemas.py, backend/main.py
- Verify: curl -s -H 'Authorization: Bearer $TOKEN' http://localhost:8000/api/v1/creator/dashboard | python3 -m json.tool — returns JSON with video_count, technique_count, key_moment_count, search_impressions, techniques (array), videos (array). Unauthenticated request returns 401.
- [ ] **T02: Replace placeholder dashboard with real stats and content library** — Replace the three placeholder cards in CreatorDashboard.tsx with real data from the new endpoint. Add the API module, types, and all frontend rendering.
Steps:
1. Create `frontend/src/api/creator-dashboard.ts` with TypeScript types matching the backend response schema and a `fetchCreatorDashboard()` function using the shared `request<T>()` helper from `client.ts`
2. Export from `frontend/src/api/index.ts`
3. Rewrite `frontend/src/pages/CreatorDashboard.tsx`:
a. Add state: `useState` for dashboard data, loading, error
b. `useEffect` to call `fetchCreatorDashboard()` on mount
c. Render 4 stat cards in a row: Uploads (video_count), Techniques (technique_count), Key Moments (key_moment_count), Search Impressions (search_impressions)
d. Render content library section: table/card list of technique pages showing title (linked to /techniques/:slug), topic_category badge, key_moment_count, created_at date
e. Below techniques, show source videos: filename, processing_status badge, created_at
f. Handle loading state (skeleton or spinner), error state (message), empty state (user has no creator_id → friendly 'not linked' message)
g. Keep the existing SidebarNav component and layout structure — the sidebar already works
4. Update `frontend/src/pages/CreatorDashboard.module.css` with styles for stat cards (number + label layout), content table, status badges
5. Verify: `npx tsc --noEmit` and `npm run build` pass
Constraints:
- Use existing CSS custom properties (var(--color-*)) for all colors
- Use existing `request()` from `client.ts` which auto-attaches auth token
- Link technique titles to `/techniques/{slug}` using React Router `<Link>`
- Status badges should use existing badge color patterns from the CSS variable system
- Mobile: stat cards should stack 2-col on tablet, 1-col on mobile. Content table should become cards on mobile.
- Estimate: 1h
- Files: frontend/src/api/creator-dashboard.ts, frontend/src/api/index.ts, frontend/src/pages/CreatorDashboard.tsx, frontend/src/pages/CreatorDashboard.module.css
- Verify: cd frontend && npx tsc --noEmit && npm run build — both pass with zero errors. Visual check: dashboard page renders stat cards with numbers and content library table.

View file

@ -0,0 +1,116 @@
# S02 Research — Creator Dashboard with Real Analytics
## Summary
This slice replaces the placeholder CreatorDashboard.tsx with real analytics: upload count, technique pages generated, search impressions, and a content library. The auth infrastructure (JWT, User→Creator FK, ProtectedRoute) is fully built. The main work is: (1) a new backend endpoint returning creator-scoped stats, (2) search impression tracking per-creator, and (3) replacing the placeholder frontend cards with real data.
**Depth: Targeted** — known patterns, moderate integration (new endpoint + SearchLog enrichment + frontend).
## Recommendation
Build a single new `GET /api/v1/creator/dashboard` authenticated endpoint that aggregates all dashboard data in one call (videos, techniques, search impressions, content list). Add `creator_slug` to SearchLog so impressions can be attributed. Replace the placeholder cards in CreatorDashboard.tsx with real stat cards and a content library table.
## Implementation Landscape
### What Exists
| Component | File | State |
|-----------|------|-------|
| Dashboard page (placeholder) | `frontend/src/pages/CreatorDashboard.tsx` | Shell with 3 "coming soon" cards, sidebar nav, welcome message |
| Dashboard CSS module | `frontend/src/pages/CreatorDashboard.module.css` | Full sidebar + card grid layout, mobile responsive |
| Sidebar nav component | `SidebarNav` exported from `CreatorDashboard.tsx` | Dashboard + Content (disabled) + Settings links |
| Auth context | `frontend/src/context/AuthContext.tsx` | Provides `user` (with `creator_id`), `token`, `isAuthenticated` |
| API client | `frontend/src/api/client.ts` | Auto-attaches Bearer token from localStorage |
| Auth backend | `backend/auth.py` | `get_current_user` dependency, `create_access_token`, JWT HS256, 24h expiry |
| Auth router | `backend/routers/auth.py` | Register, login, GET/PUT /me |
| User model | `backend/models.py:133` | `creator_id` FK to creators table (nullable) |
| Creator model | `backend/models.py:105` | `view_count` (int, never incremented), relationships to videos and technique_pages |
| TechniquePage model | `backend/models.py:265` | `creator_id` FK, `view_count` (never incremented) |
| SourceVideo model | `backend/models.py:179` | `creator_id` FK, `processing_status`, `created_at` |
| SearchLog model | `backend/models.py:457` | `query`, `scope`, `result_count`, `created_at`**NO creator attribution** |
| Stats endpoint | `backend/routers/stats.py` | Global counts only (technique_count, creator_count) |
| Creator detail endpoint | `backend/routers/creators.py` | `GET /creators/{slug}` returns video_count, technique_count, techniques list |
| Protected route wrapper | `frontend/src/components/ProtectedRoute.tsx` | Wraps creator routes in App.tsx |
| Route definition | `frontend/src/App.tsx` | `/creator/dashboard` → lazy-loaded CreatorDashboard inside ProtectedRoute |
### What's Missing
1. **Creator-scoped dashboard endpoint** — No `GET /api/v1/creator/dashboard` exists. The public `GET /creators/{slug}` has most of the data but isn't auth-scoped and doesn't include search impressions.
2. **Search impression attribution**`SearchLog` has no `creator_id` or result metadata. To show "search impressions" per creator, we need either:
- **Option A (recommended):** Count SearchLog rows where the query text matches the creator's technique page titles/tags (approximate but zero-migration)
- **Option B:** Add a `search_impression` table or enrich SearchLog with result metadata (accurate but requires migration + search endpoint changes)
- **Option C:** Count technique page `view_count` as a proxy for "impressions" — but view_count is never incremented currently
3. **Frontend data fetching and rendering** — Dashboard page needs API call, stat cards, content library table.
4. **Content library** — A table/list of the creator's technique pages and source videos with status, dates, key moment counts.
### Data Availability for Dashboard Stats
| Stat | Source | Query |
|------|--------|-------|
| Upload count | `COUNT(source_videos WHERE creator_id = ?)` | Direct, available now |
| Technique pages generated | `COUNT(technique_pages WHERE creator_id = ?)` | Direct, available now |
| Total key moments | `COUNT(key_moments) JOIN source_videos WHERE creator_id = ?` | Available via join |
| Search impressions | **Not directly available** — SearchLog lacks creator attribution | Needs approximation or new tracking |
| Content library | `technique_pages + source_videos WHERE creator_id = ?` | Direct, available now |
### Search Impressions Strategy
**Recommended approach:** Approximate impressions by counting SearchLog rows where the lowercased query appears in any of the creator's technique page titles or topic_tags. This is imprecise but:
- Zero migration required
- Gives directionally useful data
- Can be replaced with proper impression tracking later
The query: join SearchLog against TechniquePage (by creator_id), WHERE `LOWER(search_log.query)` matches `LOWER(technique_pages.title)` or is contained in `technique_pages.topic_tags`. This catches searches for "keota snare" showing up as impressions for Keota's technique pages.
Alternatively, just show the total view_count sum across the creator's technique pages — but this is always 0 currently because view counting isn't implemented. The simplest honest approach is to show search impressions as "queries that returned your content" — count SearchLog rows where the search results would have included this creator's content. Given the small scale, running the actual search query count isn't expensive.
**Simplest viable path:** Add a search_impressions column or compute it from SearchLog + technique page title matching. Show 0 honestly if no data exists yet — the dashboard should be real, not inflated.
### Natural Task Seams
1. **T01: Backend dashboard endpoint** — New `GET /api/v1/creator/dashboard` endpoint behind `get_current_user`. Queries: video count, technique count, key moment count, technique pages list (with key_moment_count), source videos list (with processing_status). Returns a single JSON payload. Search impressions can be a simple count from SearchLog where query text partially matches any of the creator's technique titles (case-insensitive ILIKE). No migration needed.
2. **T02: Frontend dashboard with real data** — Replace placeholder cards with: (a) stat cards row (uploads, techniques, key moments, search impressions), (b) content library table showing technique pages + videos with status/dates. Wire to new endpoint. Enable the "Content" sidebar link.
3. **T03: Frontend API module + types** — Add `frontend/src/api/creator-dashboard.ts` with types and fetch function for the new endpoint. Small but clean separation.
T01 and T03 are independent. T02 depends on both.
### Key Constraints
- **User.creator_id may be null** — Dashboard must handle users who aren't linked to a creator (show empty state, not crash).
- **Auth required** — Endpoint must use `Depends(get_current_user)` and resolve creator from `user.creator_id`.
- **Existing sidebar nav** — Already has Dashboard/Content/Settings links. Content link is currently disabled (`sidebarLinkDisabled` class). Should remain disabled unless we add a content management page (out of scope for this slice — the "content library" is read-only view).
- **No migration needed** — All data is queryable from existing tables. Search impressions are approximated.
### Files Likely Touched
**Backend:**
- `backend/routers/creator_dashboard.py` (new) — authenticated dashboard endpoint
- `backend/main.py` — register new router
- `backend/schemas.py` — new response schemas (CreatorDashboardResponse, etc.)
**Frontend:**
- `frontend/src/api/creator-dashboard.ts` (new) — API types and fetch function
- `frontend/src/api/index.ts` — re-export new module
- `frontend/src/pages/CreatorDashboard.tsx` — replace placeholder with real data
- `frontend/src/pages/CreatorDashboard.module.css` — new styles for stat cards and content table
### Verification
```bash
# Backend: endpoint returns real data for authenticated creator
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/creator/dashboard
# Frontend: build succeeds
cd frontend && npm run build
# Integration: dashboard loads with real stats when logged in as a creator user
```
### Risk Assessment
**Low risk.** All data sources exist. Auth infrastructure is proven. The only uncertainty is the search impressions approximation — but showing an honest count (even 0) is better than fabricating metrics. The pattern of "authenticated endpoint querying per-creator data" follows the existing creator detail endpoint closely.

View file

@ -0,0 +1,51 @@
---
estimated_steps: 20
estimated_files: 3
skills_used: []
---
# T01: Build authenticated creator dashboard endpoint
Create `GET /api/v1/creator/dashboard` behind `get_current_user`. Returns a single JSON payload with:
- `video_count`: COUNT of source_videos for this creator
- `technique_count`: COUNT of technique_pages for this creator
- `key_moment_count`: COUNT of key_moments via JOIN through source_videos
- `search_impressions`: COUNT of SearchLog rows where LOWER(query) matches any of the creator's technique page titles (case-insensitive ILIKE)
- `techniques`: list of technique pages (title, slug, topic_category, created_at, key_moment_count)
- `videos`: list of source videos (filename, processing_status, created_at)
The User model has `creator_id` FK which may be null — return 404 with clear message if user has no linked creator.
Steps:
1. Create `backend/routers/creator_dashboard.py` with `router = APIRouter(prefix="/creator", tags=["creator-dashboard"])`
2. Add Pydantic response schemas in `backend/schemas.py`: `CreatorDashboardStats`, `CreatorDashboardTechnique`, `CreatorDashboardVideo`, `CreatorDashboardResponse`
3. Implement the endpoint: resolve creator from `user.creator_id`, run count queries, compute search impressions via SearchLog JOIN, assemble technique and video lists
4. Register router in `backend/main.py`
5. Test with curl against running API
Search impressions query approach: SELECT COUNT(DISTINCT sl.id) FROM search_log sl WHERE EXISTS (SELECT 1 FROM technique_pages tp WHERE tp.creator_id = :creator_id AND LOWER(sl.query) = LOWER(tp.title)). This gives exact title matches — sufficient for MVP. Can be expanded to ILIKE partial matching later.
Constraints:
- Use `Depends(get_current_user)` from `backend/auth.py`
- Use `AsyncSession` from `database.get_session`
- Follow existing patterns from `backend/routers/creators.py` for count subqueries
- Logger: `logging.getLogger('chrysopedia.creator_dashboard')`
## Inputs
- ``backend/auth.py` — get_current_user dependency`
- ``backend/models.py` — User, Creator, SourceVideo, TechniquePage, KeyMoment, SearchLog models`
- ``backend/schemas.py` — existing schema patterns (CreatorRead, CreatorDetail, CreatorTechniqueItem)`
- ``backend/routers/creators.py` — reference for count subquery patterns`
- ``backend/database.py` — get_session dependency`
- ``backend/main.py` — router registration pattern`
## Expected Output
- ``backend/routers/creator_dashboard.py` — new authenticated dashboard endpoint`
- ``backend/schemas.py` — CreatorDashboardResponse and supporting schemas added`
- ``backend/main.py` — creator_dashboard router registered`
## Verification
curl -s -H 'Authorization: Bearer $TOKEN' http://localhost:8000/api/v1/creator/dashboard | python3 -m json.tool — returns JSON with video_count, technique_count, key_moment_count, search_impressions, techniques (array), videos (array). Unauthenticated request returns 401.

View file

@ -0,0 +1,85 @@
---
id: T01
parent: S02
milestone: M020
provides: []
requires: []
affects: []
key_files: ["backend/routers/creator_dashboard.py", "backend/schemas.py", "backend/main.py", "alembic/versions/016_add_users_and_invite_codes.py"]
key_decisions: ["Search impressions use exact case-insensitive title match via EXISTS subquery for MVP", "Alembic migration 016 uses raw SQL for enum/table creation to avoid SQLAlchemy asyncpg double-creation bug"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Verified authenticated request returns JSON with all expected fields (video_count=3, technique_count=2, key_moment_count=30, search_impressions=0, 2 techniques, 3 videos). Unauthenticated returns 401. Works through both direct API and nginx proxy."
completed_at: 2026-04-04T00:09:12.412Z
blocker_discovered: false
---
# T01: Added GET /api/v1/creator/dashboard returning video_count, technique_count, key_moment_count, search_impressions, techniques list, and videos list for the authenticated creator
> Added GET /api/v1/creator/dashboard returning video_count, technique_count, key_moment_count, search_impressions, techniques list, and videos list for the authenticated creator
## What Happened
---
id: T01
parent: S02
milestone: M020
key_files:
- backend/routers/creator_dashboard.py
- backend/schemas.py
- backend/main.py
- alembic/versions/016_add_users_and_invite_codes.py
key_decisions:
- Search impressions use exact case-insensitive title match via EXISTS subquery for MVP
- Alembic migration 016 uses raw SQL for enum/table creation to avoid SQLAlchemy asyncpg double-creation bug
duration: ""
verification_result: passed
completed_at: 2026-04-04T00:09:12.413Z
blocker_discovered: false
---
# T01: Added GET /api/v1/creator/dashboard returning video_count, technique_count, key_moment_count, search_impressions, techniques list, and videos list for the authenticated creator
**Added GET /api/v1/creator/dashboard returning video_count, technique_count, key_moment_count, search_impressions, techniques list, and videos list for the authenticated creator**
## What Happened
Created backend/routers/creator_dashboard.py with authenticated endpoint that resolves creator from user.creator_id, runs aggregate count queries, and returns dashboard analytics. Added Pydantic response schemas to schemas.py, registered router in main.py. Fixed alembic migration 016 to use raw SQL to avoid SQLAlchemy asyncpg enum double-creation bug. Synced full backend to ub01 and applied pending migrations.
## Verification
Verified authenticated request returns JSON with all expected fields (video_count=3, technique_count=2, key_moment_count=30, search_impressions=0, 2 techniques, 3 videos). Unauthenticated returns 401. Works through both direct API and nginx proxy.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `curl authenticated via docker exec chrysopedia-api` | 0 | ✅ pass | 200ms |
| 2 | `curl unauthenticated via docker exec chrysopedia-api` | 0 | ✅ pass | 50ms |
| 3 | `curl authenticated via nginx proxy :8096` | 0 | ✅ pass | 150ms |
| 4 | `curl unauthenticated via nginx proxy :8096` | 0 | ✅ pass | 50ms |
| 5 | `alembic upgrade head (migrations 016+017)` | 0 | ✅ pass | 2000ms |
## Deviations
Synced entire backend directory to ub01 (was significantly behind). Rewrote migration 016 to use raw SQL instead of SQLAlchemy ORM table creation to work around asyncpg enum double-creation bug.
## Known Issues
search_impressions returns 0 because exact title match finds no hits in current SearchLog data. Can be expanded to ILIKE partial matching later.
## Files Created/Modified
- `backend/routers/creator_dashboard.py`
- `backend/schemas.py`
- `backend/main.py`
- `alembic/versions/016_add_users_and_invite_codes.py`
## Deviations
Synced entire backend directory to ub01 (was significantly behind). Rewrote migration 016 to use raw SQL instead of SQLAlchemy ORM table creation to work around asyncpg enum double-creation bug.
## Known Issues
search_impressions returns 0 because exact title match finds no hits in current SearchLog data. Can be expanded to ILIKE partial matching later.

View file

@ -0,0 +1,50 @@
---
estimated_steps: 20
estimated_files: 4
skills_used: []
---
# T02: Replace placeholder dashboard with real stats and content library
Replace the three placeholder cards in CreatorDashboard.tsx with real data from the new endpoint. Add the API module, types, and all frontend rendering.
Steps:
1. Create `frontend/src/api/creator-dashboard.ts` with TypeScript types matching the backend response schema and a `fetchCreatorDashboard()` function using the shared `request<T>()` helper from `client.ts`
2. Export from `frontend/src/api/index.ts`
3. Rewrite `frontend/src/pages/CreatorDashboard.tsx`:
a. Add state: `useState` for dashboard data, loading, error
b. `useEffect` to call `fetchCreatorDashboard()` on mount
c. Render 4 stat cards in a row: Uploads (video_count), Techniques (technique_count), Key Moments (key_moment_count), Search Impressions (search_impressions)
d. Render content library section: table/card list of technique pages showing title (linked to /techniques/:slug), topic_category badge, key_moment_count, created_at date
e. Below techniques, show source videos: filename, processing_status badge, created_at
f. Handle loading state (skeleton or spinner), error state (message), empty state (user has no creator_id → friendly 'not linked' message)
g. Keep the existing SidebarNav component and layout structure — the sidebar already works
4. Update `frontend/src/pages/CreatorDashboard.module.css` with styles for stat cards (number + label layout), content table, status badges
5. Verify: `npx tsc --noEmit` and `npm run build` pass
Constraints:
- Use existing CSS custom properties (var(--color-*)) for all colors
- Use existing `request()` from `client.ts` which auto-attaches auth token
- Link technique titles to `/techniques/{slug}` using React Router `<Link>`
- Status badges should use existing badge color patterns from the CSS variable system
- Mobile: stat cards should stack 2-col on tablet, 1-col on mobile. Content table should become cards on mobile.
## Inputs
- ``frontend/src/api/client.ts` — request() helper and BASE url`
- ``frontend/src/api/index.ts` — re-export pattern`
- ``frontend/src/pages/CreatorDashboard.tsx` — existing placeholder to replace`
- ``frontend/src/pages/CreatorDashboard.module.css` — existing layout styles`
- ``frontend/src/context/AuthContext.tsx` — useAuth hook for user info`
- ``backend/routers/creator_dashboard.py` — endpoint response shape (from T01)`
## Expected Output
- ``frontend/src/api/creator-dashboard.ts` — types and fetch function for dashboard endpoint`
- ``frontend/src/api/index.ts` — re-exports creator-dashboard module`
- ``frontend/src/pages/CreatorDashboard.tsx` — real dashboard with stat cards and content library`
- ``frontend/src/pages/CreatorDashboard.module.css` — updated styles for stat cards, content table, status badges`
## Verification
cd frontend && npx tsc --noEmit && npm run build — both pass with zero errors. Visual check: dashboard page renders stat cards with numbers and content library table.

View file

@ -4,8 +4,6 @@ Revision ID: 016_add_users_and_invite_codes
Revises: 015_add_creator_profile
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "016_add_users_and_invite_codes"
down_revision = "015_add_creator_profile"
@ -14,37 +12,41 @@ depends_on = None
def upgrade() -> None:
# Create user_role enum type
user_role_enum = sa.Enum("creator", "admin", name="user_role", create_constraint=True)
user_role_enum.create(op.get_bind(), checkfirst=True)
# Use raw SQL to avoid SQLAlchemy's Enum double-creation bug with asyncpg
op.execute("""
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('creator', 'admin');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
# Create users table
op.create_table(
"users",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("role", user_role_enum, nullable=False, server_default="creator"),
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="SET NULL"), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.execute("""
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
hashed_password VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'creator',
creator_id UUID REFERENCES creators(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
""")
# Create invite_codes table
op.create_table(
"invite_codes",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("code", sa.String(100), nullable=False, unique=True),
sa.Column("uses_remaining", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.execute("""
CREATE TABLE IF NOT EXISTS invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(100) NOT NULL UNIQUE,
uses_remaining INTEGER NOT NULL DEFAULT 1,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
""")
def downgrade() -> None:
op.drop_table("invite_codes")
op.drop_table("users")
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
op.execute("DROP TABLE IF EXISTS invite_codes")
op.execute("DROP TABLE IF EXISTS users")
op.execute("DROP TYPE IF EXISTS user_role")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import get_settings
from routers import auth, consent, creators, health, ingest, pipeline, reports, search, stats, techniques, topics, videos
from routers import auth, consent, creator_dashboard, creators, health, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None:
@ -80,6 +80,7 @@ app.include_router(health.router)
# Versioned API
app.include_router(auth.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1")
app.include_router(pipeline.router, prefix="/api/v1")

View file

@ -0,0 +1,162 @@
"""Creator dashboard endpoint — authenticated analytics for a linked creator.
Returns aggregate counts (videos, technique pages, key moments, search
impressions) and content lists for the logged-in creator's dashboard.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import (
Creator,
KeyMoment,
SearchLog,
SourceVideo,
TechniquePage,
User,
)
from schemas import (
CreatorDashboardResponse,
CreatorDashboardTechnique,
CreatorDashboardVideo,
)
logger = logging.getLogger("chrysopedia.creator_dashboard")
router = APIRouter(prefix="/creator", tags=["creator-dashboard"])
@router.get("/dashboard", response_model=CreatorDashboardResponse)
async def get_creator_dashboard(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> CreatorDashboardResponse:
"""Return dashboard analytics for the authenticated creator.
Requires the user to have a linked creator_id. Returns 404 if the
user has no linked creator profile.
"""
if current_user.creator_id is None:
raise HTTPException(
status_code=404,
detail="No creator profile linked to this account",
)
creator_id = current_user.creator_id
# Verify creator exists (defensive — FK should guarantee this)
creator = (await db.execute(
select(Creator).where(Creator.id == creator_id)
)).scalar_one_or_none()
if creator is None:
logger.error("User %s has creator_id %s but creator row missing", current_user.id, creator_id)
raise HTTPException(
status_code=404,
detail="Linked creator profile not found",
)
# ── Aggregate counts ─────────────────────────────────────────────────
video_count = (await db.execute(
select(func.count()).select_from(SourceVideo)
.where(SourceVideo.creator_id == creator_id)
)).scalar() or 0
technique_count = (await db.execute(
select(func.count()).select_from(TechniquePage)
.where(TechniquePage.creator_id == creator_id)
)).scalar() or 0
key_moment_count = (await db.execute(
select(func.count()).select_from(KeyMoment)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.where(SourceVideo.creator_id == creator_id)
)).scalar() or 0
# Search impressions: count distinct search_log rows where the query
# exactly matches (case-insensitive) any of this creator's technique titles.
search_impressions = (await db.execute(
select(func.count(func.distinct(SearchLog.id)))
.where(
select(TechniquePage.id)
.where(
TechniquePage.creator_id == creator_id,
func.lower(SearchLog.query) == func.lower(TechniquePage.title),
)
.correlate(SearchLog)
.exists()
)
)).scalar() or 0
# ── Content lists ────────────────────────────────────────────────────
# Techniques with per-page key moment count
km_count_sq = (
select(func.count(KeyMoment.id))
.where(KeyMoment.technique_page_id == TechniquePage.id)
.correlate(TechniquePage)
.scalar_subquery()
.label("key_moment_count")
)
technique_rows = (await db.execute(
select(
TechniquePage.title,
TechniquePage.slug,
TechniquePage.topic_category,
TechniquePage.created_at,
km_count_sq,
)
.where(TechniquePage.creator_id == creator_id)
.order_by(TechniquePage.created_at.desc())
)).all()
techniques = [
CreatorDashboardTechnique(
title=r.title,
slug=r.slug,
topic_category=r.topic_category,
created_at=r.created_at,
key_moment_count=r.key_moment_count or 0,
)
for r in technique_rows
]
# Videos
video_rows = (await db.execute(
select(
SourceVideo.filename,
SourceVideo.processing_status,
SourceVideo.created_at,
)
.where(SourceVideo.creator_id == creator_id)
.order_by(SourceVideo.created_at.desc())
)).all()
videos = [
CreatorDashboardVideo(
filename=r.filename,
processing_status=r.processing_status.value if hasattr(r.processing_status, 'value') else str(r.processing_status),
created_at=r.created_at,
)
for r in video_rows
]
logger.info(
"Dashboard loaded for creator %s: %d videos, %d techniques, %d moments, %d impressions",
creator_id, video_count, technique_count, key_moment_count, search_impressions,
)
return CreatorDashboardResponse(
video_count=video_count,
technique_count=technique_count,
key_moment_count=key_moment_count,
search_impressions=search_impressions,
techniques=techniques,
videos=videos,
)

View file

@ -621,3 +621,39 @@ class ConsentSummary(BaseModel):
kb_inclusion_granted: int = 0
training_usage_granted: int = 0
public_display_granted: int = 0
# ── Creator Dashboard ────────────────────────────────────────────────────────
class CreatorDashboardTechnique(BaseModel):
"""Technique page summary for creator dashboard."""
title: str
slug: str
topic_category: str
created_at: datetime
key_moment_count: int = 0
class CreatorDashboardVideo(BaseModel):
"""Source video summary for creator dashboard."""
filename: str
processing_status: str
created_at: datetime
class CreatorDashboardStats(BaseModel):
"""Aggregate counts for dashboard header."""
video_count: int = 0
technique_count: int = 0
key_moment_count: int = 0
search_impressions: int = 0
class CreatorDashboardResponse(BaseModel):
"""Full creator dashboard payload."""
video_count: int = 0
technique_count: int = 0
key_moment_count: int = 0
search_impressions: int = 0
techniques: list[CreatorDashboardTechnique] = Field(default_factory=list)
videos: list[CreatorDashboardVideo] = Field(default_factory=list)