11from __future__ import annotations
22
3- from fastapi import APIRouter , Depends , Response , status
3+ from typing import Annotated , Optional
4+
5+ from fastapi import APIRouter , Depends , HTTPException , Response , status
46from sqlalchemy .orm import Session
57
6- from app .core .auth import ClerkClaims , require_clerk_auth
8+ from app .core .auth import ClerkClaims , optional_clerk_auth , require_clerk_auth
79from app .core .database import get_db
810from app .models .roadmap import (
911 RatingRequest ,
1315 RoadmapResponse ,
1416 UserRepoStateResponse ,
1517)
18+ from app .services .roadmap_repository import SortOption
1619from app .services .roadmap_service import RoadmapService , build_roadmap_service
1720
1821router = APIRouter ()
@@ -22,13 +25,38 @@ def get_roadmap_service(session: Session = Depends(get_db)) -> RoadmapService:
2225 return build_roadmap_service (session )
2326
2427
28+ def get_user_id (claims : ClerkClaims ) -> str :
29+ """Extract user ID from ClerkClaims, raising error if missing."""
30+ user_id = claims .get ("sub" )
31+ if not user_id :
32+ raise HTTPException (
33+ status_code = status .HTTP_401_UNAUTHORIZED ,
34+ detail = "User ID not found in authentication claims" ,
35+ )
36+ return user_id
37+
38+
2539@router .get ("/catalog" , response_model = RoadmapCatalogPage )
2640async def list_roadmaps (
2741 page : int = 1 ,
2842 page_size : int = 20 ,
43+ sort : Annotated [
44+ SortOption ,
45+ "Sort option: newest, most_viewed, most_synced, highest_rated, trending" ,
46+ ] = "newest" ,
2947 service : RoadmapService = Depends (get_roadmap_service ),
3048) -> RoadmapCatalogPage :
31- return await service .list_catalog (page , page_size )
49+ """
50+ List roadmaps with pagination and sorting.
51+
52+ Sort options:
53+ - newest: Order by updated_at DESC (default)
54+ - most_viewed: Order by view_count DESC
55+ - most_synced: Order by sync_count DESC
56+ - highest_rated: Order by average rating DESC
57+ - trending: Order by trending score (combines views, syncs, and ratings)
58+ """
59+ return await service .list_catalog (page , page_size , sort )
3260
3361
3462@router .get ("/cached/{owner}/{repo}" , response_model = RoadmapResponse )
@@ -47,10 +75,11 @@ async def generate_roadmap(
4775 service : RoadmapService = Depends (get_roadmap_service ),
4876 current_user : ClerkClaims = Depends (require_clerk_auth ),
4977) -> RoadmapResponse :
78+ user_id = get_user_id (current_user )
5079 return await service .generate (
5180 repo_url = str (payload .repo_url ),
5281 force_refresh = payload .force_refresh ,
53- actor_id = current_user [ "sub" ] ,
82+ actor_id = user_id ,
5483 )
5584
5685
@@ -59,7 +88,7 @@ async def list_pinned_roadmaps(
5988 current_user : ClerkClaims = Depends (require_clerk_auth ),
6089 service : RoadmapService = Depends (get_roadmap_service ),
6190) -> list [RoadmapResponse ]:
62- return await service .list_user_pins (current_user [ "sub" ] )
91+ return await service .list_user_pins (get_user_id ( current_user ) )
6392
6493
6594@router .delete ("/pins/{owner}/{repo}" , status_code = status .HTTP_204_NO_CONTENT )
@@ -69,7 +98,7 @@ async def unpin_roadmap(
6998 current_user : ClerkClaims = Depends (require_clerk_auth ),
7099 service : RoadmapService = Depends (get_roadmap_service ),
71100) -> Response :
72- await service .unpin_repo (current_user [ "sub" ] , f"{ owner } /{ repo } " )
101+ await service .unpin_repo (get_user_id ( current_user ) , f"{ owner } /{ repo } " )
73102 return Response (status_code = status .HTTP_204_NO_CONTENT )
74103
75104
@@ -78,7 +107,7 @@ async def list_user_repositories(
78107 current_user : ClerkClaims = Depends (require_clerk_auth ),
79108 service : RoadmapService = Depends (get_roadmap_service ),
80109) -> list [UserRepoStateResponse ]:
81- return await service .list_user_repos (current_user [ "sub" ] )
110+ return await service .list_user_repos (get_user_id ( current_user ) )
82111
83112
84113@router .post ("/sync/{owner}/{repo}" , response_model = UserRepoStateResponse )
@@ -88,7 +117,7 @@ async def sync_repository(
88117 current_user : ClerkClaims = Depends (require_clerk_auth ),
89118 service : RoadmapService = Depends (get_roadmap_service ),
90119) -> UserRepoStateResponse :
91- return await service .sync_repo (owner , repo , current_user [ "sub" ] )
120+ return await service .sync_repo (owner , repo , get_user_id ( current_user ) )
92121
93122
94123@router .delete ("/sync/{owner}/{repo}" , status_code = status .HTTP_204_NO_CONTENT )
@@ -98,7 +127,7 @@ async def desync_repository(
98127 current_user : ClerkClaims = Depends (require_clerk_auth ),
99128 service : RoadmapService = Depends (get_roadmap_service ),
100129) -> Response :
101- await service .desync_repo (owner , repo , current_user [ "sub" ] )
130+ await service .desync_repo (owner , repo , get_user_id ( current_user ) )
102131 return Response (status_code = status .HTTP_204_NO_CONTENT )
103132
104133
@@ -109,7 +138,7 @@ async def archive_repository(
109138 current_user : ClerkClaims = Depends (require_clerk_auth ),
110139 service : RoadmapService = Depends (get_roadmap_service ),
111140) -> UserRepoStateResponse :
112- return await service .archive_repo (owner , repo , current_user [ "sub" ] )
141+ return await service .archive_repo (owner , repo , get_user_id ( current_user ) )
113142
114143
115144@router .post ("/unarchive/{owner}/{repo}" , response_model = UserRepoStateResponse )
@@ -119,15 +148,15 @@ async def unarchive_repository(
119148 current_user : ClerkClaims = Depends (require_clerk_auth ),
120149 service : RoadmapService = Depends (get_roadmap_service ),
121150) -> UserRepoStateResponse :
122- return await service .unarchive_repo (owner , repo , current_user [ "sub" ] )
151+ return await service .unarchive_repo (owner , repo , get_user_id ( current_user ) )
123152
124153
125154@router .get ("/archived" , response_model = list [UserRepoStateResponse ])
126155async def list_archived_repositories (
127156 current_user : ClerkClaims = Depends (require_clerk_auth ),
128157 service : RoadmapService = Depends (get_roadmap_service ),
129158) -> list [UserRepoStateResponse ]:
130- return await service .list_archived_repos (current_user [ "sub" ] )
159+ return await service .list_archived_repos (get_user_id ( current_user ) )
131160
132161
133162@router .post ("/{owner}/{repo}/rating" , response_model = RatingResponse )
@@ -138,7 +167,7 @@ async def set_repository_rating(
138167 current_user : ClerkClaims = Depends (require_clerk_auth ),
139168 service : RoadmapService = Depends (get_roadmap_service ),
140169) -> RatingResponse :
141- return service .set_rating (current_user [ "sub" ] , owner , repo , payload .rating )
170+ return service .set_rating (get_user_id ( current_user ) , owner , repo , payload .rating )
142171
143172
144173@router .get ("/{owner}/{repo}/rating" , response_model = RatingResponse | None )
@@ -148,4 +177,22 @@ async def get_repository_rating(
148177 current_user : ClerkClaims = Depends (require_clerk_auth ),
149178 service : RoadmapService = Depends (get_roadmap_service ),
150179) -> RatingResponse | None :
151- return service .get_user_rating (current_user ["sub" ], owner , repo )
180+ return service .get_user_rating (get_user_id (current_user ), owner , repo )
181+
182+
183+ @router .post ("/{owner}/{repo}/view" , status_code = status .HTTP_204_NO_CONTENT )
184+ async def record_roadmap_view (
185+ owner : str ,
186+ repo : str ,
187+ current_user : Optional [ClerkClaims ] = Depends (optional_clerk_auth ),
188+ service : RoadmapService = Depends (get_roadmap_service ),
189+ ) -> Response :
190+ """
191+ Record a view of a roadmap timeline.
192+
193+ Authentication is optional. If authenticated, implements anti-spam logic
194+ (only one view per user per 24-hour window). Anonymous views are always counted.
195+ """
196+ user_id = current_user .get ("sub" ) if current_user else None
197+ await service .record_roadmap_view (f"{ owner } /{ repo } " , user_id )
198+ return Response (status_code = status .HTTP_204_NO_CONTENT )
0 commit comments