import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.domain import Domain from api.models.suggestion import ( OPEN_SUGGESTION_STAGES, Suggestion, SuggestionNote, SuggestionStage, ) from api.schemas.suggestion import ( SuggestionBumpRelevance, SuggestionCreate, SuggestionDecline, SuggestionNoteRead, SuggestionPromote, SuggestionRead, SuggestionVet, ) from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key router = APIRouter(prefix="/suggestions", tags=["suggestions"]) def _retired() -> HTTPException: """CUST-WP-0061-T06 (2026-07-21): suggestions are read-only legacy. Reads (GET) stay live for the historical record; every mutation is retired. Use the intake work-record entity instead (canon/standards/work-record-types_v0.1.md).""" return HTTPException( status_code=410, detail=( "suggestions are retired (read-only legacy, CUST-WP-0061-T06). " "Use POST /intakes/ instead — see canon/standards/" "work-record-types_v0.1.md and the-custodian/" "intake-legacy-suggestions-migration.md." ), ) async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: row = await session.execute( select(Domain.id).where(Domain.slug == slug, Domain.status == "active") ) domain_id = row.scalar_one_or_none() if domain_id is None: valid = [r[0] for r in (await session.execute( select(Domain.slug).where(Domain.status == "active") )).all()] raise HTTPException( status_code=422, detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", ) return domain_id def _enrich_read(suggestion: Suggestion) -> SuggestionRead: data = SuggestionRead.model_validate(suggestion) data.cost_of_delay = cost_of_delay(suggestion) data.wsjf = compute_wsjf(suggestion) return data async def _get_suggestion_or_404( suggestion_id: uuid.UUID, session: AsyncSession, ) -> Suggestion: suggestion = await session.get(Suggestion, suggestion_id) if suggestion is None: raise HTTPException(status_code=404, detail="Suggestion not found") return suggestion @router.get("/", response_model=list[SuggestionRead]) async def list_suggestions( domain: str | None = None, stage: SuggestionStage | None = None, include_terminal: bool = Query(False), rank: str | None = Query(None), limit: int = Query(100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> list[SuggestionRead]: q = select(Suggestion) if domain: domain_id = await _resolve_domain_id(domain, session) q = q.where(Suggestion.domain_id == domain_id) if stage: q = q.where(Suggestion.stage == stage) elif not include_terminal: q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) result = await session.execute(q) suggestions = list(result.scalars().all()) if rank == "wsjf": suggestions.sort(key=suggestion_sort_key) else: suggestions.sort(key=lambda s: s.created_at) return [_enrich_read(s) for s in suggestions[:limit]] @router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED) async def create_suggestion( body: SuggestionCreate, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only legacy. Use `POST /intakes/` (kind: intake per canon/standards/work-record-types_v0.1.md) instead — a fresh entity, not a rename of this table. The 10 records that were still open at retirement were migrated to file-backed intake records; see the-custodian/intake-legacy-suggestions-migration.md.""" raise _retired() @router.get("/{suggestion_id}", response_model=SuggestionRead) async def get_suggestion( suggestion_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) return _enrich_read(suggestion) @router.post("/{suggestion_id}/vet", response_model=SuggestionRead) async def vet_suggestion( suggestion_id: uuid.UUID, body: SuggestionVet, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: """Retired (CUST-WP-0061-T06). See _retired().""" raise _retired() @router.post("/{suggestion_id}/decline", response_model=SuggestionRead) async def decline_suggestion( suggestion_id: uuid.UUID, body: SuggestionDecline, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: """Retired (CUST-WP-0061-T06). See _retired().""" raise _retired() @router.post("/{suggestion_id}/promote", response_model=SuggestionRead) async def promote_suggestion_to_task( suggestion_id: uuid.UUID, body: SuggestionPromote, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: """Retired (CUST-WP-0061-T06). See _retired().""" raise _retired() @router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead) async def bump_suggestion_relevance( suggestion_id: uuid.UUID, body: SuggestionBumpRelevance, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: """Retired (CUST-WP-0061-T06). See _retired().""" raise _retired() @router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead]) async def list_suggestion_notes( suggestion_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> list[SuggestionNote]: await _get_suggestion_or_404(suggestion_id, session) result = await session.execute( select(SuggestionNote) .where(SuggestionNote.suggestion_id == suggestion_id) .order_by(SuggestionNote.created_at) ) return list(result.scalars().all())