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_connection import PsaConnection
|
||||||
from .psa_post_log import PsaPostLog
|
from .psa_post_log import PsaPostLog
|
||||||
from .psa_member_mapping import PsaMemberMapping
|
from .psa_member_mapping import PsaMemberMapping
|
||||||
|
from .supporting_data import SessionSupportingData
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -92,4 +93,5 @@ __all__ = [
|
|||||||
"PsaConnection",
|
"PsaConnection",
|
||||||
"PsaPostLog",
|
"PsaPostLog",
|
||||||
"PsaMemberMapping",
|
"PsaMemberMapping",
|
||||||
|
"SessionSupportingData",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class Session(Base):
|
|||||||
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
|
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
|
||||||
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
||||||
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
|
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 link
|
||||||
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
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
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime
|
from sqlalchemy import String, DateTime, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -23,6 +23,12 @@ class Team(Base):
|
|||||||
default=uuid.uuid4
|
default=uuid.uuid4
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc)
|
default=lambda: datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, TYPE_CHECKING
|
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.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -73,6 +73,15 @@ class User(Base):
|
|||||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
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")
|
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)
|
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(
|
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
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):
|
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_timestamps: bool = True
|
||||||
include_tree_info: bool = True
|
include_tree_info: bool = True
|
||||||
# Phase A
|
# 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