Skip to content

Commit f9ba0f9

Browse files
authored
Merge pull request #56 from alimkhann/feature/views-trending-sort
Feature/views trending sort
2 parents 1226edc + 43f6df8 commit f9ba0f9

File tree

18 files changed

+2014
-114
lines changed

18 files changed

+2014
-114
lines changed

commitly-backend/alembic/versions/20241115_add_generated_roadmaps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""add generated roadmaps table
22
33
Revision ID: 20241115_add_generated_roadmaps
4-
Revises: 20241114_add_github_credentials
4+
Revises: 20241114_add_github_creds
55
Create Date: 2025-11-15 07:40:00.000000
66
"""
77

@@ -10,7 +10,7 @@
1010

1111
# revision identifiers, used by Alembic.
1212
revision = "20241115_add_generated_roadmaps"
13-
down_revision = "20241114_add_github_credentials"
13+
down_revision = "20241114_add_github_creds"
1414
branch_labels = None
1515
depends_on = None
1616

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""add roadmap view tracker table
2+
3+
Revision ID: add_view_tracker
4+
Revises: 20241116_add_roadmap_ratings_table
5+
Create Date: 2024-11-16 14:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "add_view_tracker"
16+
down_revision: Union[str, None] = "20241116_add_roadmap_ratings"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Create roadmap_view_tracker table for anti-spam view counting."""
23+
op.create_table(
24+
"roadmap_view_tracker",
25+
sa.Column("id", sa.Integer(), nullable=False),
26+
sa.Column("repo_full_name", sa.String(length=255), nullable=False),
27+
sa.Column("user_id", sa.String(length=255), nullable=False),
28+
sa.Column(
29+
"viewed_at",
30+
sa.DateTime(timezone=True),
31+
server_default=sa.text("now()"),
32+
nullable=False,
33+
),
34+
sa.PrimaryKeyConstraint("id"),
35+
sa.UniqueConstraint(
36+
"repo_full_name", "user_id", name="uq_roadmap_view_tracker_repo_user"
37+
),
38+
)
39+
op.create_index(
40+
"idx_roadmap_view_tracker_viewed_at",
41+
"roadmap_view_tracker",
42+
["viewed_at"],
43+
)
44+
45+
46+
def downgrade() -> None:
47+
"""Drop roadmap_view_tracker table."""
48+
op.drop_index(
49+
"idx_roadmap_view_tracker_viewed_at", table_name="roadmap_view_tracker"
50+
)
51+
op.drop_table("roadmap_view_tracker")

commitly-backend/app/api/roadmap.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __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
46
from 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
79
from app.core.database import get_db
810
from app.models.roadmap import (
911
RatingRequest,
@@ -13,6 +15,7 @@
1315
RoadmapResponse,
1416
UserRepoStateResponse,
1517
)
18+
from app.services.roadmap_repository import SortOption
1619
from app.services.roadmap_service import RoadmapService, build_roadmap_service
1720

1821
router = 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)
2640
async 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])
126155
async 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)

commitly-backend/app/core/auth.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,29 @@ def require_clerk_auth(request: Request) -> ClerkClaims:
211211
return claims
212212

213213

214+
def optional_clerk_auth(request: Request) -> Optional[ClerkClaims]:
215+
"""
216+
Optional Clerk authentication dependency.
217+
218+
Returns ClerkClaims if a valid token is present, otherwise None.
219+
Does not raise exceptions for missing or invalid tokens.
220+
221+
This is useful for endpoints that should work for both authenticated
222+
and unauthenticated users.
223+
"""
224+
cached: Optional[ClerkClaims] = getattr(request.state, "clerk_claims", None)
225+
if cached:
226+
return cached
227+
228+
try:
229+
token = _get_bearer_token(request)
230+
claims = verify_clerk_token(token)
231+
request.state.clerk_claims = claims
232+
return claims
233+
except (MissingBearerToken, InvalidClerkToken):
234+
return None
235+
236+
214237
class ClerkAuthMiddleware(BaseHTTPMiddleware):
215238
"""Pre-decodes Clerk tokens so downstream dependencies can reuse them."""
216239

commitly-backend/app/models/roadmap.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ class RoadmapRating(Base):
130130
)
131131

132132

133+
class RoadmapViewTracker(Base):
134+
"""Tracks views to prevent spam and implement anti-spam logic."""
135+
136+
__tablename__ = "roadmap_view_tracker"
137+
__table_args__ = (
138+
UniqueConstraint(
139+
"repo_full_name", "user_id", name="uq_roadmap_view_tracker_repo_user"
140+
),
141+
)
142+
143+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
144+
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
145+
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
146+
viewed_at: Mapped[datetime] = mapped_column(
147+
DateTime(timezone=True), server_default=func.now(), nullable=False
148+
)
149+
150+
133151
class RoadmapRequest(BaseModel):
134152
repo_url: AnyHttpUrl = Field(description="GitHub repository URL")
135153
force_refresh: bool = Field(

0 commit comments

Comments
 (0)