feat: empty states, onboarding checklist, PDF exports, and supporting data #114
@@ -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