From f16a686fb4fe154396507f554d6240a9c6d3ce3b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 23:51:42 -0400 Subject: [PATCH] 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) --- ...d05_add_onboarding_and_branding_columns.py | 41 +++++++++++++++++++ ...dd18c_add_session_supporting_data_table.py | 41 +++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/session.py | 1 + backend/app/models/supporting_data.py | 25 +++++++++++ backend/app/models/team.py | 10 ++++- backend/app/models/user.py | 11 ++++- backend/app/schemas/branding.py | 14 +++++++ backend/app/schemas/onboarding.py | 12 ++++++ backend/app/schemas/session.py | 2 +- backend/app/schemas/supporting_data.py | 30 ++++++++++++++ 11 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py create mode 100644 backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py create mode 100644 backend/app/models/supporting_data.py create mode 100644 backend/app/schemas/branding.py create mode 100644 backend/app/schemas/onboarding.py create mode 100644 backend/app/schemas/supporting_data.py diff --git a/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py new file mode 100644 index 00000000..4627141a --- /dev/null +++ b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py @@ -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') diff --git a/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py new file mode 100644 index 00000000..9a4ecdef --- /dev/null +++ b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1938cdf0..b7854b0b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/session.py b/backend/app/models/session.py index bbab74cf..c191572b 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -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) diff --git a/backend/app/models/supporting_data.py b/backend/app/models/supporting_data.py new file mode 100644 index 00000000..ea04cd91 --- /dev/null +++ b/backend/app/models/supporting_data.py @@ -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") diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 299c9cb9..098444dd 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f0c3f3f6..c7d566d7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 ) diff --git a/backend/app/schemas/branding.py b/backend/app/schemas/branding.py new file mode 100644 index 00000000..bd6baeaf --- /dev/null +++ b/backend/app/schemas/branding.py @@ -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 diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py new file mode 100644 index 00000000..d21647b5 --- /dev/null +++ b/backend/app/schemas/onboarding.py @@ -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 diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 58f4e2df..2a764711 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -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 diff --git a/backend/app/schemas/supporting_data.py b/backend/app/schemas/supporting_data.py new file mode 100644 index 00000000..7469fab8 --- /dev/null +++ b/backend/app/schemas/supporting_data.py @@ -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}