Files
impactflow_discovery/app/models.py
T
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:26:08 -05:00

149 lines
5.9 KiB
Python

"""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)
# Phase 1 goal-articulation prompts: the person's own near- and long-term
# goals, in their own words.
prompt_goals_short: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
prompt_goals_long: 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
)
# AI-articulated goals: the person's own stated goals, clarified and
# connected to their Ikigai/enneagram pattern (mirror, never prescription).
short_term_goals: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
long_term_goals: 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
)