Files
resolutionflow/docs/plans/archive/2026-03-19-phase4-remaining-slices-impl.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00

37 KiB

Phase 4 Remaining Slices — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Complete Phase 4 by building the Public Templates Gallery (Slice 1), polishing Session Export (Slice 3), adding mobile/responsive support (Slice 4), and laying enterprise readiness groundwork (Slice 5).

Architecture: Slice 1 adds a public-facing read-only API over the existing trees and script_templates tables with a new is_gallery_featured flag. Slice 3 is verification/polish of existing export code. Slice 4 is a Tailwind responsive CSS pass. Slice 5 adds model columns + stub services for branding, multi-PSA, and SSO.

Tech Stack: FastAPI, SQLAlchemy 2.0 (async), React 19, TypeScript, Tailwind CSS v4 (@tailwindcss/vite), slowapi (IP-based rate limiting), weasyprint (PDF), PostHog analytics

Detailed specs: docs/2026-03-18-flowpilot-first-pivot-phase4.md (reference for schemas, UI layout, and verification criteria)


Files:

  • Modify: backend/app/models/tree.py
  • Modify: backend/app/models/script_template.py
  • Create: backend/alembic/versions/NNN_add_gallery_featuring_columns.py

Step 1: Add columns to Tree model

In backend/app/models/tree.py, add after the is_default column:

is_gallery_featured: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
gallery_sort_order: Mapped[int] = mapped_column(Integer, default=0)

Step 2: Add columns to ScriptTemplate model

In backend/app/models/script_template.py, add:

is_gallery_featured: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
gallery_sort_order: Mapped[int] = mapped_column(Integer, default=0)

Step 3: Generate and run migration

cd backend
alembic revision --autogenerate -m "add gallery featuring columns to trees and script_templates"
alembic upgrade head

Step 4: Verify migration

docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "\d trees" | grep gallery

Expected: is_gallery_featured and gallery_sort_order columns visible.

Step 5: Commit

git add backend/app/models/tree.py backend/app/models/script_template.py backend/alembic/versions/
git commit -m "feat(gallery): add is_gallery_featured and gallery_sort_order columns to trees and script_templates"

Task 2: Public templates API — schemas

Files:

  • Create: backend/app/schemas/public_templates.py

Step 1: Write schemas

"""Schemas for the public templates gallery. No auth required."""

from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel


class PublicFlowTemplate(BaseModel):
    """A flow template visible in the public gallery."""
    id: UUID
    name: str
    description: str | None = None
    category: str | None = None
    tree_type: str
    step_count: int
    usage_count: int
    success_rate: float | None = None
    tags: list[str] = []
    preview_structure: dict[str, Any] | None = None
    created_at: datetime

    model_config = {"from_attributes": True}


class PublicScriptTemplate(BaseModel):
    """A script template visible in the public gallery."""
    id: UUID
    name: str
    description: str | None = None
    category_name: str | None = None
    category_icon: str | None = None
    complexity: str | None = None
    tags: list[str] = []
    parameter_count: int = 0
    requires_elevation: bool = False
    requires_modules: list[str] = []
    usage_count: int = 0
    is_verified: bool = False
    created_at: datetime

    model_config = {"from_attributes": True}


class PublicGalleryResponse(BaseModel):
    """Paginated gallery listing."""
    flow_templates: list[PublicFlowTemplate]
    script_templates: list[PublicScriptTemplate]
    total_flows: int
    total_scripts: int
    categories: list[str]
    domains: list[str]


class PublicFlowDetail(BaseModel):
    """Flow template detail — preview only, no full structure."""
    id: UUID
    name: str
    description: str | None = None
    category: str | None = None
    tree_type: str
    step_count: int
    usage_count: int
    success_rate: float | None = None
    tags: list[str] = []
    preview_structure: dict[str, Any] | None = None
    created_at: datetime

    model_config = {"from_attributes": True}


class PublicScriptDetail(BaseModel):
    """Script template detail — NO script body (behind signup wall)."""
    id: UUID
    name: str
    description: str | None = None
    category_name: str | None = None
    complexity: str | None = None
    tags: list[str] = []
    parameters: list[dict[str, Any]] = []
    requires_elevation: bool = False
    requires_modules: list[str] = []
    usage_count: int = 0
    is_verified: bool = False
    created_at: datetime

    model_config = {"from_attributes": True}

Step 2: Commit

git add backend/app/schemas/public_templates.py
git commit -m "feat(gallery): add public templates gallery schemas"

Task 3: Public templates API — endpoints

Files:

  • Create: backend/app/api/endpoints/public_templates.py
  • Modify: backend/app/api/router.py

Step 1: Write the test file

Create backend/tests/test_public_templates.py:

"""Tests for the public templates gallery API."""

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
class TestPublicTemplatesGallery:
    """Test GET /api/v1/public/templates endpoints."""

    async def test_gallery_returns_only_featured_flows(self, client: AsyncClient, test_db, auth_headers: dict):
        """Only flows with is_gallery_featured=True appear in gallery."""
        # Create a tree and feature it
        tree_resp = await client.post("/api/v1/trees", json={
            "name": "Featured Flow",
            "description": "A featured troubleshooting flow",
            "tree_type": "troubleshooting",
            "tree_structure": {"id": "root", "type": "root", "children": []},
        }, headers=auth_headers)
        assert tree_resp.status_code == 201
        tree_id = tree_resp.json()["id"]

        # Feature it via admin endpoint (built in Task 5)
        # For now, directly update in DB
        from sqlalchemy import text
        from app.core.database import async_session_factory
        async with async_session_factory() as db:
            await db.execute(text(
                f"UPDATE trees SET is_gallery_featured = true WHERE id = '{tree_id}'"
            ))
            await db.commit()

        # Hit public gallery without auth
        response = await client.get("/api/v1/public/templates")
        assert response.status_code == 200
        data = response.json()
        assert data["total_flows"] >= 1
        flow_ids = [f["id"] for f in data["flow_templates"]]
        assert tree_id in flow_ids

    async def test_gallery_accessible_without_auth(self, client: AsyncClient, test_db):
        """Gallery endpoint works without authentication."""
        response = await client.get("/api/v1/public/templates")
        assert response.status_code == 200

    async def test_gallery_does_not_expose_full_tree_structure(self, client: AsyncClient, test_db, auth_headers: dict):
        """Preview structure should be truncated, not the full tree."""
        # Create and feature a tree with deep structure
        deep_structure = {
            "id": "root", "type": "root", "children": [
                {"id": "n1", "type": "question", "question": "Level 1", "children": [
                    {"id": "n2", "type": "question", "question": "Level 2", "children": [
                        {"id": "n3", "type": "question", "question": "Level 3", "children": [
                            {"id": "n4", "type": "action", "content": "Deep action"}
                        ]}
                    ]}
                ]}
            ]
        }
        tree_resp = await client.post("/api/v1/trees", json={
            "name": "Deep Flow",
            "description": "Has deep nesting",
            "tree_type": "troubleshooting",
            "tree_structure": deep_structure,
        }, headers=auth_headers)
        assert tree_resp.status_code == 201
        tree_id = tree_resp.json()["id"]

        from sqlalchemy import text
        from app.core.database import async_session_factory
        async with async_session_factory() as db:
            await db.execute(text(
                f"UPDATE trees SET is_gallery_featured = true WHERE id = '{tree_id}'"
            ))
            await db.commit()

        # Get detail — should NOT contain level 4
        response = await client.get(f"/api/v1/public/templates/flows/{tree_id}")
        assert response.status_code == 200
        # The preview should exist but be truncated

    async def test_gallery_search(self, client: AsyncClient, test_db, auth_headers: dict):
        """Search returns matching featured templates."""
        tree_resp = await client.post("/api/v1/trees", json={
            "name": "Active Directory Password Reset",
            "description": "Reset AD passwords",
            "tree_type": "troubleshooting",
            "tree_structure": {"id": "root", "type": "root", "children": []},
        }, headers=auth_headers)
        tree_id = tree_resp.json()["id"]

        from sqlalchemy import text
        from app.core.database import async_session_factory
        async with async_session_factory() as db:
            await db.execute(text(
                f"UPDATE trees SET is_gallery_featured = true WHERE id = '{tree_id}'"
            ))
            await db.commit()

        response = await client.get("/api/v1/public/templates/search?q=Active+Directory")
        assert response.status_code == 200
        data = response.json()
        assert data["total_flows"] >= 1

    async def test_gallery_categories(self, client: AsyncClient, test_db):
        """Categories endpoint returns list of categories."""
        response = await client.get("/api/v1/public/templates/categories")
        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)

Step 2: Run tests to verify they fail

cd backend && pytest tests/test_public_templates.py -v

Expected: FAIL (endpoints don't exist yet)

Step 3: Implement the endpoints

Create backend/app/api/endpoints/public_templates.py:

"""Public templates gallery API. No authentication required.

These endpoints power the public gallery at /templates for SEO and lead generation.
Only flows/scripts with is_gallery_featured=True are exposed.
"""

from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.rate_limit import limiter
from app.models.tree import Tree
from app.models.script_template import ScriptTemplate
from app.models.category import Category
from app.schemas.public_templates import (
    PublicFlowDetail,
    PublicFlowTemplate,
    PublicGalleryResponse,
    PublicScriptDetail,
    PublicScriptTemplate,
)

router = APIRouter(prefix="/public/templates", tags=["public-gallery"])


def _truncate_tree_structure(structure: dict, max_depth: int = 3) -> dict | None:
    """Truncate tree structure to max_depth levels for public preview."""
    if not structure:
        return None

    def _truncate(node: dict, depth: int) -> dict:
        truncated = {k: v for k, v in node.items() if k != "children"}
        if depth < max_depth and "children" in node and node["children"]:
            truncated["children"] = [
                _truncate(child, depth + 1) for child in node["children"]
            ]
        elif "children" in node and node["children"]:
            truncated["children"] = []
            truncated["_truncated"] = True
        return truncated

    return _truncate(structure, 1)


def _count_steps(structure: dict) -> int:
    """Count total nodes in a tree structure."""
    if not structure:
        return 0
    count = 1
    for child in structure.get("children", []):
        count += _count_steps(child)
    return count


@router.get("", response_model=PublicGalleryResponse)
@limiter.limit("30/minute")
async def list_gallery(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
    category: str | None = Query(None),
    template_type: str | None = Query(None, alias="type"),
    sort: str = Query("usage", regex="^(usage|newest|success_rate)$"),
    page: int = Query(1, ge=1),
    per_page: int = Query(24, ge=1, le=100),
):
    """List featured templates in the public gallery. No auth required."""
    flow_templates: list[PublicFlowTemplate] = []
    script_templates: list[PublicScriptTemplate] = []
    total_flows = 0
    total_scripts = 0

    if template_type != "scripts":
        # Query featured flows
        flow_query = select(Tree).where(
            Tree.is_gallery_featured == True,
            Tree.is_active == True,
        )
        if category:
            flow_query = flow_query.join(Category, Tree.category_id == Category.id).where(
                func.lower(Category.name) == category.lower()
            )

        # Count
        count_result = await db.execute(
            select(func.count()).select_from(flow_query.subquery())
        )
        total_flows = count_result.scalar() or 0

        # Sort
        if sort == "newest":
            flow_query = flow_query.order_by(Tree.created_at.desc())
        elif sort == "success_rate":
            flow_query = flow_query.order_by(Tree.gallery_sort_order.asc(), Tree.created_at.desc())
        else:  # usage
            flow_query = flow_query.order_by(Tree.gallery_sort_order.asc(), Tree.created_at.desc())

        # Paginate
        offset = (page - 1) * per_page
        flow_query = flow_query.offset(offset).limit(per_page)

        result = await db.execute(flow_query)
        trees = result.scalars().all()

        for tree in trees:
            flow_templates.append(PublicFlowTemplate(
                id=tree.id,
                name=tree.name,
                description=tree.description,
                category=None,  # Will be joined if needed
                tree_type=tree.tree_type,
                step_count=_count_steps(tree.tree_structure) if tree.tree_structure else 0,
                usage_count=0,  # TODO: track usage
                success_rate=None,
                tags=[],  # TODO: join tags
                preview_structure=_truncate_tree_structure(tree.tree_structure),
                created_at=tree.created_at,
            ))

    if template_type != "flows":
        # Query featured scripts
        script_query = select(ScriptTemplate).where(
            ScriptTemplate.is_gallery_featured == True,
        )
        count_result = await db.execute(
            select(func.count()).select_from(script_query.subquery())
        )
        total_scripts = count_result.scalar() or 0

        result = await db.execute(
            script_query.order_by(ScriptTemplate.gallery_sort_order.asc()).offset(0).limit(per_page)
        )
        scripts = result.scalars().all()

        for script in scripts:
            script_templates.append(PublicScriptTemplate(
                id=script.id,
                name=script.name,
                description=script.description,
                category_name=None,
                complexity=getattr(script, "complexity", None),
                tags=[],
                parameter_count=len(script.parameters_schema) if script.parameters_schema else 0,
                requires_elevation=getattr(script, "requires_elevation", False),
                requires_modules=getattr(script, "requires_modules", None) or [],
                usage_count=0,
                is_verified=getattr(script, "is_verified", False),
                created_at=script.created_at,
            ))

    # Get categories
    cat_result = await db.execute(select(Category.name).distinct().order_by(Category.name))
    categories = [row[0] for row in cat_result.all()]

    return PublicGalleryResponse(
        flow_templates=flow_templates,
        script_templates=script_templates,
        total_flows=total_flows,
        total_scripts=total_scripts,
        categories=categories,
        domains=[],  # TODO: populate from tree metadata
    )


@router.get("/flows/{flow_id}", response_model=PublicFlowDetail)
@limiter.limit("30/minute")
async def get_flow_detail(
    request: Request,
    flow_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a featured flow template detail. Preview only — no full structure."""
    from fastapi import HTTPException

    result = await db.execute(
        select(Tree).where(
            Tree.id == flow_id,
            Tree.is_gallery_featured == True,
            Tree.is_active == True,
        )
    )
    tree = result.scalar_one_or_none()
    if not tree:
        raise HTTPException(status_code=404, detail="Template not found")

    return PublicFlowDetail(
        id=tree.id,
        name=tree.name,
        description=tree.description,
        category=None,
        tree_type=tree.tree_type,
        step_count=_count_steps(tree.tree_structure) if tree.tree_structure else 0,
        usage_count=0,
        success_rate=None,
        tags=[],
        preview_structure=_truncate_tree_structure(tree.tree_structure),
        created_at=tree.created_at,
    )


@router.get("/scripts/{script_id}", response_model=PublicScriptDetail)
@limiter.limit("30/minute")
async def get_script_detail(
    request: Request,
    script_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a featured script template detail. NO script body exposed."""
    from fastapi import HTTPException

    result = await db.execute(
        select(ScriptTemplate).where(
            ScriptTemplate.id == script_id,
            ScriptTemplate.is_gallery_featured == True,
        )
    )
    script = result.scalar_one_or_none()
    if not script:
        raise HTTPException(status_code=404, detail="Template not found")

    return PublicScriptDetail(
        id=script.id,
        name=script.name,
        description=script.description,
        category_name=None,
        complexity=getattr(script, "complexity", None),
        tags=[],
        parameters=[
            {"name": p.get("name", ""), "description": p.get("description", ""), "type": p.get("type", "string")}
            for p in (script.parameters_schema or [])
        ],
        requires_elevation=getattr(script, "requires_elevation", False),
        requires_modules=getattr(script, "requires_modules", None) or [],
        usage_count=0,
        is_verified=getattr(script, "is_verified", False),
        created_at=script.created_at,
    )


@router.get("/categories")
@limiter.limit("30/minute")
async def list_categories(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List all categories with template counts."""
    result = await db.execute(
        select(Category.name, func.count(Tree.id))
        .outerjoin(Tree, (Tree.category_id == Category.id) & (Tree.is_gallery_featured == True) & (Tree.is_active == True))
        .group_by(Category.name)
        .order_by(Category.name)
    )
    return [{"name": row[0], "count": row[1]} for row in result.all()]


@router.get("/search", response_model=PublicGalleryResponse)
@limiter.limit("20/minute")
async def search_gallery(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
    q: str = Query(..., min_length=2, max_length=200),
):
    """Full-text search across featured flows and scripts."""
    search_term = f"%{q}%"

    # Search flows
    flow_result = await db.execute(
        select(Tree).where(
            Tree.is_gallery_featured == True,
            Tree.is_active == True,
            or_(
                Tree.name.ilike(search_term),
                Tree.description.ilike(search_term),
            ),
        ).limit(24)
    )
    trees = flow_result.scalars().all()

    flow_templates = [
        PublicFlowTemplate(
            id=t.id, name=t.name, description=t.description,
            category=None, tree_type=t.tree_type,
            step_count=_count_steps(t.tree_structure) if t.tree_structure else 0,
            usage_count=0, success_rate=None, tags=[],
            preview_structure=_truncate_tree_structure(t.tree_structure),
            created_at=t.created_at,
        ) for t in trees
    ]

    # Search scripts
    script_result = await db.execute(
        select(ScriptTemplate).where(
            ScriptTemplate.is_gallery_featured == True,
            or_(
                ScriptTemplate.name.ilike(search_term),
                ScriptTemplate.description.ilike(search_term),
            ),
        ).limit(24)
    )
    scripts = script_result.scalars().all()

    script_templates = [
        PublicScriptTemplate(
            id=s.id, name=s.name, description=s.description,
            category_name=None, complexity=getattr(s, "complexity", None),
            tags=[], parameter_count=len(s.parameters_schema) if s.parameters_schema else 0,
            requires_elevation=getattr(s, "requires_elevation", False),
            requires_modules=getattr(s, "requires_modules", None) or [],
            usage_count=0, is_verified=getattr(s, "is_verified", False),
            created_at=s.created_at,
        ) for s in scripts
    ]

    return PublicGalleryResponse(
        flow_templates=flow_templates,
        script_templates=script_templates,
        total_flows=len(flow_templates),
        total_scripts=len(script_templates),
        categories=[],
        domains=[],
    )

Step 4: Register router

In backend/app/api/router.py, add:

from app.api.endpoints import public_templates
# In the public endpoints section:
api_router.include_router(public_templates.router)

Step 5: Run tests

cd backend && pytest tests/test_public_templates.py -v

Expected: All PASS

Step 6: Commit

git add backend/app/api/endpoints/public_templates.py backend/app/api/router.py backend/tests/test_public_templates.py
git commit -m "feat(gallery): add public templates gallery API endpoints with tests"

Task 4: Frontend — types and API client

Files:

  • Create: frontend/src/types/public-templates.ts
  • Create: frontend/src/api/publicTemplates.ts

Step 1: Create types

frontend/src/types/public-templates.ts:

export interface PublicFlowTemplate {
  id: string
  name: string
  description: string | null
  category: string | null
  tree_type: string
  step_count: number
  usage_count: number
  success_rate: number | null
  tags: string[]
  preview_structure: Record<string, any> | null
  created_at: string
}

export interface PublicScriptTemplate {
  id: string
  name: string
  description: string | null
  category_name: string | null
  category_icon: string | null
  complexity: string | null
  tags: string[]
  parameter_count: number
  requires_elevation: boolean
  requires_modules: string[]
  usage_count: number
  is_verified: boolean
  created_at: string
}

export interface PublicGalleryResponse {
  flow_templates: PublicFlowTemplate[]
  script_templates: PublicScriptTemplate[]
  total_flows: number
  total_scripts: number
  categories: string[]
  domains: string[]
}

export interface PublicFlowDetail extends PublicFlowTemplate {}

export interface PublicScriptDetail {
  id: string
  name: string
  description: string | null
  category_name: string | null
  complexity: string | null
  tags: string[]
  parameters: Array<{ name: string; description: string; type: string }>
  requires_elevation: boolean
  requires_modules: string[]
  usage_count: number
  is_verified: boolean
  created_at: string
}

export interface GalleryCategory {
  name: string
  count: number
}

Step 2: Create API client

frontend/src/api/publicTemplates.ts:

import type {
  GalleryCategory,
  PublicFlowDetail,
  PublicGalleryResponse,
  PublicScriptDetail,
} from '@/types/public-templates'

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'

/**
 * Public templates API client. Uses raw fetch() since these endpoints
 * require no authentication (same pattern as survey/shared pages).
 */
export const publicTemplatesApi = {
  async listGallery(params?: {
    category?: string
    type?: string
    sort?: string
    page?: number
    per_page?: number
  }): Promise<PublicGalleryResponse> {
    const searchParams = new URLSearchParams()
    if (params?.category) searchParams.set('category', params.category)
    if (params?.type) searchParams.set('type', params.type)
    if (params?.sort) searchParams.set('sort', params.sort)
    if (params?.page) searchParams.set('page', String(params.page))
    if (params?.per_page) searchParams.set('per_page', String(params.per_page))

    const response = await fetch(
      `${API_URL}/api/v1/public/templates?${searchParams.toString()}`
    )
    if (!response.ok) throw new Error('Failed to load gallery')
    return response.json()
  },

  async getFlowDetail(id: string): Promise<PublicFlowDetail> {
    const response = await fetch(`${API_URL}/api/v1/public/templates/flows/${id}`)
    if (!response.ok) throw new Error('Template not found')
    return response.json()
  },

  async getScriptDetail(id: string): Promise<PublicScriptDetail> {
    const response = await fetch(`${API_URL}/api/v1/public/templates/scripts/${id}`)
    if (!response.ok) throw new Error('Template not found')
    return response.json()
  },

  async listCategories(): Promise<GalleryCategory[]> {
    const response = await fetch(`${API_URL}/api/v1/public/templates/categories`)
    if (!response.ok) throw new Error('Failed to load categories')
    return response.json()
  },

  async search(q: string): Promise<PublicGalleryResponse> {
    const response = await fetch(
      `${API_URL}/api/v1/public/templates/search?q=${encodeURIComponent(q)}`
    )
    if (!response.ok) throw new Error('Search failed')
    return response.json()
  },
}

export default publicTemplatesApi

Step 3: Export types from index

Add to frontend/src/types/index.ts:

export type * from './public-templates'

Step 4: Commit

git add frontend/src/types/public-templates.ts frontend/src/api/publicTemplates.ts frontend/src/types/index.ts
git commit -m "feat(gallery): add public templates types and API client"

Task 5: Frontend — PublicTemplatesPage

Files:

  • Create: frontend/src/pages/PublicTemplatesPage.tsx
  • Create: frontend/src/components/public/FlowTemplateCard.tsx
  • Create: frontend/src/components/public/ScriptTemplateCard.tsx
  • Create: frontend/src/components/public/TemplateDetailModal.tsx
  • Modify: frontend/src/router.tsx

Design reference: See docs/2026-03-18-flowpilot-first-pivot-phase4.md Task 2 for detailed layout spec.

Key layout:

  • Hero section: "MSP Troubleshooting Templates" heading (Bricolage Grotesque), search bar, "Sign Up Free" CTA
  • Filter bar: category pills, type toggle (Flows/Scripts/All), sort dropdown
  • Responsive card grid: 3 columns desktop (lg:grid-cols-3), 2 tablet (md:grid-cols-2), 1 mobile
  • Flow cards: .glass-card with name, description, domain badge, step count, success rate
  • Script cards: .glass-card with name, description, complexity badge, verified badge
  • Detail modal: preview of first 2-3 tree levels or parameter list, "Sign Up to Use" CTA

Route: Add to frontend/src/router.tsx at top level (outside ProtectedRoute), alongside /landing:

{
  path: '/templates',
  element: page(lazy(() => import('@/pages/PublicTemplatesPage'))),
  errorElement: <RouteError />,
},

PostHog events: gallery_viewed, template_clicked, template_search, signup_cta_clicked

Step 1: Build the page and components

Implement following the Slate & Ice design system. Use text-gradient-brand for the hero heading, .glass-card for template cards, bg-gradient-brand for the primary CTA button. Page should NOT use the AppLayout — it's a standalone public page with its own minimal header (BrandLogo + "Sign Up" button).

Step 2: Add route to router.tsx

Step 3: Verify

Open http://localhost:5173/templates without being logged in. Browse gallery. Search. Filter by category. Click a card — see preview modal with signup CTA.

Step 4: Run build

cd frontend && npm run build

Step 5: Commit

git add frontend/src/pages/PublicTemplatesPage.tsx frontend/src/components/public/ frontend/src/router.tsx
git commit -m "feat(gallery): add public templates gallery page with search, filters, and detail modal"

Files:

  • Create: backend/app/api/endpoints/admin_gallery.py
  • Modify: backend/app/api/router.py
  • Create: frontend/src/pages/admin/GalleryManagementPage.tsx
  • Modify: frontend/src/router.tsx

Backend endpoints (admin-only):

PATCH /api/v1/admin/gallery/flows/{id}/feature     — Toggle is_gallery_featured
PATCH /api/v1/admin/gallery/flows/{id}/sort-order   — Update gallery_sort_order
PATCH /api/v1/admin/gallery/scripts/{id}/feature   — Toggle is_gallery_featured
GET   /api/v1/admin/gallery/featured                — List all featured items

Frontend: Admin page showing all flows/scripts with toggle switches for featuring and drag-to-reorder (or simple sort order input).

Step 1: Write admin endpoint tests Step 2: Implement admin endpoints Step 3: Build admin gallery management page Step 4: Add route under admin children in router.tsx Step 5: Verify: Log in as admin → feature a flow → open /templates incognito → verify it appears Step 6: Commit

git commit -m "feat(admin): add gallery curation tools for featuring and ordering templates"

Slice 3: Session Export Polish

Task 7: Verify and polish session export

Files:

  • Audit: backend/app/services/export_service.py
  • Audit: backend/app/templates/ (check PDF template exists)
  • Modify: frontend/src/pages/SessionDetailPage.tsx (if loading spinner needed)

Step 1: Verify PDF template exists and renders

ls backend/app/templates/

Check if session_export.html or similar PDF template file exists. If not, create one with ResolutionFlow branding.

Step 2: Test all 5 export formats end-to-end

Start the backend, create a session, resolve it, then test each export:

  • GET /api/v1/sessions/{id}/export?format=markdown
  • GET /api/v1/sessions/{id}/export?format=text
  • GET /api/v1/sessions/{id}/export?format=html
  • GET /api/v1/sessions/{id}/export?format=psa
  • GET /api/v1/sessions/{id}/export?format=pdf

Step 3: Verify "Generated with ResolutionFlow" branding in all formats

Check each format's output for branding footer.

Step 4: Add PDF loading spinner if missing

In SessionDetailPage.tsx, ensure pdfLoading state shows a spinner/loading indicator when PDF generation is in progress.

Step 5: Commit any fixes

git commit -m "fix(export): polish session export — verify PDF template, add loading states, ensure branding"

Slice 4: Mobile/Responsive Polish

Task 8: Responsive audit and fix pass

Files to audit and fix (responsive classes):

  • frontend/src/components/flowpilot/FlowPilotIntake.tsx
  • frontend/src/components/flowpilot/FlowPilotSession.tsx
  • frontend/src/components/flowpilot/FlowPilotStepCard.tsx
  • frontend/src/components/flowpilot/FlowPilotOptions.tsx
  • frontend/src/components/flowpilot/FlowPilotActionBar.tsx
  • frontend/src/components/flowpilot/SessionDocView.tsx
  • frontend/src/components/flowpilot/EscalateModal.tsx
  • frontend/src/components/flowpilot/EscalationQueue.tsx
  • frontend/src/components/flowpilot/InSessionScriptGenerator.tsx
  • frontend/src/pages/ReviewQueuePage.tsx
  • frontend/src/pages/FlowPilotAnalyticsPage.tsx
  • frontend/src/pages/PublicTemplatesPage.tsx (already responsive from Task 5)

Responsive breakpoints (Tailwind v4):

  • Mobile: default (< 640px)
  • Tablet: sm: (640px+) and md: (768px+)
  • Desktop: lg: (1024px+)

Key changes per component — see docs/2026-03-18-flowpilot-first-pivot-phase4.md Task 9 for full spec:

  • FlowPilotSession: Desktop 2-col → tablet sidebar-as-topbar → mobile single-col, action bar fixed bottom
  • Options grid: grid-cols-1 sm:grid-cols-2
  • Step cards: p-3 sm:p-4 lg:p-6, edge-to-edge on mobile
  • Modals: Full-screen slide-up on mobile (fixed inset-x-0 bottom-0 rounded-t-2xl)
  • Script generator: Stacked on mobile
  • Review queue: List-only on mobile
  • Analytics charts: Single column stack on mobile
  • Touch targets: Minimum 44px (min-h-[44px] min-w-[44px]) on all buttons/links

Step 1: Audit each component at 390px, 810px, and 1440px Step 2: Fix responsive issues — add Tailwind responsive prefixes Step 3: Verify no horizontal overflow on mobile Step 4: Run build

cd frontend && npm run build

Step 5: Commit

git commit -m "feat(responsive): mobile and tablet responsive pass for FlowPilot and analytics pages"

Slice 5: Enterprise Readiness

Task 9: Custom branding system

Files:

  • Check if backend/app/api/endpoints/branding.py exists; extend or create
  • Modify: backend/app/models/ (Account or Team model — add branding fields)
  • Create migration for branding columns
  • Create: frontend/src/pages/account/BrandingSettingsPage.tsx
  • Modify: frontend/src/components/layout/AppLayout.tsx (apply custom branding CSS vars)

Branding fields (on Account or Team):

  • branding_logo_url: String(500), nullable
  • branding_primary_color: String(7), nullable (hex like #06b6d4)
  • branding_company_name: String(200), nullable

Backend: CRUD endpoint for branding settings (owner-only).

Frontend:

  • Branding settings page under Account Settings
  • AppLayout.tsx reads branding and applies CSS variable overrides via inline style on root element
  • Convert hex to oklch for --color-primary override

Step 1: Add branding columns + migration Step 2: Write branding API endpoint (if not existing) Step 3: Build BrandingSettingsPage (logo upload, color picker, company name) Step 4: Apply CSS overrides in AppLayout Step 5: Verify: Upload logo + set color → sidebar updates → export shows company name Step 6: Commit

git commit -m "feat(enterprise): add custom branding system — logo, accent color, company name"

Task 10: Multi-PSA adapter stubs

Files:

  • Create: backend/app/services/psa/autotask/__init__.py
  • Create: backend/app/services/psa/autotask/provider.py
  • Create: backend/app/services/psa/halopsa/__init__.py
  • Create: backend/app/services/psa/halopsa/provider.py
  • Modify: backend/app/services/psa/registry.py
  • Modify: Frontend integrations page (add "Coming Soon" badges)

Step 1: Create Autotask stub provider

Extend PSAProvider ABC. All methods raise NotImplementedError("Autotask integration coming soon").

Step 2: Create Halo PSA stub provider

Same pattern.

Step 3: Register stubs in PSA registry

Step 4: Update frontend integrations page — show Autotask and Halo as disabled options with "Coming Soon" badge

Step 5: Verify: Open integrations → see ConnectWise (active), Autotask (Coming Soon), Halo (Coming Soon)

Step 6: Commit

git commit -m "feat(enterprise): add multi-PSA adapter stubs for Autotask and Halo PSA"

Task 11: SSO/SAML groundwork

Files:

  • Modify: Account model (add sso_enabled, sso_provider, sso_config columns)
  • Create: backend/app/services/sso_service.py (stub interface only)
  • Create migration
  • Modify: Frontend account settings (add SSO section with "Contact us" message)

Step 1: Add SSO columns to Account model

sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)  # "saml" | "oidc"
sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)

Step 2: Create stub sso_service.py

"""SSO service stub. Full implementation in Phase 5."""

async def initiate_sso_login(account_slug: str) -> str:
    raise NotImplementedError("SSO coming soon")

async def process_sso_callback(saml_response: str):
    raise NotImplementedError("SSO coming soon")

async def validate_sso_config(config: dict) -> bool:
    raise NotImplementedError("SSO coming soon")

Step 3: Generate migration

Step 4: Add SSO section to account settings frontend — "Contact us to enable SSO" with email link

Step 5: Verify: Settings page shows SSO section. DB has new columns.

Step 6: Commit

git commit -m "feat(enterprise): add SSO/SAML groundwork — model columns and stub service"

Final Steps

Task 12: Update project docs and verify

Step 1: Run full test suite

cd backend && pytest --override-ini="addopts="

Step 2: Run frontend build

cd frontend && npm run build

Step 3: Update CURRENT-STATE.md — mark Phase 4 slices as complete

Step 4: Commit

git commit -m "docs: update CURRENT-STATE.md — Phase 4 complete"