"""Phase 4 integration routes: the boundary the ImpactFlow core time-tracker plugs into. When a user logs time in the core tracker, the tracker asks "which goal does this build toward?" — fetching the options from ``GET /foundations`` — and posts the answer to ``POST /task-mappings``. ``GET /work-patterns`` rolls those up to feed the coaching reminder engine and the goal dashboard. All routes are user-scoped via the dual-auth dependency; the tracker calls as the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``. """ import uuid from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.auth import get_current_user from app.database import get_db from app.models import DiscoveryProfile, TaskMapping, User from app.services.foundations import ( FOUNDATION_TO_FIELD, FOUNDATIONS, rollup, ) router = APIRouter(prefix="/discovery/integration", tags=["integration"]) def _now() -> datetime: return datetime.now(timezone.utc) async def _latest_profile( db: AsyncSession, user_id: str ) -> DiscoveryProfile | None: stmt = ( select(DiscoveryProfile) .where(DiscoveryProfile.user_id == user_id) .order_by(DiscoveryProfile.generated_at.desc()) ) return (await db.execute(stmt)).scalars().first() @router.get("/foundations", response_model=list[schemas.FoundationOut]) async def list_foundations( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """The mappable foundations for the user's latest profile, with the person's own text — what the tracker shows as "which goal does this build toward?".""" profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") return [ schemas.FoundationOut( key=key, label=label, text=getattr(profile, FOUNDATION_TO_FIELD[key], None), ) for key, label in FOUNDATIONS.items() ] @router.post("/task-mappings", response_model=schemas.TaskMappingOut) async def create_task_mapping( payload: schemas.TaskMappingCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Record one logged unit of work, mapped to the foundation it builds toward. Called by the core tracker when time is logged.""" if payload.foundation not in FOUNDATIONS: raise HTTPException( status_code=400, detail=f"Invalid foundation: {payload.foundation!r}. " f"Allowed: {sorted(FOUNDATIONS)}", ) if payload.minutes < 0: raise HTTPException(status_code=400, detail="minutes must be >= 0") mapping = TaskMapping( id=str(uuid.uuid4()), user_id=user.id, external_task_id=payload.external_task_id, task_label=payload.task_label or None, foundation=payload.foundation, minutes=payload.minutes, occurred_at=payload.occurred_at or _now(), created_at=_now(), ) db.add(mapping) await db.commit() await db.refresh(mapping) return schemas.TaskMappingOut.model_validate(mapping, from_attributes=True) @router.get("/task-mappings", response_model=list[schemas.TaskMappingOut]) async def list_task_mappings( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): cutoff = _now() - timedelta(days=days) stmt = ( select(TaskMapping) .where(TaskMapping.user_id == user.id) .where(TaskMapping.occurred_at >= cutoff) .order_by(TaskMapping.occurred_at.desc()) ) rows = (await db.execute(stmt)).scalars().all() return [ schemas.TaskMappingOut.model_validate(r, from_attributes=True) for r in rows ] async def work_patterns_for( db: AsyncSession, user_id: str, days: int ) -> dict: """Roll up the user's task mappings over the window. Shared with the coaching check-in engine.""" cutoff = _now() - timedelta(days=days) stmt = ( select(TaskMapping) .where(TaskMapping.user_id == user_id) .where(TaskMapping.occurred_at >= cutoff) ) rows = (await db.execute(stmt)).scalars().all() entries = [ { "foundation": r.foundation, "minutes": r.minutes, "occurred_at": r.occurred_at, } for r in rows ] return rollup(entries) @router.get("/work-patterns", response_model=schemas.WorkPatternsOut) async def get_work_patterns( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Per-foundation work patterns over the window (powers the dashboard and the coaching reminder engine).""" summary = await work_patterns_for(db, user.id, days) return schemas.WorkPatternsOut(window_days=days, **summary)