feat: add onboarding, branding, and supporting data models, migrations, and schemas

Add onboarding_dismissed and branding columns (logo_data, logo_content_type,
company_display_name) to users and teams models. Create SessionSupportingData
model for attaching text snippets and screenshots to sessions. Add Pydantic
schemas for onboarding status, branding responses, and supporting data CRUD.
Update SessionExport to accept pdf format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-16 23:51:42 -04:00
parent ae6b7b3055
commit f16a686fb4
11 changed files with 185 additions and 4 deletions

View File

@@ -0,0 +1,41 @@
"""add onboarding and branding columns
Revision ID: 21ddb46ddd05
Revises: 061
Create Date: 2026-03-16 23:30:48.910485
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '21ddb46ddd05'
down_revision: Union[str, None] = '061'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Users: onboarding + branding columns
op.add_column('users', sa.Column('onboarding_dismissed', sa.Boolean(), server_default='false', nullable=False))
op.add_column('users', sa.Column('logo_data', sa.Text(), nullable=True))
op.add_column('users', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
op.add_column('users', sa.Column('company_display_name', sa.String(length=255), nullable=True))
# Teams: branding columns
op.add_column('teams', sa.Column('logo_data', sa.Text(), nullable=True))
op.add_column('teams', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
op.add_column('teams', sa.Column('company_display_name', sa.String(length=255), nullable=True))
def downgrade() -> None:
op.drop_column('teams', 'company_display_name')
op.drop_column('teams', 'logo_content_type')
op.drop_column('teams', 'logo_data')
op.drop_column('users', 'company_display_name')
op.drop_column('users', 'logo_content_type')
op.drop_column('users', 'logo_data')
op.drop_column('users', 'onboarding_dismissed')

View File

@@ -0,0 +1,41 @@
"""add session_supporting_data table
Revision ID: ee98013dd18c
Revises: 21ddb46ddd05
Create Date: 2026-03-16 23:31:43.483511
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ee98013dd18c'
down_revision: Union[str, None] = '21ddb46ddd05'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('session_supporting_data',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('data_type', sa.Enum('text_snippet', 'screenshot', name='supporting_data_type'), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('content_type', sa.String(length=50), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_session_supporting_data_session_id'), 'session_supporting_data', ['session_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_session_supporting_data_session_id'), table_name='session_supporting_data')
op.drop_table('session_supporting_data')
op.execute("DROP TYPE IF EXISTS supporting_data_type")

View File

@@ -39,6 +39,7 @@ from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
from .supporting_data import SessionSupportingData
__all__ = [
"User",
@@ -92,4 +93,5 @@ __all__ = [
"PsaConnection",
"PsaPostLog",
"PsaMemberMapping",
"SessionSupportingData",
]

View File

@@ -82,6 +82,7 @@ class Session(Base):
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order")
# PSA ticket link
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)

View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class SessionSupportingData(Base):
__tablename__ = "session_supporting_data"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
session = relationship("Session", back_populates="supporting_data")

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import String, DateTime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -23,6 +23,12 @@ class Team(Base):
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
# Branding
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -73,6 +73,15 @@ class User(Base):
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Onboarding
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
# Branding (solo pros without a team)
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@@ -0,0 +1,14 @@
from typing import Optional
from pydantic import BaseModel
class BrandingResponse(BaseModel):
company_display_name: Optional[str] = None
logo_content_type: Optional[str] = None
has_logo: bool = False
class BrandingLogoResponse(BaseModel):
company_display_name: Optional[str] = None
logo_data: Optional[str] = None
logo_content_type: Optional[str] = None

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
class OnboardingStatus(BaseModel):
created_flow: bool
ran_session: bool
exported_session: bool
tried_ai_assistant: bool
invited_teammate: bool
connected_psa: bool
is_team_user: bool
dismissed: bool

View File

@@ -106,7 +106,7 @@ class SessionResponse(BaseModel):
class SessionExport(BaseModel):
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
include_timestamps: bool = True
include_tree_info: bool = True
# Phase A

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class SupportingDataCreate(BaseModel):
label: str = Field(..., min_length=1, max_length=255)
data_type: Literal["text_snippet", "screenshot"]
content: str = Field(..., min_length=1, max_length=5_000_000)
content_type: Optional[str] = Field(None, max_length=50)
class SupportingDataUpdate(BaseModel):
label: Optional[str] = Field(None, min_length=1, max_length=255)
content: Optional[str] = Field(None, min_length=1)
class SupportingDataResponse(BaseModel):
id: UUID
session_id: UUID
label: str
data_type: str
content: str
content_type: Optional[str]
sort_order: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}