state-hub/api/routers/intake.py
tegwick 88ba666c95
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m23s
CUST-WP-0061-T01: intake work-record entity (stage 3)
Fresh hub entity per the founder-reviewed decision (not a suggestions
rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md,
lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed).

- api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7
  generator (48-bit ms timestamp, version/variant bits, random remainder);
  existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new
  work-record entities per the identity-layering canon
- api/models/intake.py: Intake + IntakeNote ORM models, mirroring
  Decision's shape (topic/workplan/repo scope, lane, status, outcome,
  promoted_to back-link); CHECK constraints enforce scope-required,
  closed-requires-outcome, promoted-requires-promoted_to at the DB level
- migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types
- api/routers/intake.py: list/create/get/patch + /route + /close + /notes
  actions, mirroring decisions.py's pattern (409 on invalid transitions,
  progress event on close)
- api/schemas/intake.py: Pydantic create/update/route/close/note schemas
- mcp_server/server.py: create_intake, list_intakes, route_intake,
  close_intake tool wrappers
- tests/test_intake.py: 12 tests against the real Postgres test DB
  (create/list/scope-validation, full lifecycle incl. 409s and the
  promoted-requires-promoted_to constraint, notes, UUIDv7 verification)

Verified live against the running dev API + DB (not just pytest): applied
the migration, restarted the MCP server, and ran a full create -> route ->
close cycle over the real REST endpoints. No regressions: full existing
suite (test_routers_core, test_suggestions, test_mcp_smoke,
test_mcp_write_tools, test_mcp_registration, test_consistency_check,
test_consistency_sweep) all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00

173 lines
5.6 KiB
Python

import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
from api.models.progress_event import ProgressEvent
from api.schemas.intake import (
IntakeClose,
IntakeCreate,
IntakeNoteCreate,
IntakeRead,
IntakeRoute,
IntakeUpdate,
)
router = APIRouter(prefix="/intakes", tags=["intakes"])
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
if intake.status not in allowed:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Cannot {action} intake in status '{intake.status.value}'; "
f"allowed from: {sorted(s.value for s in allowed)}"
),
)
@router.get("/", response_model=list[IntakeRead])
async def list_intakes(
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
status_: IntakeStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Intake]:
q = select(Intake)
if topic_id:
q = q.where(Intake.topic_id == topic_id)
if workplan_id:
q = q.where(Intake.workplan_id == workplan_id)
if repo_id:
q = q.where(Intake.repo_id == repo_id)
if status_:
q = q.where(Intake.status == status_)
q = q.order_by(Intake.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def create_intake(
body: IntakeCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = Intake(**body.model_dump())
session.add(intake)
await session.commit()
await session.refresh(intake)
return intake
@router.get("/{intake_id}", response_model=IntakeRead)
async def get_intake(
intake_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
return intake
@router.patch("/{intake_id}", response_model=IntakeRead)
async def update_intake(
intake_id: uuid.UUID,
body: IntakeUpdate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(intake, field, value)
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/route", response_model=IntakeRead)
async def route_intake(
intake_id: uuid.UUID,
body: IntakeRoute,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Move an intake into `routed` — eligible for the promotion transition."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
intake.status = IntakeStatus.routed
if body.routed_note:
intake.routed_note = body.routed_note
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/close", response_model=IntakeRead)
async def close_intake(
intake_id: uuid.UUID,
body: IntakeClose,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Close an intake with an outcome. `outcome=promoted` requires
`promoted_to` (the canonical id of the record it became) — this is
normally called by the promotion transition (CUST-WP-0061-T03), not by
hand, but a manual close (declined/absorbed, or a promotion recorded
after the fact) is supported directly."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
intake.status = IntakeStatus.closed
intake.outcome = body.outcome
intake.closed_at = datetime.now(tz=timezone.utc)
if body.promoted_to:
intake.promoted_to = body.promoted_to
await session.commit()
await session.refresh(intake)
event = ProgressEvent(
topic_id=intake.topic_id,
workplan_id=intake.workplan_id,
event_type="intake_closed",
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
detail={
"intake_id": str(intake.id),
"outcome": body.outcome.value,
"promoted_to": body.promoted_to,
"note": body.note,
},
)
session.add(event)
await session.commit()
return intake
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def add_intake_note(
intake_id: uuid.UUID,
body: IntakeNoteCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
session.add(note)
await session.commit()
await session.refresh(intake)
return intake