diff --git a/docs/plans/2026-03-19-phase4-remaining-slices-impl.md b/docs/plans/2026-03-19-phase4-remaining-slices-impl.md new file mode 100644 index 00000000..6574d7e0 --- /dev/null +++ b/docs/plans/2026-03-19-phase4-remaining-slices-impl.md @@ -0,0 +1,1136 @@ +# 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) + +--- + +## Slice 1: Public Templates Gallery + +### Task 1: Database migration — add gallery columns + +**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: + +```python +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: + +```python +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** + +```bash +cd backend +alembic revision --autogenerate -m "add gallery featuring columns to trees and script_templates" +alembic upgrade head +``` + +**Step 4: Verify migration** + +```bash +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** + +```bash +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** + +```python +"""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** + +```bash +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`: + +```python +"""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** + +```bash +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`: + +```python +"""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: + +```python +from app.api.endpoints import public_templates +# In the public endpoints section: +api_router.include_router(public_templates.router) +``` + +**Step 5: Run tests** + +```bash +cd backend && pytest tests/test_public_templates.py -v +``` + +Expected: All PASS + +**Step 6: Commit** + +```bash +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`: + +```typescript +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 | 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`: + +```typescript +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 { + 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 { + 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 { + 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 { + 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 { + 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`: +```typescript +export type * from './public-templates' +``` + +**Step 4: Commit** + +```bash +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`: +```typescript +{ + path: '/templates', + element: page(lazy(() => import('@/pages/PublicTemplatesPage'))), + errorElement: , +}, +``` + +**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** + +```bash +cd frontend && npm run build +``` + +**Step 5: Commit** + +```bash +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" +``` + +--- + +### Task 6: Admin gallery curation + +**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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +cd frontend && npm run build +``` + +**Step 5: Commit** + +```bash +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** + +```bash +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** + +```bash +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** + +```python +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** + +```python +"""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** + +```bash +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** + +```bash +cd backend && pytest --override-ini="addopts=" +``` + +**Step 2: Run frontend build** + +```bash +cd frontend && npm run build +``` + +**Step 3: Update CURRENT-STATE.md** — mark Phase 4 slices as complete + +**Step 4: Commit** + +```bash +git commit -m "docs: update CURRENT-STATE.md — Phase 4 complete" +```