Initial commit: ImpactFlow Discovery + Google OAuth auth layer

Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-05-27 10:59:41 -05:00
commit b8f176bb31
45 changed files with 4679 additions and 0 deletions
View File
+230
View File
@@ -0,0 +1,230 @@
"""Authentication: Google OAuth flow, JWT issuance, dual-auth dependency.
Two ways to authenticate:
- JWT in `Authorization: Bearer <token>` (browser users, issued by /api/auth/callback)
- X-API-Key header matching IMPACTFLOW_API_KEY (machine-to-machine; MCP server)
The API key resolves to a synthetic admin user (`API_KEY_ADMIN_ID`) seeded into
the users table on first use, so foreign keys from data tables stay valid.
"""
import hashlib
import hmac
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RefreshToken, User
API_KEY_ADMIN_ID = "api-key-admin"
JWT_ALGORITHM = "HS256"
oauth = OAuth()
oauth.register(
name="google",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
server_metadata_url=(
"https://accounts.google.com/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid email profile"},
)
def _jwt_secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET is not configured")
return secret
def _access_minutes() -> int:
return int(os.getenv("JWT_ACCESS_MINUTES", "15"))
def _refresh_days() -> int:
return int(os.getenv("JWT_REFRESH_DAYS", "7"))
def create_access_token(user_id: str, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"email": email,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=_access_minutes())).timestamp()),
"type": "access",
}
return jwt.encode(payload, _jwt_secret(), algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, _jwt_secret(), algorithms=[JWT_ALGORITHM])
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
) from exc
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Wrong token type",
)
return payload
def hash_refresh_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def issue_refresh_token(
db: AsyncSession, user: User, device: Optional[str]
) -> str:
raw = secrets.token_urlsafe(48)
now = datetime.now(timezone.utc)
row = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_refresh_token(raw),
device=(device or "")[:255] or None,
created_at=now,
expires_at=now + timedelta(days=_refresh_days()),
)
db.add(row)
await db.commit()
return raw
async def ensure_api_key_admin(db: AsyncSession) -> User:
"""Idempotently return the synthetic admin user backing the API key."""
existing = await db.get(User, API_KEY_ADMIN_ID)
if existing is not None:
return existing
now = datetime.now(timezone.utc)
admin = User(
id=API_KEY_ADMIN_ID,
email="api-key@impactflow.local",
display_name="API Key (MCP)",
role="admin",
created_at=now,
)
db.add(admin)
await db.commit()
await db.refresh(admin)
return admin
_bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
_bearer_scheme
),
db: AsyncSession = Depends(get_db),
) -> User:
"""Dual-auth dependency. Tries X-API-Key first (cheap, no JWT decode),
then falls back to the Authorization bearer JWT."""
expected_api_key = os.getenv("IMPACTFLOW_API_KEY")
presented_api_key = request.headers.get("x-api-key")
if (
expected_api_key
and presented_api_key
and hmac.compare_digest(presented_api_key, expected_api_key)
):
admin = await ensure_api_key_admin(db)
request.state.user = admin
request.state.auth_source = "api_key"
return admin
if credentials is not None and credentials.scheme.lower() == "bearer":
payload = decode_access_token(credentials.credentials)
user = await db.get(User, payload["sub"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
request.state.user = user
request.state.auth_source = "jwt"
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
async def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required",
)
return user
def email_domain_allowed(email: str) -> bool:
raw = os.getenv("ALLOWED_EMAIL_DOMAINS", "").strip()
if not raw:
return True
allowed = {d.strip().lower() for d in raw.split(",") if d.strip()}
domain = email.rsplit("@", 1)[-1].lower()
return domain in allowed
async def find_or_create_google_user(
db: AsyncSession, user_info: dict
) -> User:
"""Idempotent: looks up by google_id, falls back to email, otherwise
creates. First created human user is auto-promoted to admin."""
google_id = user_info["sub"]
email = user_info["email"]
now = datetime.now(timezone.utc)
stmt = select(User).where(User.google_id == google_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is None:
stmt = select(User).where(User.email == email)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is not None and user.google_id is None:
user.google_id = google_id
if user is None:
# Auto-promote the first real human user. The synthetic
# API_KEY_ADMIN_ID record is excluded from the count.
stmt = select(User).where(User.id != API_KEY_ADMIN_ID)
is_first = (await db.execute(stmt)).first() is None
user = User(
id=str(uuid.uuid4()),
email=email,
display_name=user_info.get("name") or email,
avatar_url=user_info.get("picture"),
google_id=google_id,
role="admin" if is_first else "user",
created_at=now,
last_login_at=now,
)
db.add(user)
else:
user.last_login_at = now
if user_info.get("name"):
user.display_name = user_info["name"]
if user_info.get("picture"):
user.avatar_url = user_info["picture"]
await db.commit()
await db.refresh(user)
return user
+53
View File
@@ -0,0 +1,53 @@
"""Async SQLAlchemy engine, session factory, and declarative base.
The SQLite database file lives under ./data so it can be persisted via a
Docker volume mount. The data directory is created on import if missing.
"""
import os
from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
load_dotenv()
DATABASE_URL = os.getenv(
"DATABASE_URL", "sqlite+aiosqlite:///./data/discovery.db"
)
def _ensure_sqlite_dir(url: str) -> None:
"""Make sure the directory holding the SQLite file exists."""
if "sqlite" not in url:
return
# everything after the scheme's :/// is the filesystem path
if ":///" not in url:
return
path = url.split(":///", 1)[1]
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
_ensure_sqlite_dir(DATABASE_URL)
class Base(DeclarativeBase):
"""Declarative base shared by all ORM models."""
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields a scoped async session."""
async with AsyncSessionLocal() as session:
yield session
+77
View File
@@ -0,0 +1,77 @@
"""FastAPI entrypoint for the ImpactFlow self-discovery module."""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import models # noqa: F401 - register ORM models with Base.metadata
from app.auth import ensure_api_key_admin
from app.database import AsyncSessionLocal, Base, engine
from app.routers import activity, auth as auth_router, discovery
from app.routers.activity import prune_old_activity
from app.tracking import ActivityTrackingMiddleware
# Path to the static directory, resolved relative to this file so it works
# regardless of the current working directory.
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Alembic owns schema migrations in Docker; this create_all is an
# idempotent safety net so the app also runs cleanly in local/dev where
# migrations may not have been applied.
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSessionLocal() as db:
await ensure_api_key_admin(db)
await prune_old_activity(db)
yield
app = FastAPI(title="ImpactFlow Self-Discovery", lifespan=lifespan)
# add_middleware wraps the previous app, so the LAST call becomes the
# OUTERMOST middleware. Register inner-to-outer:
# tracking (innermost — sees request.state.user set by deps) ->
# session (needs to wrap routes for the OAuth state cookie) ->
# CORS (outermost — preflights must short-circuit before anything else).
app.add_middleware(ActivityTrackingMiddleware)
_session_secret = os.getenv("JWT_SECRET") or "dev-session-secret-change-me"
app.add_middleware(SessionMiddleware, secret_key=_session_secret)
_cors_origins = [
o.strip()
for o in os.getenv(
"CORS_ALLOWED_ORIGINS", "http://localhost:8000"
).split(",")
if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(activity.router)
app.include_router(discovery.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/")
async def root():
return RedirectResponse(url="/static/discovery.html")
+70
View File
@@ -0,0 +1,70 @@
"""Helpers for bringing older local SQLite databases under Alembic control."""
import os
import sqlite3
from dotenv import load_dotenv
DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///./data/discovery.db"
CURRENT_REVISION = "001"
REQUIRED_TABLES = {"discovery_conversation", "discovery_profile"}
def _sqlite_path(database_url: str) -> str | None:
if not database_url.startswith("sqlite") or ":///" not in database_url:
return None
return database_url.split(":///", 1)[1]
def stamp_existing_sqlite_schema(
database_url: str, revision: str = CURRENT_REVISION
) -> bool:
"""Stamp a pre-Alembic SQLite DB when it already has the app tables.
Early local/dev runs could create tables through SQLAlchemy create_all()
before Alembic was applied. In that case, `alembic upgrade head` tries to
create tables that already exist. This marks that compatible schema as
revision 001 so normal migrations can continue.
"""
path = _sqlite_path(database_url)
if not path or not os.path.exists(path):
return False
with sqlite3.connect(path) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
tables = {row[0] for row in rows}
if not REQUIRED_TABLES.issubset(tables):
return False
if "alembic_version" in tables:
versions = conn.execute(
"SELECT version_num FROM alembic_version"
).fetchall()
if versions:
return False
else:
conn.execute(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
conn.execute(
"INSERT INTO alembic_version (version_num) VALUES (?)",
(revision,),
)
conn.commit()
return True
def main() -> None:
load_dotenv()
database_url = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
stamped = stamp_existing_sqlite_schema(database_url)
if stamped:
print(f"Stamped existing SQLite schema as Alembic revision {CURRENT_REVISION}")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""SQLAlchemy ORM models for the self-discovery module."""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
"""A signed-in human (Google OAuth) or the synthetic admin record used
by the X-API-Key dual-auth path for the MCP server."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String, nullable=False)
avatar_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Google's "sub" claim. Null only for the synthetic API-key admin user.
google_id: Mapped[Optional[str]] = mapped_column(
String, unique=True, nullable=True
)
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_login_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
class RefreshToken(Base):
"""One row per issued refresh token. token_hash stores a SHA-256 of the
raw token so a DB leak can't be replayed back at the auth endpoint."""
__tablename__ = "refresh_tokens"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(
String, unique=True, nullable=False
)
device: Mapped[Optional[str]] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
class ActivityLog(Base):
"""Append-only audit trail. Pruned to 90 days on app startup."""
__tablename__ = "activity_log"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
action: Mapped[str] = mapped_column(String, nullable=False)
resource: Mapped[str] = mapped_column(String, nullable=False)
resource_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String, nullable=False, default="web")
ip_address: Mapped[Optional[str]] = mapped_column(String, nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, index=True
)
class DiscoveryConversation(Base):
"""A single self-discovery conversation: the five narrative responses."""
__tablename__ = "discovery_conversation"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
prompt_alive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prompt_friction: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prompt_pull: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prompt_recognition: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
prompt_future: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class DiscoveryProfile(Base):
"""The extracted enneagram + Ikigai profile for a conversation."""
__tablename__ = "discovery_profile"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
conversation_id: Mapped[str] = mapped_column(
String, ForeignKey("discovery_conversation.id"), nullable=False
)
generated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
triad: Mapped[Optional[str]] = mapped_column(String, nullable=True)
probable_type: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
wing: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
instinctual_variant: Mapped[Optional[str]] = mapped_column(
String, nullable=True
)
instinctual_stack: Mapped[Optional[str]] = mapped_column(
String, nullable=True
)
love_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
strength_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
mission_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
vocation_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
overlap_narrative: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
confidence_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
locked: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
View File
+135
View File
@@ -0,0 +1,135 @@
"""Activity feed + admin endpoints."""
from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user, require_admin
from app.database import get_db
from app.models import ActivityLog, User
router = APIRouter(prefix="/api", tags=["activity"])
class ActivityOut(BaseModel):
id: str
user_id: str
action: str
resource: str
resource_id: str | None
source: str
created_at: datetime
ip_address: str | None
user_agent: str | None
class ActivitySummary(BaseModel):
days: int
total: int
actions_per_day: dict[str, int]
top_resources: list[tuple[str, int]]
web_count: int
mcp_count: int
def _to_out(row: ActivityLog) -> ActivityOut:
return ActivityOut(
id=row.id,
user_id=row.user_id,
action=row.action,
resource=row.resource,
resource_id=row.resource_id,
source=row.source,
created_at=row.created_at,
ip_address=row.ip_address,
user_agent=row.user_agent,
)
@router.get("/activity", response_model=list[ActivityOut])
async def list_my_activity(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=200),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.order_by(ActivityLog.created_at.desc())
.offset((page - 1) * limit)
.limit(limit)
)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
@router.get("/activity/summary", response_model=ActivitySummary)
async def my_activity_summary(
days: int = Query(30, ge=1, le=365),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
since = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.where(ActivityLog.created_at >= since)
)
rows = (await db.execute(stmt)).scalars().all()
per_day: Counter[str] = Counter()
resource_counts: Counter[str] = Counter()
web = mcp = 0
for r in rows:
per_day[r.created_at.date().isoformat()] += 1
resource_counts[r.resource] += 1
if r.source == "mcp":
mcp += 1
else:
web += 1
return ActivitySummary(
days=days,
total=len(rows),
actions_per_day=dict(per_day),
top_resources=resource_counts.most_common(5),
web_count=web,
mcp_count=mcp,
)
@router.get("/admin/activity", response_model=list[ActivityOut])
async def admin_list_activity(
user_id: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=500),
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(ActivityLog).order_by(ActivityLog.created_at.desc())
if user_id:
stmt = stmt.where(ActivityLog.user_id == user_id)
stmt = stmt.offset((page - 1) * limit).limit(limit)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
async def prune_old_activity(db: AsyncSession, days: int = 90) -> int:
"""Delete rows older than N days. Returns rows deleted."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
stmt = select(func.count()).select_from(ActivityLog).where(
ActivityLog.created_at < cutoff
)
count = (await db.execute(stmt)).scalar_one()
if count:
from sqlalchemy import delete
await db.execute(delete(ActivityLog).where(
ActivityLog.created_at < cutoff
))
await db.commit()
return int(count or 0)
+288
View File
@@ -0,0 +1,288 @@
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
import os
from datetime import datetime, timedelta, timezone
from authlib.integrations.base_client import OAuthError
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
create_access_token,
email_domain_allowed,
find_or_create_google_user,
get_current_user,
hash_refresh_token,
issue_refresh_token,
oauth,
)
from app.database import get_db
from app.models import (
ActivityLog,
DiscoveryConversation,
DiscoveryProfile,
RefreshToken,
User,
)
from sqlalchemy import func
router = APIRouter(prefix="/api", tags=["auth"])
# -- response shapes ----------------------------------------------------------
class UserOut(BaseModel):
id: str
email: str
display_name: str
avatar_url: str | None = None
role: str
@classmethod
def from_orm_user(cls, user: User) -> "UserOut":
return cls(
id=user.id,
email=user.email,
display_name=user.display_name,
avatar_url=user.avatar_url,
role=user.role,
)
class TokenBundle(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshIn(BaseModel):
refresh_token: str
class AccessOut(BaseModel):
access_token: str
token_type: str = "bearer"
class DisplayNamePatch(BaseModel):
display_name: str
class SessionOut(BaseModel):
id: str
device: str | None
created_at: datetime
expires_at: datetime
# -- OAuth flow ---------------------------------------------------------------
@router.get("/auth/login")
async def login(request: Request):
"""Kick off the OAuth dance: 302 to Google's consent screen."""
redirect_uri = os.getenv("OAUTH_REDIRECT_URI") or str(
request.url_for("auth_callback")
)
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""Google redirects here with `code`; exchange it, mint our tokens."""
try:
token = await oauth.google.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"OAuth error: {exc.error or exc}",
) from exc
user_info = token.get("userinfo")
if not user_info or not user_info.get("email") or not user_info.get("sub"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google did not return email/sub",
)
if user_info.get("email_verified") is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified by Google",
)
if not email_domain_allowed(user_info["email"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email domain not allowed",
)
user = await find_or_create_google_user(db, user_info)
access = create_access_token(user.id, user.email)
refresh = await issue_refresh_token(
db, user, request.headers.get("user-agent")
)
return TokenBundle(
access_token=access,
refresh_token=refresh,
user=UserOut.from_orm_user(user),
)
@router.post("/auth/refresh", response_model=AccessOut)
async def refresh_access_token(
body: RefreshIn, db: AsyncSession = Depends(get_db)
):
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
)
row = (await db.execute(stmt)).scalar_one_or_none()
now = datetime.now(timezone.utc)
if row is None or row.revoked_at is not None or _expired(row.expires_at, now):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token invalid or expired",
)
user = await db.get(User, row.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
access = create_access_token(user.id, user.email)
return AccessOut(access_token=access)
def _expired(expires_at: datetime, now: datetime) -> bool:
# SQLite stores naive datetimes; compare in UTC.
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at < now
@router.post("/auth/logout")
async def logout(
body: RefreshIn, db: AsyncSession = Depends(get_db)
):
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
)
row = (await db.execute(stmt)).scalar_one_or_none()
if row is not None and row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "ok"}
# -- profile / sessions -------------------------------------------------------
@router.get("/auth/me", response_model=UserOut)
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
return UserOut.from_orm_user(user)
@router.patch("/me", response_model=UserOut)
async def patch_me(
body: DisplayNamePatch,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
new_name = body.display_name.strip()
if not new_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="display_name cannot be empty",
)
user.display_name = new_name
await db.commit()
await db.refresh(user)
return UserOut.from_orm_user(user)
class MeStats(BaseModel):
conversations: int
profiles: int
locked_profiles: int
activity_last_30d: int
last_login_at: datetime | None
@router.get("/me/stats", response_model=MeStats)
async def my_stats(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
convo_count = (await db.execute(
select(func.count())
.select_from(DiscoveryConversation)
.where(DiscoveryConversation.user_id == user.id)
)).scalar_one()
profile_count = (await db.execute(
select(func.count())
.select_from(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user.id)
)).scalar_one()
locked_count = (await db.execute(
select(func.count())
.select_from(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user.id)
.where(DiscoveryProfile.locked.is_(True))
)).scalar_one()
since = datetime.now(timezone.utc) - timedelta(days=30)
activity_count = (await db.execute(
select(func.count())
.select_from(ActivityLog)
.where(ActivityLog.user_id == user.id)
.where(ActivityLog.created_at >= since)
)).scalar_one()
return MeStats(
conversations=int(convo_count or 0),
profiles=int(profile_count or 0),
locked_profiles=int(locked_count or 0),
activity_last_30d=int(activity_count or 0),
last_login_at=user.last_login_at,
)
@router.get("/me/sessions", response_model=list[SessionOut])
async def list_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(RefreshToken)
.where(RefreshToken.user_id == user.id)
.where(RefreshToken.revoked_at.is_(None))
.order_by(RefreshToken.created_at.desc())
)
rows = (await db.execute(stmt)).scalars().all()
return [
SessionOut(
id=r.id,
device=r.device,
created_at=r.created_at,
expires_at=r.expires_at,
)
for r in rows
]
@router.delete("/me/sessions/{session_id}")
async def revoke_session(
session_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
row = await db.get(RefreshToken, session_id)
if row is None or row.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
if row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "revoked"}
+230
View File
@@ -0,0 +1,230 @@
"""Discovery API routes: start a conversation, save responses, generate and
confirm a profile.
All routes are user-scoped via the dual-auth dependency. The MCP server uses
the X-API-Key header and operates under the synthetic admin user; the
browser app uses a Google-issued JWT.
"""
import json
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
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 DiscoveryConversation, DiscoveryProfile, User
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
router = APIRouter(prefix="/discovery", tags=["discovery"])
def _now() -> datetime:
return datetime.now(timezone.utc)
def _to_profile_response(
profile: DiscoveryProfile, extraction_notes: str | None = None
) -> schemas.ProfileResponse:
"""Build a ProfileResponse from a stored profile row."""
confidence = None
if profile.confidence_json:
try:
confidence = schemas.Confidence(**json.loads(profile.confidence_json))
except (json.JSONDecodeError, TypeError, ValueError):
confidence = None
return schemas.ProfileResponse(
id=profile.id,
user_id=profile.user_id,
conversation_id=profile.conversation_id,
generated_at=profile.generated_at,
triad=profile.triad,
probable_type=profile.probable_type,
wing=profile.wing,
instinctual_variant=profile.instinctual_variant,
instinctual_stack=profile.instinctual_stack,
love_summary=profile.love_summary,
strength_summary=profile.strength_summary,
mission_summary=profile.mission_summary,
vocation_summary=profile.vocation_summary,
overlap_narrative=profile.overlap_narrative,
confidence=confidence,
locked=profile.locked,
extraction_notes=extraction_notes,
)
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())
)
result = await db.execute(stmt)
return result.scalars().first()
async def _owned_conversation(
db: AsyncSession, conversation_id: str, user: User
) -> DiscoveryConversation:
"""Fetch a conversation and assert the caller owns it (admins bypass).
Returns 404 for both 'not found' and 'not yours' so the existence of
other users' conversations isn't leaked."""
conversation = await db.get(DiscoveryConversation, conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != user.id and user.role != "admin":
raise HTTPException(status_code=404, detail="Conversation not found")
return conversation
@router.post("/start", response_model=schemas.StartResponse)
async def start_conversation(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = DiscoveryConversation(
id=str(uuid.uuid4()),
user_id=user.id,
started_at=_now(),
)
db.add(conversation)
await db.commit()
return schemas.StartResponse(conversation_id=conversation.id)
@router.put(
"/{conversation_id}/respond", response_model=schemas.RespondResponse
)
async def save_responses(
conversation_id: str,
payload: schemas.RespondRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
conversation.prompt_alive = payload.prompt_alive
conversation.prompt_friction = payload.prompt_friction
conversation.prompt_pull = payload.prompt_pull
conversation.prompt_recognition = payload.prompt_recognition
conversation.prompt_future = payload.prompt_future
await db.commit()
return schemas.RespondResponse(
conversation_id=conversation_id, status="responses_saved"
)
@router.post(
"/{conversation_id}/complete", response_model=schemas.ProfileResponse
)
async def complete_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
responses = {
"alive": conversation.prompt_alive or "",
"friction": conversation.prompt_friction or "",
"pull": conversation.prompt_pull or "",
"recognition": conversation.prompt_recognition or "",
"future": conversation.prompt_future or "",
}
if not any(text.strip() for text in responses.values()):
raise HTTPException(
status_code=400, detail="No responses available to analyze"
)
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
try:
extractor = DiscoveryExtractor(api_key=api_key, model=model)
data = await extractor.extract(responses)
except DiscoveryExtractionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
profile = DiscoveryProfile(
id=str(uuid.uuid4()),
user_id=conversation.user_id,
conversation_id=conversation.id,
generated_at=_now(),
triad=data.get("triad"),
probable_type=_as_int(data.get("probable_type")),
wing=_as_int(data.get("wing")),
instinctual_variant=data.get("instinctual_variant"),
instinctual_stack=data.get("instinctual_stack"),
love_summary=data.get("love_summary"),
strength_summary=data.get("strength_summary"),
mission_summary=data.get("mission_summary"),
vocation_summary=data.get("vocation_summary"),
overlap_narrative=data.get("overlap_narrative"),
confidence_json=json.dumps(data.get("confidence", {})),
locked=False,
)
conversation.completed_at = _now()
db.add(profile)
await db.commit()
return _to_profile_response(
profile, extraction_notes=data.get("extraction_notes")
)
@router.get("/profile/me", response_model=schemas.ProfileResponse)
async def get_my_profile(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
return _to_profile_response(profile)
@router.put(
"/profile/me/confirm", response_model=schemas.ConfirmResponse
)
async def confirm_my_profile(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
profile.locked = True
await db.commit()
return schemas.ConfirmResponse(status="locked")
@router.get(
"/conversation/{conversation_id}",
response_model=schemas.ConversationResponse,
)
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
return schemas.ConversationResponse.model_validate(conversation)
def _as_int(value) -> int | None:
"""Coerce the model's numeric fields to int, tolerating strings/None."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
+67
View File
@@ -0,0 +1,67 @@
"""Pydantic request/response models for the discovery API."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class StartResponse(BaseModel):
conversation_id: str
class RespondRequest(BaseModel):
prompt_alive: str = ""
prompt_friction: str = ""
prompt_pull: str = ""
prompt_recognition: str = ""
prompt_future: str = ""
class RespondResponse(BaseModel):
conversation_id: str
status: str
class Confidence(BaseModel):
triad: Optional[str] = None
type: Optional[str] = None
variant: Optional[str] = None
ikigai: Optional[str] = None
class ProfileResponse(BaseModel):
id: str
user_id: str
conversation_id: str
generated_at: datetime
triad: Optional[str] = None
probable_type: Optional[int] = None
wing: Optional[int] = None
instinctual_variant: Optional[str] = None
instinctual_stack: Optional[str] = None
love_summary: Optional[str] = None
strength_summary: Optional[str] = None
mission_summary: Optional[str] = None
vocation_summary: Optional[str] = None
overlap_narrative: Optional[str] = None
confidence: Optional[Confidence] = None
locked: bool = False
extraction_notes: Optional[str] = None
class ConfirmResponse(BaseModel):
status: str
class ConversationResponse(BaseModel):
id: str
user_id: str
started_at: datetime
completed_at: Optional[datetime] = None
prompt_alive: Optional[str] = None
prompt_friction: Optional[str] = None
prompt_pull: Optional[str] = None
prompt_recognition: Optional[str] = None
prompt_future: Optional[str] = None
model_config = {"from_attributes": True}
View File
+214
View File
@@ -0,0 +1,214 @@
"""DiscoveryExtractor: turns five narrative responses into a structured
enneagram + Ikigai profile via the Anthropic API.
The extractor is responsible only for plumbing: building the message,
calling the model, parsing/validating the JSON it returns, and retrying
once if the first response is not valid JSON. The actual analysis lives
in the model behind ``SYSTEM_PROMPT``.
"""
import json
from typing import Any, Dict
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-5"
MAX_TOKENS = 2000
# Ordered mapping of response keys -> the human-facing prompt label, used to
# label each section of the concatenated user message.
PROMPT_LABELS = {
"alive": "The Alive Moment",
"friction": "The Friction Moment",
"pull": "The Natural Pull",
"recognition": "The Recognition Moment",
"future": "The Future Pull",
}
# Keys the model must return for a profile to be considered well-formed.
REQUIRED_KEYS = (
"triad",
"probable_type",
"wing",
"instinctual_variant",
"instinctual_stack",
"love_summary",
"strength_summary",
"mission_summary",
"vocation_summary",
"overlap_narrative",
"confidence",
)
REQUIRED_CONFIDENCE_KEYS = ("triad", "type", "variant", "ikigai")
SYSTEM_PROMPT = """You are a skilled personality analyst trained in the Enneagram system and the Ikigai framework.
You will receive five narrative responses from a person answering open-ended reflection prompts.
Your job is to extract a structured self-discovery profile from their stories.
ENNEAGRAM EXTRACTION RULES:
- The nine types cluster into three triads based on emotional center:
- Gut (instinctive): Types 8, 9, 1 — driven by anger, focused on control, body-based decisions
- Heart (feeling): Types 2, 3, 4 — driven by shame, focused on image and connection
- Head (thinking): Types 5, 6, 7 — driven by fear, focused on safety and understanding
- The Friction Moment response reveals the triad most clearly — gut types act against injustice, heart types feel exposed or unseen, head types analyze and strategize
- The Alive Moment and Recognition Moment reveal the type's core need
- The Natural Pull reveals instinctual variant: sp (self-preservation) = tasks/systems/stability, so (social) = groups/community/belonging, sx (sexual/one-to-one) = intensity/connection/depth
- The Future Pull reveals the type's idealized self and Ikigai vocation
IKIGAI EXTRACTION RULES:
- Love: what activities, topics, or experiences appear across responses with energy and enthusiasm
- Strength: what the person describes doing well or being recognized for
- Mission: what problem or need in the world their stories orbit around
- Vocation: where their strength and the world's need intersect with economic potential
CONFIDENCE RULES:
- high: strong consistent signal across 2+ responses
- medium: signal present but only in one response or partially contradicted
- low: weak or absent signal — do not guess, flag it
OUTPUT FORMAT:
Respond ONLY with valid JSON. No preamble, no explanation, no markdown fences.
{
"triad": "gut | heart | head",
"probable_type": 1-9,
"wing": 1-9 (must be adjacent to probable_type),
"instinctual_variant": "sp | so | sx",
"instinctual_stack": "e.g. sp/so/sx",
"love_summary": "2-3 sentence summary of what they love",
"strength_summary": "2-3 sentence summary of what they are good at",
"mission_summary": "2-3 sentence summary of what the world needs from them",
"vocation_summary": "2-3 sentence summary of what they can be paid for",
"overlap_narrative": "One paragraph (4-6 sentences) describing where their four Ikigai circles converge and how their enneagram type shapes that intersection. Write directly to the person in second person (you/your). Do not mention enneagram type numbers — describe the pattern in plain language.",
"confidence": {
"triad": "high | medium | low",
"type": "high | medium | low",
"variant": "high | medium | low",
"ikigai": "high | medium | low"
},
"extraction_notes": "Optional: flag anything ambiguous, contradictory, or uncertain that the user should know"
}"""
RETRY_REMINDER = (
"Your previous response could not be parsed as JSON. "
"Respond ONLY with the single valid JSON object described in your "
"instructions — no preamble, no explanation, and no markdown code fences."
)
class DiscoveryExtractionError(Exception):
"""Raised when extraction fails (API error or unparseable output)."""
class DiscoveryExtractor:
"""Extracts a self-discovery profile from narrative responses."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
if not api_key:
raise DiscoveryExtractionError(
"ANTHROPIC_API_KEY is not set; cannot run extraction."
)
self.model = model
self.client = AsyncAnthropic(api_key=api_key)
def _build_user_message(self, responses: Dict[str, str]) -> str:
"""Concatenate the five responses, each under its prompt heading."""
sections = []
for key, label in PROMPT_LABELS.items():
text = (responses.get(key) or "").strip()
sections.append(f"## {label}\n{text if text else '(no response)'}")
return "\n\n".join(sections)
async def _call_model(self, user_message: str) -> str:
"""Make a single Anthropic API call and return the raw text."""
response = await self.client.messages.create(
model=self.model,
max_tokens=MAX_TOKENS,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
return response.content[0].text
async def extract(self, responses: Dict[str, str]) -> Dict[str, Any]:
"""Run extraction. Retries once if the first output is not valid JSON.
Args:
responses: dict with keys alive, friction, pull, recognition, future.
Returns:
Parsed profile dict matching the system-prompt schema.
Raises:
DiscoveryExtractionError: on API failure or repeated parse failure.
"""
user_message = self._build_user_message(responses)
try:
raw = await self._call_model(user_message)
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
raise DiscoveryExtractionError(
f"Anthropic API call failed: {exc}"
) from exc
try:
return self._parse(raw)
except (json.JSONDecodeError, ValueError):
# One retry with an explicit JSON-only reminder appended.
retry_message = f"{user_message}\n\n{RETRY_REMINDER}"
try:
raw_retry = await self._call_model(retry_message)
except Exception as exc: # noqa: BLE001
raise DiscoveryExtractionError(
f"Anthropic API call failed on retry: {exc}"
) from exc
try:
return self._parse(raw_retry)
except (json.JSONDecodeError, ValueError) as exc:
raise DiscoveryExtractionError(
f"Model did not return valid JSON after retry: {exc}"
) from exc
@staticmethod
def _strip_fences(text: str) -> str:
"""Remove a leading/trailing markdown code fence if present."""
stripped = text.strip()
if stripped.startswith("```"):
# drop the opening fence line (``` or ```json)
newline = stripped.find("\n")
if newline != -1:
stripped = stripped[newline + 1 :]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[: -len("```")]
return stripped.strip()
@classmethod
def _parse(cls, raw: str) -> Dict[str, Any]:
"""Parse and validate the model's JSON output.
Raises json.JSONDecodeError if the text is not JSON, or ValueError if
required keys are missing — both of which trigger a retry upstream.
"""
if not raw or not raw.strip():
raise ValueError("empty response from model")
data = json.loads(cls._strip_fences(raw))
if not isinstance(data, dict):
raise ValueError("top-level JSON value is not an object")
missing = [k for k in REQUIRED_KEYS if k not in data]
if missing:
raise ValueError(f"missing required keys: {', '.join(missing)}")
confidence = data.get("confidence")
if not isinstance(confidence, dict):
raise ValueError("confidence must be an object")
missing_conf = [
k for k in REQUIRED_CONFIDENCE_KEYS if k not in confidence
]
if missing_conf:
raise ValueError(
f"missing confidence keys: {', '.join(missing_conf)}"
)
return data
+223
View File
@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Self Discovery</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- Question flow -->
<div class="wrap" id="flow">
<div class="brand">ImpactFlow · Self Discovery</div>
<div class="progress" id="progress">1 of 5</div>
<h2 class="prompt-title" id="promptTitle"></h2>
<p class="prompt-text" id="promptText"></p>
<textarea
id="answer"
placeholder="Take your time. Tell the whole story…"
></textarea>
<div class="nav">
<button class="btn-ghost" id="backBtn">Back</button>
<button class="btn-primary" id="nextBtn">Next</button>
</div>
</div>
<!-- Loading state -->
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Reading your story…</p>
</div>
<script>
const PROMPTS = [
{
key: "prompt_alive",
title: "The Alive Moment",
text:
"Tell me about a time you felt most alive and useful. What were you doing, who was involved, and what made it matter?",
},
{
key: "prompt_friction",
title: "The Friction Moment",
text:
"Describe a situation where something felt deeply wrong or unfair. What was it, and what did you do about it?",
},
{
key: "prompt_pull",
title: "The Natural Pull",
text:
"What do you find yourself doing or thinking about in your free time, even when you're supposed to be doing something else?",
},
{
key: "prompt_recognition",
title: "The Recognition Moment",
text:
"When have you felt most seen or valued — and what were you being recognized for?",
},
{
key: "prompt_future",
title: "The Future Pull",
text:
"If you knew you couldn't fail and money wasn't a factor, what would you spend the next five years building or doing?",
},
];
function createUserId() {
const webCrypto = globalThis.crypto;
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID();
}
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const bytes = new Uint8Array(16);
webCrypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) =>
b.toString(16).padStart(2, "0")
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
12,
16
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return `local-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
}
// Persistent per-browser user id.
function getUserId() {
let id = localStorage.getItem("impactflow_user_id");
if (!id) {
id = createUserId();
localStorage.setItem("impactflow_user_id", id);
}
return id;
}
const userId = getUserId();
const answers = new Array(PROMPTS.length).fill("");
let index = 0;
let conversationId = null;
const el = {
progress: document.getElementById("progress"),
title: document.getElementById("promptTitle"),
text: document.getElementById("promptText"),
answer: document.getElementById("answer"),
back: document.getElementById("backBtn"),
next: document.getElementById("nextBtn"),
flow: document.getElementById("flow"),
loading: document.getElementById("loading"),
};
function render() {
const p = PROMPTS[index];
el.progress.textContent = `${index + 1} of ${PROMPTS.length}`;
el.title.textContent = p.title;
el.text.textContent = p.text;
el.answer.value = answers[index];
el.back.style.visibility = index === 0 ? "hidden" : "visible";
el.next.textContent =
index === PROMPTS.length - 1 ? "Submit" : "Next";
el.answer.focus();
}
function saveCurrent() {
answers[index] = el.answer.value;
}
el.back.addEventListener("click", () => {
saveCurrent();
if (index > 0) {
index -= 1;
render();
}
});
el.next.addEventListener("click", async () => {
saveCurrent();
if (index < PROMPTS.length - 1) {
index += 1;
render();
} else {
await submit();
}
});
async function startConversation() {
const res = await fetch("/discovery/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
if (!res.ok) throw new Error("Could not start conversation");
const data = await res.json();
conversationId = data.conversation_id;
}
async function submit() {
el.flow.style.display = "none";
el.loading.classList.add("active");
try {
if (!conversationId) await startConversation();
const body = {};
PROMPTS.forEach((p, i) => {
body[p.key] = answers[i];
});
const respondRes = await fetch(
`/discovery/${conversationId}/respond`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!respondRes.ok) throw new Error("Could not save responses");
const completeRes = await fetch(
`/discovery/${conversationId}/complete`,
{ method: "POST" }
);
if (!completeRes.ok) {
const detail = await completeRes
.json()
.catch(() => ({ detail: "Extraction failed" }));
throw new Error(detail.detail || "Extraction failed");
}
window.location.href = `/static/profile.html?user_id=${encodeURIComponent(
userId
)}`;
} catch (err) {
el.loading.classList.remove("active");
el.flow.style.display = "block";
el.flow.innerHTML =
`<div class="error-box"><strong>Something went wrong.</strong><br/>` +
`${err.message}<br/><br/>Your answers are still here — ` +
`please try submitting again.</div>` +
`<div class="nav"><span></span>` +
`<button class="btn-primary" onclick="location.reload()">Reload</button></div>`;
}
}
// Kick off a conversation as soon as the page loads so the id is ready.
startConversation().catch(() => {
/* will retry on submit */
});
render();
</script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Your Profile</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="wrap">
<div class="brand">ImpactFlow · Your Direction</div>
<div id="content">
<p>Loading your profile…</p>
</div>
</div>
<script>
// Plain-language description of each enneagram triad. We never show the
// type number on this page — only the pattern.
const TRIAD_INFO = {
gut: {
label: "You lead with instinct and will",
text:
"You move through the world from your gut. You sense what's right before you can fully explain it, and you're driven to act, to protect what matters, and to set things right. Autonomy and integrity are non-negotiable for you, and you'd rather meet a problem head-on than wait for permission.",
},
heart: {
label: "You lead with feeling and connection",
text:
"You navigate through emotion and relationship. You're finely attuned to how things land — for you and for the people around you — and you care deeply about being genuinely seen for who you are. Your energy comes alive in connection, recognition, and making others feel that they matter.",
},
head: {
label: "You lead with thought and perception",
text:
"You meet the world through your mind. You like to understand before you commit — gathering information, mapping possibilities, and anticipating what's ahead so you can feel prepared and secure. Your gift is seeing patterns and thinking your way toward clarity others miss.",
},
};
const IKIGAI = [
{ key: "love_summary", title: "What you love", icon: "♥" },
{ key: "strength_summary", title: "What you're good at", icon: "★" },
{ key: "mission_summary", title: "What the world needs", icon: "◆" },
{ key: "vocation_summary", title: "What you can be paid for", icon: "$" },
];
function getParam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function confClass(level) {
if (level === "high") return "high";
if (level === "medium") return "medium";
return "low";
}
function dot(level) {
const c = confClass(level);
const title = `Confidence: ${level || "low"}`;
return `<span class="dot ${c}" title="${title}"></span>`;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
async function load() {
const userId = getParam("user_id");
const content = document.getElementById("content");
if (!userId) {
content.innerHTML =
'<div class="error-box">No user id provided.</div>';
return;
}
let profile;
try {
const res = await fetch(
`/discovery/profile/${encodeURIComponent(userId)}`
);
if (!res.ok) throw new Error("Profile not found");
profile = await res.json();
} catch (err) {
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
err.message
)}</div>`;
return;
}
const conf = profile.confidence || {};
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
const ikigaiCards = IKIGAI.map((item) => {
return `
<div class="card">
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
<p>${escapeHtml(profile[item.key]) || "—"}</p>
</div>`;
}).join("");
const locked = profile.locked;
content.innerHTML = `
<div class="profile-narrative">${escapeHtml(
profile.overlap_narrative
)}</div>
<p class="section-label">Where your four circles meet</p>
<div class="ikigai-grid">${ikigaiCards}</div>
<div class="triad-block">
<h2>${dot(conf.triad)} ${triad.label}</h2>
<p>${triad.text}</p>
</div>
<div class="confirm-row">
<button class="btn-primary ${locked ? "confirmed" : ""}" id="confirmBtn" ${
locked ? "disabled" : ""
}>
${locked ? "Profile confirmed ✓" : "This is me"}
</button>
</div>
`;
const btn = document.getElementById("confirmBtn");
if (btn && !locked) {
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const res = await fetch(
`/discovery/profile/${encodeURIComponent(userId)}/confirm`,
{ method: "PUT" }
);
if (!res.ok) throw new Error("confirm failed");
btn.textContent = "Profile confirmed ✓";
btn.classList.add("confirmed");
} catch (err) {
btn.disabled = false;
btn.textContent = "Try again";
}
});
}
}
load();
</script>
</body>
</html>
+295
View File
@@ -0,0 +1,295 @@
/* ImpactFlow Self-Discovery — shared styles
Palette: navy #0d1b2a, gold #c9a84c, cream #f8f5ef */
:root {
--navy: #0d1b2a;
--gold: #c9a84c;
--cream: #f8f5ef;
--navy-soft: #1c3049;
--shadow: rgba(13, 27, 42, 0.12);
--green: #2e7d32;
--yellow: #c9a84c;
--red: #c0392b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--cream);
color: var(--navy);
font-family: "DM Sans", system-ui, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 80px;
}
/* ---------- Discovery flow ---------- */
.brand {
font-family: "Playfair Display", "DM Sans", serif;
font-size: 1.1rem;
letter-spacing: 0.04em;
color: var(--gold);
text-transform: uppercase;
margin-bottom: 40px;
}
.progress {
color: var(--gold);
font-weight: 600;
letter-spacing: 0.08em;
font-size: 0.85rem;
text-transform: uppercase;
margin-bottom: 14px;
}
.prompt-title {
font-family: "Playfair Display", "DM Sans", serif;
font-size: 1.35rem;
color: var(--gold);
margin: 0 0 10px;
}
.prompt-text {
font-family: "Playfair Display", "DM Sans", serif;
font-size: 1.7rem;
font-weight: 500;
color: var(--navy);
margin: 0 0 28px;
line-height: 1.35;
}
textarea {
width: 100%;
min-height: 240px;
padding: 20px;
border: 1px solid rgba(13, 27, 42, 0.18);
border-radius: 12px;
background: #fff;
color: var(--navy);
font-family: "DM Sans", system-ui, sans-serif;
font-size: 1.05rem;
line-height: 1.6;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.25);
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 28px;
gap: 16px;
}
button {
font-family: "DM Sans", system-ui, sans-serif;
font-size: 1rem;
font-weight: 600;
padding: 13px 30px;
border-radius: 10px;
border: none;
cursor: pointer;
transition: transform 0.06s ease, opacity 0.2s ease, background 0.2s ease;
}
button:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: var(--gold);
color: var(--navy);
}
.btn-primary:hover:not(:disabled) {
background: #d8b95f;
}
.btn-ghost {
background: transparent;
color: var(--navy);
border: 1px solid rgba(13, 27, 42, 0.25);
}
.btn-ghost:hover:not(:disabled) {
background: rgba(13, 27, 42, 0.05);
}
/* ---------- Loading state ---------- */
.loading {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 60vh;
background: var(--cream);
color: var(--navy);
}
.loading.active {
display: flex;
}
.loading p {
font-family: "Playfair Display", "DM Sans", serif;
font-size: 1.6rem;
color: var(--navy);
}
.spinner {
width: 46px;
height: 46px;
border: 4px solid rgba(201, 168, 76, 0.3);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.9s linear infinite;
margin-bottom: 22px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Profile page ---------- */
.profile-narrative {
background: var(--navy);
color: var(--cream);
padding: 36px 34px;
border-radius: 16px;
font-size: 1.2rem;
line-height: 1.7;
box-shadow: 0 10px 30px var(--shadow);
margin-bottom: 36px;
}
.section-label {
font-family: "Playfair Display", "DM Sans", serif;
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
margin: 0 0 18px;
}
.ikigai-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
margin-bottom: 40px;
}
.card {
background: #fff;
border: 1px solid rgba(13, 27, 42, 0.1);
border-radius: 14px;
padding: 22px 24px;
box-shadow: 0 4px 14px rgba(13, 27, 42, 0.05);
}
.card h3 {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 10px;
font-size: 1.05rem;
color: var(--navy);
}
.card p {
margin: 0;
color: var(--navy-soft);
font-size: 0.98rem;
}
.dot {
display: inline-block;
width: 11px;
height: 11px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.high {
background: var(--green);
}
.dot.medium {
background: var(--yellow);
}
.dot.low {
background: var(--red);
}
.triad-block {
background: #fff;
border: 1px solid rgba(13, 27, 42, 0.1);
border-left: 5px solid var(--gold);
border-radius: 14px;
padding: 26px 28px;
margin-bottom: 40px;
}
.triad-block h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
font-family: "Playfair Display", "DM Sans", serif;
font-size: 1.3rem;
color: var(--navy);
}
.triad-block p {
margin: 0;
color: var(--navy-soft);
}
.confirm-row {
text-align: center;
}
.confirmed {
background: var(--navy) !important;
color: var(--cream) !important;
cursor: default;
}
.error-box {
background: #fdecea;
border: 1px solid var(--red);
color: #7a231b;
padding: 18px 22px;
border-radius: 12px;
}
@media (max-width: 560px) {
.ikigai-grid {
grid-template-columns: 1fr;
}
.prompt-text {
font-size: 1.4rem;
}
}
+111
View File
@@ -0,0 +1,111 @@
"""Activity-log helpers + middleware.
Middleware fires after every request and writes one ActivityLog row when
the request was authenticated and succeeded (status < 400). We classify the
verb (GET -> view / POST -> create / etc.) and bucket the URL path into a
coarse resource name so the dashboard can group "everything about
conversations" without parsing URLs at query time.
"""
import json
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.database import AsyncSessionLocal
from app.models import ActivityLog
_METHOD_ACTION = {
"GET": "view",
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
}
# Paths whose activity is not worth recording (noisy / no user signal).
_SKIP_PATH_PREFIXES = (
"/health",
"/static",
"/favicon.ico",
"/openapi.json",
"/docs",
"/redoc",
"/api/auth/login",
"/api/auth/callback",
)
def _method_to_action(method: str) -> str:
return _METHOD_ACTION.get(method.upper(), "other")
def _path_to_resource(path: str) -> str:
parts = [p for p in path.strip("/").split("/") if p]
if not parts:
return "root"
if parts[0] == "api" and len(parts) > 1:
return parts[1]
return parts[0]
async def log_activity(
*,
user_id: str,
action: str,
resource: str,
resource_id: Optional[str] = None,
metadata: Optional[dict] = None,
source: str = "web",
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
) -> None:
"""Write one row. Uses its own session because the request's session is
already closed by the time tracking middleware sees the response."""
row = ActivityLog(
id=str(uuid.uuid4()),
user_id=user_id,
action=action,
resource=resource,
resource_id=resource_id,
metadata_json=json.dumps(metadata) if metadata else None,
source=source,
ip_address=ip_address,
user_agent=(user_agent or "")[:1000] or None,
created_at=datetime.now(timezone.utc),
)
async with AsyncSessionLocal() as db:
db.add(row)
await db.commit()
class ActivityTrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only log authenticated, successful, non-noisy requests.
if response.status_code >= 400:
return response
path = request.url.path
if any(path.startswith(p) for p in _SKIP_PATH_PREFIXES):
return response
user = getattr(request.state, "user", None)
if user is None:
return response
source = "mcp" if request.headers.get("x-api-key") else "web"
try:
await log_activity(
user_id=user.id,
action=_method_to_action(request.method),
resource=_path_to_resource(path),
source=source,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
except Exception:
# Tracking failures must never break the underlying request.
pass
return response