Files
resolutionflow/backend/app/api/endpoints/steps.py
chihlasm e0089a9c5a feat: update all endpoints and schemas for account-based model
Replace team_id with account_id across all API endpoints (trees,
categories, tags, steps, step_categories, admin, auth). Add new
accounts and webhooks endpoints. Registration now atomically creates
Account + Subscription, with account_invite_code bypassing the
platform invite gate.

Schemas updated for account_id/account_role. 82 tests passing
including 18 new tests for accounts, subscriptions, and permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 02:39:01 -05:00

632 lines
21 KiB
Python

from uuid import UUID
from typing import Optional
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_, and_, func, desc, Integer, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_view_step, can_edit_step
from app.models.user import User
from app.models.step_library import StepLibrary, StepRating
from app.models.step_category import StepCategory
from app.schemas.step_library import (
StepLibraryCreate,
StepLibraryUpdate,
StepLibraryResponse,
StepLibraryListResponse,
StepRatingCreate,
StepRatingUpdate,
StepRatingResponse,
PopularTagResponse,
)
router = APIRouter(prefix="/steps", tags=["steps"])
async def get_step_or_404(
step_id: UUID,
db: AsyncSession,
current_user: User,
check_view: bool = True,
check_edit: bool = False
) -> StepLibrary:
"""Get step by ID with permission checks."""
result = await db.execute(
select(StepLibrary).where(
StepLibrary.id == step_id,
StepLibrary.is_active == True
)
)
step = result.scalar_one_or_none()
if not step:
raise HTTPException(status_code=404, detail="Step not found")
if check_view and not can_view_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to view this step")
if check_edit and not can_edit_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
return step
def build_visibility_filter(user: User):
"""Build SQLAlchemy filter for step visibility based on user."""
if user.account_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == user.account_id),
StepLibrary.created_by == user.id # Own private steps
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == user.id
)
@router.get("", response_model=list[StepLibraryListResponse])
async def list_steps(
visibility: Optional[str] = Query(None, regex="^(private|team|public)$"),
category_id: Optional[UUID] = None,
tags: Optional[list[str]] = Query(None),
min_rating: Optional[float] = Query(None, ge=0, le=5),
step_type: Optional[str] = Query(None, regex="^(decision|action|solution)$"),
sort_by: str = Query("recent", regex="^(recent|popular|highest_rated|most_used)$"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""List steps with filters and pagination."""
query = select(StepLibrary).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user)
)
# Apply filters
if visibility:
query = query.where(StepLibrary.visibility == visibility)
if category_id:
query = query.where(StepLibrary.category_id == category_id)
if tags:
# Match any of the provided tags
query = query.where(StepLibrary.tags.overlap(tags))
if min_rating is not None:
query = query.where(StepLibrary.rating_average >= Decimal(str(min_rating)))
if step_type:
query = query.where(StepLibrary.step_type == step_type)
# Apply sorting
if sort_by == "recent":
query = query.order_by(desc(StepLibrary.created_at))
elif sort_by == "popular" or sort_by == "most_used":
query = query.order_by(desc(StepLibrary.usage_count))
elif sort_by == "highest_rated":
query = query.order_by(desc(StepLibrary.rating_average), desc(StepLibrary.rating_count))
# Apply pagination
query = query.offset(offset).limit(limit)
result = await db.execute(query)
steps = result.scalars().all()
# Fetch category names and author names
response = []
for step in steps:
step_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"visibility": step.visibility,
"category_id": step.category_id,
"tags": step.tags,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
}
# Get category name if exists
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
cat_name = cat_result.scalar_one_or_none()
step_dict["category_name"] = cat_name
# Get author name
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
author_name = author_result.scalar_one_or_none()
step_dict["author_name"] = author_name
response.append(StepLibraryListResponse(**step_dict))
return response
@router.get("/search", response_model=list[StepLibraryListResponse])
async def search_steps(
q: str = Query(..., min_length=1),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Full-text search for steps."""
# Use PostgreSQL full-text search
search_query = func.to_tsquery('english', q.replace(' ', ' & '))
query = select(StepLibrary).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user),
func.to_tsvector('english', StepLibrary.title).match(search_query)
).order_by(desc(StepLibrary.rating_average)).limit(limit)
result = await db.execute(query)
steps = result.scalars().all()
response = []
for step in steps:
step_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"visibility": step.visibility,
"category_id": step.category_id,
"tags": step.tags,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
step_dict["category_name"] = cat_result.scalar_one_or_none()
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
step_dict["author_name"] = author_result.scalar_one_or_none()
response.append(StepLibraryListResponse(**step_dict))
return response
@router.get("/tags/popular", response_model=list[PopularTagResponse])
async def get_popular_tags(
limit: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get popular tags with usage counts."""
# Use unnest to expand arrays and count occurrences
query = select(
func.unnest(StepLibrary.tags).label('tag'),
func.count().label('count')
).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user)
).group_by(
func.unnest(StepLibrary.tags)
).order_by(
desc(func.count())
).limit(limit)
result = await db.execute(query)
tags = result.all()
return [PopularTagResponse(tag=row.tag, count=row.count) for row in tags]
@router.get("/{step_id}", response_model=StepLibraryResponse)
async def get_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get a step by ID."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"account_id": step.account_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
}
# Get category name if exists
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
# Get author name
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
response_dict["author_name"] = author_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.post("", response_model=StepLibraryResponse, status_code=201)
async def create_step(
step_data: StepLibraryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_engineer_or_admin)
):
"""Create a new step (engineers and admins only, not viewers)."""
# Validate category if provided
if step_data.category_id:
cat_result = await db.execute(
select(StepCategory).where(
StepCategory.id == step_data.category_id,
StepCategory.is_active == True
)
)
if not cat_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Invalid category")
# Account validation: can only set account_id to own account
account_id = step_data.account_id
if account_id and account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Cannot create step for another account")
step = StepLibrary(
title=step_data.title,
step_type=step_data.step_type,
content=step_data.content.model_dump(),
category_id=step_data.category_id,
tags=step_data.tags,
visibility=step_data.visibility,
created_by=current_user.id,
account_id=account_id or current_user.account_id,
)
db.add(step)
await db.commit()
await db.refresh(step)
# Build response
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"account_id": step.account_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
"author_name": current_user.name,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.put("/{step_id}", response_model=StepLibraryResponse)
async def update_step(
step_id: UUID,
step_data: StepLibraryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Update a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
# Validate category if being updated
if step_data.category_id:
cat_result = await db.execute(
select(StepCategory).where(
StepCategory.id == step_data.category_id,
StepCategory.is_active == True
)
)
if not cat_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Invalid category")
# Apply updates
update_data = step_data.model_dump(exclude_unset=True)
if 'content' in update_data and update_data['content']:
update_data['content'] = update_data['content'].model_dump() if hasattr(update_data['content'], 'model_dump') else update_data['content']
for field, value in update_data.items():
setattr(step, field, value)
step.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(step)
# Build response
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"account_id": step.account_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
response_dict["author_name"] = author_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.delete("/{step_id}", status_code=204)
async def delete_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Soft delete a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
step.is_active = False
step.updated_at = datetime.now(timezone.utc)
await db.commit()
return None
# Rating endpoints
@router.post("/{step_id}/rate", response_model=StepRatingResponse, status_code=201)
async def rate_step(
step_id: UUID,
rating_data: StepRatingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Rate a step (1-5 stars with optional review)."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
# Check if user already rated
existing = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You have already rated this step. Use PUT to update.")
rating = StepRating(
step_id=step_id,
user_id=current_user.id,
rating=rating_data.rating,
was_helpful=rating_data.was_helpful,
review_text=rating_data.review_text,
session_id=rating_data.session_id,
is_verified_use=rating_data.session_id is not None,
)
db.add(rating)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
await db.refresh(rating)
return StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=current_user.name,
)
@router.put("/{step_id}/rate", response_model=StepRatingResponse)
async def update_rating(
step_id: UUID,
rating_data: StepRatingUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Update your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
rating = result.scalar_one_or_none()
if not rating:
raise HTTPException(status_code=404, detail="Rating not found. Use POST to create.")
update_data = rating_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(rating, field, value)
rating.updated_at = datetime.now(timezone.utc)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
await db.refresh(rating)
return StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=current_user.name,
)
@router.delete("/{step_id}/rate", status_code=204)
async def delete_rating(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Delete your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
rating = result.scalar_one_or_none()
if not rating:
raise HTTPException(status_code=404, detail="Rating not found")
await db.delete(rating)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
return None
@router.get("/{step_id}/reviews", response_model=list[StepRatingResponse])
async def get_reviews(
step_id: UUID,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get reviews for a step."""
await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.is_visible == True,
StepRating.review_text.isnot(None)
).order_by(desc(StepRating.created_at)).offset(offset).limit(limit)
)
ratings = result.scalars().all()
response = []
for rating in ratings:
user_result = await db.execute(
select(User.name).where(User.id == rating.user_id)
)
user_name = user_result.scalar_one_or_none()
response.append(StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=user_name,
))
return response
async def _update_step_ratings(db: AsyncSession, step: StepLibrary):
"""Update aggregated rating fields on a step."""
# Calculate new aggregates
result = await db.execute(
select(
func.count(StepRating.id),
func.avg(StepRating.rating),
func.sum(case((StepRating.was_helpful == True, 1), else_=0)),
func.sum(case((StepRating.was_helpful == False, 1), else_=0))
).where(StepRating.step_id == step.id)
)
row = result.one()
step.rating_count = row[0] or 0
step.rating_average = Decimal(str(round(row[1] or 0, 2)))
step.helpful_yes = row[2] or 0
step.helpful_no = row[3] or 0