feat: empty states, onboarding checklist, PDF exports, and supporting data #114

Merged
chihlasm merged 16 commits from feat/backend-foundation-empty-states-exports into main 2026-03-18 00:42:30 +00:00
11 changed files with 185 additions and 4 deletions
Showing only changes of commit f16a686fb4 - Show all commits

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}