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:
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
25
backend/app/models/supporting_data.py
Normal file
25
backend/app/models/supporting_data.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
14
backend/app/schemas/branding.py
Normal file
14
backend/app/schemas/branding.py
Normal 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
|
||||
12
backend/app/schemas/onboarding.py
Normal file
12
backend/app/schemas/onboarding.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
30
backend/app/schemas/supporting_data.py
Normal file
30
backend/app/schemas/supporting_data.py
Normal 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}
|
||||
Reference in New Issue
Block a user