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>
1137 lines
37 KiB
Markdown
1137 lines
37 KiB
Markdown
# 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<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`:
|
|
|
|
```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<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`:
|
|
```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: <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**
|
|
|
|
```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"
|
|
```
|