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}