chrysopedia/backend/routers/follows.py
jlightner 243a7a3eb6 feat: Follow system + tier config page (M022/S02)
- CreatorFollow model + pure SQL migration 022
- Follow router: POST/DELETE /follows/{creator_id}, GET /follows/me, GET /follows/{id}/status
- follower_count on creator detail endpoint
- Follow button on CreatorDetail (authenticated users only)
- CreatorTiers page with Free/Pro/Premium cards, Coming Soon modals
- Tiers link in creator sidebar nav
- Route /creator/tiers (protected)
2026-04-04 07:34:03 +00:00

116 lines
4.1 KiB
Python

"""Follow system — users follow creators.
Endpoints:
POST /follows/{creator_id} — follow a creator
DELETE /follows/{creator_id} — unfollow a creator
GET /follows/{creator_id}/status — check if following
GET /follows/me — list followed creators
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import Creator, CreatorFollow, User
from schemas import FollowResponse, FollowStatusResponse, FollowedCreatorItem
router = APIRouter(prefix="/follows", tags=["follows"])
async def _follower_count(session: AsyncSession, creator_id: uuid.UUID) -> int:
result = await session.execute(
select(func.count()).select_from(CreatorFollow).where(
CreatorFollow.creator_id == creator_id
)
)
return result.scalar_one()
@router.post("/{creator_id}", response_model=FollowResponse)
async def follow_creator(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Follow a creator. Idempotent — following twice is a no-op."""
# Verify creator exists
result = await session.execute(select(Creator).where(Creator.id == creator_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Creator not found")
# Upsert — idempotent
stmt = pg_insert(CreatorFollow).values(
user_id=current_user.id,
creator_id=creator_id,
).on_conflict_do_nothing(constraint="uq_creator_follow_user_creator")
await session.execute(stmt)
await session.commit()
count = await _follower_count(session, creator_id)
return FollowResponse(followed=True, creator_id=creator_id, follower_count=count)
@router.delete("/{creator_id}", response_model=FollowResponse)
async def unfollow_creator(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Unfollow a creator. Idempotent — unfollowing when not following is a no-op."""
await session.execute(
delete(CreatorFollow).where(
CreatorFollow.user_id == current_user.id,
CreatorFollow.creator_id == creator_id,
)
)
await session.commit()
count = await _follower_count(session, creator_id)
return FollowResponse(followed=False, creator_id=creator_id, follower_count=count)
@router.get("/me", response_model=list[FollowedCreatorItem])
async def my_follows(
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""List all creators the current user follows."""
result = await session.execute(
select(
CreatorFollow.creator_id,
Creator.name.label("creator_name"),
Creator.slug.label("creator_slug"),
CreatorFollow.created_at.label("followed_at"),
)
.join(Creator, CreatorFollow.creator_id == Creator.id)
.where(CreatorFollow.user_id == current_user.id)
.order_by(CreatorFollow.created_at.desc())
)
return [FollowedCreatorItem(**row._mapping) for row in result.all()]
@router.get("/{creator_id}/status", response_model=FollowStatusResponse)
async def follow_status(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Check if the current user follows a specific creator."""
result = await session.execute(
select(CreatorFollow.id).where(
CreatorFollow.user_id == current_user.id,
CreatorFollow.creator_id == creator_id,
)
)
return FollowStatusResponse(
following=result.scalar_one_or_none() is not None,
creator_id=creator_id,
)