84 Commits

Author SHA1 Message Date
chihlasm
1a45f66358 feat: overhaul session documentation, PSA notes, and client communications
- Reformat PSA resolution/escalation notes: clean single-line header,
  steps with engineer responses inline, remove duplicate timing blocks,
  remove AI confidence section, add follow-up recommendations
- Standardize time display to decimal hours (e.g. 0.25 hrs) across all
  note formatters and status update context
- Add follow_up_recommendations to SessionDocumentation schema and
  surface in SessionDocView; extracted from resolution suggestion steps
- Add _build_what_we_know() helper: uses session.evidence_items when
  cockpit branch merges, falls back to deriving findings from steps
- Fix option label lookup in generate_status_update (was passing raw
  machine values to AI instead of human-readable labels)
- Add 'What We Know' section to status update ticket notes prompt
- Improve _build_session_context in resolution_output_generator to
  include intake text and full step details instead of truncated chat
- Add request_info audience type: client-facing information request
  that skips the length step and generates a numbered question list
- Improve client_update and email_draft prompts with per-context
  guidance (status/resolution/escalation) and fix escalation subject
  line from 'Specialist Review' to 'Specialist Assistance'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:19:19 +00:00
chihlasm
6f12e42ebe feat: network diagrams UX overhaul — icons, empty canvas, properties panel
- Colorize: semantic category colors for all device types (network=blue,
  security=orange, compute=emerald, endpoint=amber, storage=violet,
  cloud=cyan, infra=steel); better icons (Router, ShieldAlert, Boxes,
  Package, Gauge, PlugZap, Video, Radio); MiniMap uses category colors
- Onboard: centered AI generate prompt on empty canvas with 5 MSP-specific
  example chips, ⌘↵ shortcut, spinner; AIAssistPanel only shown with nodes
- Arrange: properties panel — status badge grid at top, fields grouped into
  Network (IP/Subnet/VLAN) and Hardware (Hostname/Vendor/Model/Role) sections
- Delight: segmented topology color bar on listing cards; backend returns
  category_counts via single extra query on list endpoint
- Harden: real PNG export via html-to-image + getNodesBounds/getViewportForBounds
- Polish: ChevronDown replaces unicode ▾, click-outside for client filter,
  consistent spinner in empty prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:55:03 +00:00
chihlasm
c0d8163e98 fix: network diagram team_id guard + multi-style edge routing
Backend:
- Guard create_diagram with 422 if current_user.team_id is None (prevents
  NOT NULL constraint crash for accounts not yet assigned to a team)
- Add routing field to DiagramEdge schema (straight/curved/step)

Frontend:
- ConnectionEdge now supports straight (default), curved (bezier), and
  step (smooth-step) routing per-edge via routing field in edge data
- PropertiesPanel Connection section gets a Line Style toggle:
  Straight | Curved | Step buttons, active state highlights in accent
- handleEdgeUpdate and serializeEdges now propagate the routing field
- DiagramEdge type gets optional routing field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:05:12 +00:00
chihlasm
af4f07cec6 fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize
- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:09:49 +00:00
chihlasm
5eac5fecff Merge remote-tracking branch 'origin/main' into feat/cockpit-harness 2026-04-04 23:06:41 +00:00
chihlasm
e042cf6186 fix: backend code review fixes for network diagrams
- Replace legacy Optional imports with modern str | None syntax
- Type JSONB columns as Mapped[list[dict[str, Any]]]
- Escape SQL LIKE wildcards (%, _) in diagram search
- Type DiagramNode.position as Position(x, y) Pydantic model
- Wrap AI response parsing in KeyError handler for clean 422 errors
- Remove unused Optional/TYPE_CHECKING imports from schemas/models
- Extract _get_available_slugs helper to DRY duplicate queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:11:44 +00:00
chihlasm
c53ced8725 fix: context menu dismisses on pane click, ISP in toolbar
Context menu now closes when clicking anywhere on the canvas via
onPaneClick prop. ISP device added as built-in toolbar item under
Internet section so it's always available without a database entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:44:48 +00:00
chihlasm
e3b2f73f38 feat: wire context menu and keyboard shortcuts into diagram editor
Right-click context menus for nodes (copy/duplicate/delete) and
canvas (paste/select-all/fit-view). Right-click selects the node
per spec. serializeNodes now handles group nodes correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:33:27 +00:00
chihlasm
866b02833c feat: add useCanvasShortcuts hook for copy/paste/duplicate
Keyboard shortcuts with preventDefault and input guard.
Clipboard stores nodes with relative positions and edge indices.
Paste computes canvas center via screenToFlowPosition.
Duplicate offsets +30px. Supports both device and group nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:28:05 +00:00
chihlasm
80c82f0b48 feat: add ContextMenu component for network diagram editor
Charcoal-styled context menu with action factories for node
and canvas variants. Viewport-clamped positioning, auto-dismiss
on click outside, escape, or scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:26:59 +00:00
chihlasm
f989c1e487 feat: improve drag-and-drop feel in network diagram editor
Grip icons on draggable toolbar items, press effect on drag start,
dashed border overlay with 'Drop to add' text when dragging over canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:26:38 +00:00
chihlasm
375e739348 feat: add ISP icon to network diagram device registry
Globe icon with accent color, under cloud category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:24:36 +00:00
chihlasm
f4b05a2ff5 fix: address code review findings for React Flow UI integration
- Use screenToFlowPosition() for drop coordinates (fixes zoom/pan bug)
- Remove duplicate selection border from DeviceNode (BaseNode handles it)
- Add w-full to GroupNode for proper container sizing
- Remove unused 'selected' destructuring from DeviceNode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:33:00 +00:00
chihlasm
73ea126f4c feat: add grouping toolbar items and traffic flow toggle
DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with
drag-drop. PropertiesPanel gets Show Traffic toggle that switches
edges between connection and animated types. DiagramEditor handles
both device and group node drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:27:23 +00:00
chihlasm
93b1398b5c refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip
Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:24:25 +00:00
chihlasm
654b9cb4ac feat: add LabeledGroupNode and AnimatedSvgEdge components
GroupNode for subnet/VLAN/site grouping with positioned label badge.
AnimatedSvgEdge for traffic flow visualization with animated SVG
shape along edge path. Both registered in type maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:22:45 +00:00
chihlasm
ee32fdbf78 feat: add React Flow UI foundation components for network diagrams
BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:20:42 +00:00
chihlasm
db9d5a8393 fix: resolve stale selection bug in network diagram PropertiesPanel
Selection state now stores IDs and derives objects from live arrays,
so edits in PropertiesPanel inputs reflect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:18:02 +00:00
chihlasm
289b3cacfc fix: resolve TypeScript errors in DeviceToolbar and DiagramEditor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:00:42 +00:00
chihlasm
6b7bc3fd42 feat: add Network Maps to sidebar navigation and router
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:59:06 +00:00
chihlasm
6aef6e2602 feat: add Network Diagrams list page with search, client filter, import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:57:43 +00:00
chihlasm
12f6a29dd5 feat: add DiagramEditor page assembling all panels with auto-save and AI generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:56:03 +00:00
chihlasm
600c3959af feat: add NetworkCanvas wrapper and DiagramHeader components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:53:52 +00:00
chihlasm
d8d8de91d1 feat: add AIAssistPanel with replace and merge modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:52:28 +00:00
chihlasm
ebfd4e5651 feat: add PropertiesPanel for node and edge property editing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:52:15 +00:00
chihlasm
7c19521fc2 feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:51:44 +00:00
chihlasm
17e1bc84e2 feat: add device registry, DeviceNode, ConnectionEdge for React Flow
Creates the React Flow building blocks for the network diagram editor:
device type registry with icon/color mappings, DeviceNode component with
status indicators and connection handles, ConnectionEdge with per-type
styling, and nodeTypes/edgeTypes registration maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:50:10 +00:00
chihlasm
ebc6b46823 feat: add frontend API clients for device types and network diagrams
Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi
(list, get, create, update, archive, duplicate, exportJson, importJson,
aiGenerate, listClients) following the existing apiClient module pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 07:48:03 +00:00
chihlasm
38be5b0370 feat: add TypeScript types for network diagrams
Adds all interfaces for network diagrams and device types including
DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse,
AI generate request/response, import/export shapes, and list item types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 07:48:00 +00:00
chihlasm
b6ea63d81a feat: add network diagrams CRUD + AI generate + export/import router
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:45:42 +00:00
chihlasm
03f6100d4c feat: add AI generation service for network diagrams
Adds network_diagram_ai_service.py with generate_diagram() function that
calls the AI provider to convert plain-English network descriptions into
structured DiagramNode/DiagramEdge data. Registers the action in
ACTION_MODEL_MAP as a standard-tier route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:44:00 +00:00
chihlasm
a3c49873f5 feat: add device types CRUD router
Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:43:23 +00:00
chihlasm
5a91c4d672 feat: add Pydantic schemas for device types and network diagrams
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:42:18 +00:00
chihlasm
068baec179 feat: add network_diagrams table
Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:41:13 +00:00
chihlasm
9c042c750e feat: add device_types table with system seed data
Creates DeviceType SQLAlchemy model and migration 073 that provisions the
device_types table with 28 system-seeded device types across 7 categories
(network, compute, storage, cloud, endpoint, infrastructure, security).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:40:12 +00:00
chihlasm
8c90da1960 docs: add network diagrams implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:36:52 +00:00
chihlasm
be34a20441 docs: add network diagrams design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:25:51 +00:00
chihlasm
e7ac87bf7d docs: update CURRENT-STATE.md with action bar consolidation work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 05:05:23 +00:00
chihlasm
165e402284 fix: deduplicate actions, promote ViewToggle tab bar, standardize naming
Remove duplicate Update/Close actions from chat toolbars (FlowPilotPage,
CockpitPage) — session lifecycle actions now live only in headers. Redesign
ViewToggle as a persistent tab bar with bottom-border active indicator and
ARIA attributes. Standardize all action naming: Resolve (emerald), Update
(blue), Close (rose), Pause (muted). Fix IncidentHeader Resolve from orange
to emerald. Delete unused FlowPilotActionBar component (227 lines). Update
ConcludeSessionModal copy to use forward-facing action verbs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:40:06 +00:00
chihlasm
aca976a49a fix: move ViewToggle into header bars and remove subtitle
Eliminates the dedicated ViewToggle row on CockpitPage by merging
it into IncidentHeader's action group via extraActions prop. Removes
subtitle from ViewToggle component entirely — the icon + label is
self-explanatory. Cleans up showSubtitle prop from all call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:48:09 +00:00
chihlasm
47e71c4753 fix: opaque overflow menu, add Update button to cockpit and assistant headers
The IncidentHeader overflow menu was see-through due to bg-elevated on
bg-card. Switched to bg-card with shadow-xl and fixed-scrim dismiss
pattern matching production FlowPilotSessionPage. Replaced unicode
ellipsis with MoreHorizontal icon + aria-label. Added Update button
(StatusUpdateModal) to IncidentHeader and FlowPilotPage header bar
for feature parity with production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:43:48 +00:00
chihlasm
91e3f80707 fix: clear cockpit state on session switch and add loading placeholders
- Reset triageMeta, psaTicketId, steps, and completedSteps when
  activeChatId changes (prevents stale triage data from previous session
  showing while AI processes the new one)
- Split the activeChatId and activeActions reset effects so triage
  only resets on session switch, not on every new action set
- Add loading placeholders to cockpit work zone: spinner + "analyzing"
  text in steps panel, "questions will appear here" in right panel
- Add centered "Starting session..." loader to FlowPilot page when
  loading with no messages yet (prefill creation period)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:56:29 +00:00
chihlasm
b03f84aecf fix: prevent task lane from showing previous session's data on switch
Root cause: race condition between setActiveChatId and the persist
effect. When switching from session A to B, setActiveChatId(B) triggers
the persist effect which writes {chatId: B, questions: [A's data]} to
sessionStorage BEFORE the async selectChat clears the task lane. The
sessionStorage fallback then finds chatId === B and restores A's stale
task lane data.

Fix: clear task lane state synchronously in selectChat before the await.
Server-side pending_task_lane restores it if the new session has data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:23:55 +00:00
chihlasm
813b598101 fix: cockpit/flowpilot bugs and redesign view toggle placement
- Fix return type annotation on unified_chat_service.send_chat_message (6→7 tuple)
- Fix stale closure in CockpitPage handleStepComplete auto-advance logic
- Fix IncidentHeader copy link hardcoding /assistant/ path (now uses current URL)
- Wire psaTicketId from session data through to CockpitPage incident header
- Fix FlowPilotAsks showing only first question — add chevron navigation for all
- Redesign ViewToggle: add icons (MessageSquare/LayoutDashboard) and subtitles
- Move view toggle from chatbar toolbar to standalone row above input on dashboard
- Standardize toggle placement across dashboard, FlowPilot, and Cockpit pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:02:42 +00:00
chihlasm
ed6e6cd1ed fix: stabilize cockpit and assistant session handoff 2026-04-03 04:00:47 +00:00
chihlasm
b07dfb7603 fix: preserve cockpit steps across view toggles and show full step info
Backend: stop wiping pending_task_lane when AI response has no new
[ACTIONS]/[QUESTIONS] markers — previous task lane state is still
relevant until replaced by new markers.

Frontend (selectChat): don't eagerly clear task lane before server
response arrives; restore from sessionStorage as fallback when
pending_task_lane is null (covers sessions before backend fix).

StepsPanel: show description and command for all steps instead of
hiding behind hover/active-only visibility. Commands render as
inline code blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:09:19 +00:00
chihlasm
4ba32a08ac fix: resolve race conditions in assistant/cockpit session loading
- Always load session data on mount even when urlSessionId matches
  activeChatId, fixing empty state after view toggle between /assistant
  and /cockpit (tasks/messages not showing until sidebar click)
- Add loadingRef for synchronous guards preventing duplicate sends,
  duplicate session creation, and prefill races
- Fix stale evidence_items closure in CockpitPage handlers
- Move setLoading(true) before first await in handlePrefill and
  handleResumeNew

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 02:49:29 +00:00
chihlasm
3ea669a1e5 chore: rename 'AI Assistant' to 'FlowPilot' in user-facing text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:34:36 +00:00
chihlasm
81ad52f5bc feat: add launch view preference toggle to StartSessionInput
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:33:57 +00:00
chihlasm
cd7774b733 feat: add preferredFlowPilotView to user preferences store
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:33:08 +00:00
chihlasm
3ce4201d62 feat: add FlowPilot and FlowPilot Cockpit to sidebar navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:32:40 +00:00
chihlasm
b994e82c56 feat: add /cockpit routes, point /assistant to FlowPilotPage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:31:47 +00:00
chihlasm
7d97412d1f feat: add ViewToggle component, uncomment in both pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:31:20 +00:00
chihlasm
fc51ceb610 refactor: rename AssistantChatPage to CockpitPage, consume useAssistantSession hook
Replace all inline session management with the shared useAssistantSession
hook. Keep cockpit-specific state (triageMeta, workZonePct, steps, onboarding)
and handlers. Wire onSessionLoadedRef/onTriageUpdateRef callbacks. Add feature
flag redirect for flowpilot_cockpit. Update router and prefetch references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:29:42 +00:00
chihlasm
4cc6ee4797 feat: create FlowPilotPage with classic chat layout
Recreates the production AssistantChatPage as FlowPilotPage using the
shared useAssistantSession hook. Classic chat interface with ChatMessage
bubbles, TaskLane side panel, rich input with file uploads, and
conclude/status update modals. ViewToggle commented out pending Task 4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:20:34 +00:00
chihlasm
2ed02607a8 fix: add loadChats to useAssistantSession return value
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:18:20 +00:00
chihlasm
1a858237ba feat: extract useAssistantSession hook from AssistantChatPage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:16:46 +00:00
chihlasm
df9e069452 docs: add FlowPilot / Cockpit side-by-side implementation plan
10-task plan covering hook extraction, page split, view toggle,
routing, sidebar nav, dashboard preference, and UI renaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:58:49 +00:00
chihlasm
79c8632dbb docs: add FlowPilot / Cockpit side-by-side design spec
Sub-project 2 design spec covering dual-page routing, view toggle,
sidebar navigation, dashboard integration, and shared logic extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:44:07 +00:00
chihlasm
a001aa11e5 chore: seed flowpilot_cockpit feature flag with plan defaults
Disabled for free plan, enabled for pro and team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:23:17 +00:00
chihlasm
2ee3c1afda refactor: remove canUseFeature from useSubscription
No external call sites existed. Feature gating now handled by
useFeatureFlag hook backed by the feature_flags system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:58 +00:00
chihlasm
cc929c8932 feat: add useFeatureFlag hook for feature gating
Selector-based hook reads from authStore.featureFlags.
Returns false for unknown keys (fail closed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:53 +00:00
chihlasm
b7e5979d0c feat: add featureFlags to authStore, fetched in fetchUser
Loads resolved feature flags from /auth/me/feature-flags alongside
user, account, and subscription data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:38 +00:00
chihlasm
6117a83b0b fix: add response_model, tighten overrides type annotation, add uuid import
Code review fixes for feature flags endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:19:11 +00:00
chihlasm
a60c19b305 feat: add GET /auth/me/feature-flags resolution endpoint
Resolves feature flags for the current user using:
account override > plan default > false

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:16:26 +00:00
chihlasm
a2749104f4 refactor: design critique fixes for account pages
- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 06:24:26 +00:00
chihlasm
8c28f48ce0 fix: remove unused admin account icon import 2026-04-02 04:48:38 +00:00
chihlasm
f9de76b28c feat: add admin account detail management 2026-04-02 04:37:23 +00:00
chihlasm
296153850b feat: expand admin customer account controls 2026-04-02 04:17:29 +00:00
chihlasm
1d4d7ef35d feat: expand account management hub 2026-04-02 03:57:27 +00:00
chihlasm
bfcb8c52d3 feat: reorganize admin panel around accounts 2026-04-02 03:46:11 +00:00
chihlasm
b8189a1999 fix: guard all chat response paths against session-switch race condition
handleSend, sendPrefill, and handleResumeNew all make async API calls
that can return after the user has switched to a different session. Without
a guard, the stale response overwrites the new session's questions/actions
state — causing the previous session's FlowPilot Asks to persist.

Fix: capture the session ID before each await and check currentChatRef
after — discarding the response if the user has since switched. This
matches the existing guard pattern in selectChat (lesson #106).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:09:28 +00:00
chihlasm
9462da8b80 feat: cockpit UX polish — conversation log, interactive steps, onboarding
- Redesign conversation log: proper role labels, left-border accents,
  larger text, CSS variable background, min-height guarantee
- Interactive steps panel: click-to-complete, click-to-select, progress
  bar with counter, hover-reveal descriptions, smooth transitions
- Replace noop overflow button with real dropdown menu (Pause, Copy Link,
  Close Case) with keyboard/click-outside dismiss
- Evidence cycling: right-click to reverse-cycle status, tooltips on icons
- First-run onboarding overlay labeling the three cockpit zones, auto-
  dismisses on first message or manual dismiss, persisted via localStorage
- Drag handle: taller, visible hover state, title tooltip
- Simplify input placeholder (remove redundant paste-log hint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:13:46 +00:00
chihlasm
63023d486d fix: resolve TypeScript errors from cockpit refactor (Phase 7)
- Remove unused imports (ChatMessage, TaskLane)
- Remove unused handleTaskSubmit and handleFlowPilotAnswer
- Remove unused setActiveStepIndex setter
- Add triage fields to AISessionDetail construction in useFlowPilotSession
- npx tsc -b passes clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:04:23 +00:00
chihlasm
1705ecbb9f feat: MSP-native language pass + conclude modal update (Phase 6)
- ConcludeSessionModal: "Conclude Session" → "Close Case"
- ChatSidebar: "New Chat" → "New Case", "chat history" → "case history"
- Language pass in AssistantChatPage already done in Phase 5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:02:14 +00:00
chihlasm
6ee6faa712 feat: refactor AssistantChatPage into cockpit layout (Phase 5)
- Stacked zone layout: incident header → work zone → drag handle →
  conversation log → compose
- IncidentHeader wired with triageMeta state and field save handlers
- Work zone: StepsPanel (left) + FlowPilotAsks + WhatWeKnow (right)
- Drag-resizable split with localStorage persistence
- Compact conversation log with you:/fp: prefixes
- Triage state populated on session load/resume
- AI triage_update merged into header via mergeTriageUpdate()
- MSP-native language: FlowPilot, New Case, Close Case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:51:29 +00:00
chihlasm
23a7cee1f5 feat: add cockpit work zone components (Phase 4)
- IncidentHeader: labelled fields with per-field edit popovers
- StepsPanel: ordered step checklist (✓/→/○) with script CTA
- FlowPilotAsks: quick-reply buttons or free-text input
- WhatWeKnow: evidence list with status toggle and inline editing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:43:15 +00:00
chihlasm
036198b224 feat: add cockpit frontend types and API methods (Phase 3)
- TriageMeta, EvidenceItem, TriageUpdate interfaces
- QuestionItem.options field for quick-reply buttons
- triage_update on ChatMessageResponse
- Triage fields on AISessionDetail for session resume
- updateTriage() and getHandoffDraft() API methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:41:49 +00:00
chihlasm
92cc62bcbd feat: add [TRIAGE_UPDATE] marker extraction and auto-PATCH (Phase 2)
- Add _parse_triage_update_marker() parser following existing marker pattern
- Add [TRIAGE_UPDATE] instructions to system prompt with grounding rules
- Add QuestionItem.options support in question parser
- Wire triage extraction into both main and branch-aware chat paths
- Auto-PATCH session: AI only fills null fields (manual edits win)
- Evidence items: AI appends only, never modifies existing
- Return triage_update in ChatMessageResponse for frontend header sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:40:49 +00:00
chihlasm
15781baeb7 feat: add cockpit triage backend foundation (Phase 1)
- Migration 071: add client_name, asset_name, issue_category,
  triage_hypothesis, evidence_items columns to ai_sessions
- TriageUpdate schema for AI-inferred header updates in chat responses
- QuestionItem.options field for quick-reply buttons
- PATCH /ai-sessions/{id}/triage endpoint for manual header edits
- POST /ai-sessions/{id}/handoff-draft streaming endpoint for conclude modal
- Structured handoff fields (root_cause, steps_taken, recommendations)
  on resolve/escalate requests, passed through to ResolutionOutputGenerator
- Triage fields exposed in AISessionDetail response for session resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:30:48 +00:00
chihlasm
cb750d52f6 docs: resolve all contract decisions from codex readiness review
Addresses every Red and Yellow item from the codex review:
- Canonical handoff: ResolutionOutputGenerator is the source of truth
- AI vs manual authority: manual edits win, AI never overwrites
- evidence_items: full-list replacement, frontend is merge authority
- TaskLane persistence: lifted into hook, StepsPanel is presentation-only
- Quick replies: immediate-send, full-stack contract change
- issue_category + asset_name: free text in v1
- Adds 5 implementation guardrails and Phase 2 gate for triage extraction
- Execution order updated to 37 steps with persistence extraction step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:33:47 +00:00
chihlasm
56ca5d2669 docs: merge codex insights into claude super plan
Adds key architectural choices summary, assumptions section,
sidebar visual demotion (F9), message click-to-expand in compact
log, and backend-first rationale from the codex plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:17:08 +00:00
chihlasm
81f8aa0074 docs: add MSP assistant harness super plan (claude synthesis)
Merges MSP_Assistant_Harness_Implementation_Plan.docx with the
brainstorming design spec into a single executable plan. Resolves
all open questions from the original docx, expands scope to include
backend changes, and adds a 35-step phased execution order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:11:26 +00:00
chihlasm
5dd43b2226 docs: add MSP assistant harness cockpit design spec
Design spec for evolving /assistant into a live triage cockpit.
Covers layout decisions (stacked zones, drag-resizable split),
incident header (labelled fields, AI-inferred + editable),
work zone (steps checklist + FlowPilot Asks + What We Know),
conclude modal redesign, and all required backend changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:59:15 +00:00
717 changed files with 13740 additions and 104757 deletions

View File

@@ -1,46 +0,0 @@
# CURRENT_TASK.md
**Active task:** Phase O cutover for self-serve signup. All code blockers are now closed on `main`. Only user-side manual ops remain: apex DNS fix at Namecheap, Stripe Dashboard live-mode config (with the new `/contact` and `/policies` URLs surfaced in the business profile), Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
## Recently shipped
- **2026-05-14 — PR #168** Session expiration policy + dashboard onboarding-CTA fix + welcome step-2 PSA CTA reshape. Merge-committed into main as `3a35121`. Three threads bundled on one branch (`feat/session-expiration-policy`):
- **Session expiration policy** (original branch scope): 3d idle / 14d absolute, per-account override, bulk revoke. New `AccountSecuritySettingsPage`, `RevokeSessionsModal`, `SessionExpiryToast`, `useAuthSessionExpiry` hook; backend dependencies in `accountSecurity.ts`.
- **Dashboard onboarding CTA fix** (`8d79dd9`): The "Start a session" CTAs on `NextStepCard` and `SetupChecklist` used to `<Link to="/">` while themselves rendered on `/`, so clicks were silent no-ops. Replaced with a `FOCUS_START_SESSION_EVENT` window event that `StartSessionInput` listens for — scrolls itself into view (top of viewport), focuses the textarea, pulses a blue ring 900ms. `NextStepCard` hides itself locally on click so the prompt doesn't linger while the user types.
- **Welcome step-2 PSA CTA reshape** (`dc88797`): Selecting a real PSA now swaps `[Continue] [Skip]` for `[Connect <PSA> now] [Connect later] [Skip this step]`. Primary blue button saves `primary_psa` and routes to `/account/integrations`; "Connect later" saves and continues to step 3. **Pre-existing bug fixed**: the old subtle "Connect now →" link never persisted `primary_psa` before navigating. Now it does. "No PSA yet" / no-selection states still show the original single Continue.
- **2026-05-14 — PR #166** Docs/handoff doc updates carrying forward PR #164/#165 state and EIN blocker. Squash-merged into main as `fe0e692`.
- **2026-05-12 — PR #167** `backend/scripts/create_site_admin.py` site-wide super-admin bootstrap script. Squash-merged into main as `e50a215`. Idempotent CLI, three modes (`--send-reset`, `--print-reset`, `--promote-only`). Uses `ADMIN_DATABASE_URL` (BYPASSRLS). User confirmed end-to-end success against prod via `railway ssh` 2026-05-12 evening.
- **2026-05-12 — PR #165** Legal/contact pages for Stripe site review. Squash-merged into main as `ba45cfe`. Three new SPA pages: `/policies` (consolidated Customer Policies — refunds, cancellation, U.S. legal/export restrictions, promotional terms; anchor IDs per subsection), `/contact` (phone (470) 949-4131, support/sales/billing/security inboxes, response-time SLAs), `/promotions` (stub satisfying Policies §6.2). New `MarketingFooter` component (`components/common/MarketingFooter.tsx`) extracted from inline landing footer; mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy/Terms/Policies/Contact) are reachable from every marketing surface. Component reuses existing `landing-footer*` CSS — must be inside a `.landing-page` wrapper (documented in JSX comment). Privacy and Terms closing sections updated to point at `/contact` + `/policies` with correct per-area inboxes; stale `hello@` mailto removed everywhere. Mailing address left as TODO comments in both `ContactPage.tsx` and `PoliciesPage.tsx`, rendered publicly as "available on request" until P.O. Box is purchased. tsc + eslint clean.
- **2026-05-08 — PR #164** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist + Stripe sync script + page-title fix + frontend taxonomy followups + doc refresh. 5 commits on `feat/billing-plan-taxonomy` from main (`dad5e1f`); HEAD `2c9f5e9`. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` and adds `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`). Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` upserts `plan_billing` rows from Stripe products by exact name match — annual fields stay NULL by design (user explicitly skipping annual pricing for exit flexibility). `Settings.is_internal_tester` + `is_self_serve_active_for` centralize the allowlist + global-flag check; new `get_current_user_optional` dep; `/config/public` honors allowlist for authenticated callers; `/auth/register` allows allowlisted emails without invite code. LandingPage page-title bug — `—` inside JSX attribute strings was rendering as 6 literal characters in browser tabs; replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs". 86/86 passing across subscription/billing/plan/invite/admin sweep; tsc + lint clean. See `.ai/DECISIONS.md` for the two architectural entries (taxonomy reconciliation, allowlist).
- **2026-05-06 — PR #163** Seed test users marked email-verified. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the plan. Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Squash-merged into main as `f1be3ab`. Single alembic head was `c6cbfc534fad` (no new migrations in Phase 2; PR #164 adds `4ce3e594cb87`).
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
- **TaskLane keyboard-first flow** (real feature): Enter submits + auto-advances to next pending task, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last submission. Mouse path also auto-advances. Subtle hint row teaches the shortcut.
- **Banner ↔ script panel linked**: collapsing or dismissing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; recording any outcome closes both surfaces.
- **WhatWeKnow collapsible**: per-session preference in `sessionStorage` (`rf-whatweknow-collapsed:{sessionId}`); auto-collapses on first render at ≥5 facts.
- **Side fix**: `ParameterizationPreview.tokenize()` word-boundary guard prevents over-eager highlighting of short values like `"D"` (no longer lights up every capital D in `Get-ADUser`).
- Validation: tsc clean, ESLint clean, Vite build clean. Type-check + lint passed at every commit boundary.
- **2026-05-01 — PR #156** Suggested-fix `applied_pending` non-terminal outcome. Merged into `main` as `3ba4532`. Adds:
- Schema/API: `FixStatus="applied_pending"`, `pending_reason` Text column, migration `c0f3a4b7e91d`. `PATCH /suggested-fixes/{id}/outcome` accepts pending, requires notes, stamps `applied_at` only.
- UI: `PendingBanner` (info-tone, worked / didn't / update reason / dismiss). "Waiting to verify…" overflow option in `VerifyingBanner`. Nudge "Still checking" records pending with a reason. Page-level Resolve auto-patches pending → success before resolution flow; page-level Escalate intercepts pending the same way verifying/partial does.
- Generators: `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples.
- Tests: 4 new in `test_fix_outcome_endpoint.py` (21/21 suite green); prompt anti-parrot guardrail green; tsc + Vite build clean.
- QA report: `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` (5/7 scripted checks PASS with concrete evidence; 2 entry-path checks deferred — same handlers verified via tested transitions).
- **2026-04-30 — PR #155** Escalation Mode wedge merged as `ac42f97`. Senior-tech magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
## Two-metric framing (Escalation Mode — read before quoting numbers)
The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
## Kill-switch (Escalation Mode)
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.
## Notes for next session
- Drive checks 1 (VerifyingBanner overflow → "Waiting to verify…") and 5 (nudge "Still checking" with 3+ post-apply messages) in real pilot usage to close the QA gap left by `/qa` (the tested handlers cover the same mutations, but the entry-path UI rendering wasn't exercised end-to-end).
- Consider monitoring how often pending fixes get parked vs resolved — if engineers report losing track across sessions, revisit the cross-session "Follow-ups" dashboard rollup that was scoped out.
- After PR #158 lands in real ticket flow, eyeball the keyboard-hint contrast and the WhatWeKnow auto-collapse-at-5 threshold — both were judgment calls (5 was a guess; the contrast bump from `/70` to full muted-foreground was based on my read, not real screen testing). Adjust if the 5-fact threshold feels too aggressive or too lenient mid-session.
- Two follow-ups logged in `.ai/TODO.md` from the impeccable pass: `ConcludeSessionModal` paused/escalated step should allow multi-select (Ticket Notes + Client Update + Email Draft simultaneously) — real feature work; `bg-card-hover` Tailwind class doesn't resolve in `CommandPalette` — two-line fix.

View File

@@ -1,250 +0,0 @@
# DECISIONS.md
> Append-only architectural decision log. Newest entries at the top.
> Entry format:
>
> ```
> ## YYYY-MM-DD — <short title>
> **Context:** why this came up
> **Decision:** what we chose
> **Rejected:** what we didn't choose and why
> **Consequences:** what this means going forward
> ```
---
## 2026-05-28 — Scope Anthropic structured outputs to flat-array JSON only
**Context:** Optimizing the existing Claude API usage (no model change). The Anthropic path in `generate_json` (`ai_provider.py`) had no equivalent to the Gemini path's `response_mime_type="application/json"` — it prompted for JSON and relied on downstream defenses: `_strip_markdown_fences` (ai_fix), `parse_llm_json` (knowledge_flywheel), and `_try_repair_json` (kb_conversion, which balances unclosed braces on truncated output). Anthropic structured outputs (`output_config.format` with a JSON schema) guarantee valid, parseable JSON and would eliminate those band-aids. The question was which of the four `generate_json` call sites can adopt it.
Structured outputs has hard schema limits: **no recursive schemas**, and **every object must set `additionalProperties: false`** (so the schema must enumerate exactly the fields the model emits — a superset is impossible, an omission makes a field unproducible). Tracing the call sites against those limits:
- **kb_conversion** → output is `{title, description, nodes: [...]}` / `{...steps[], intake_form[]}`**flat arrays**, references by `next_node_id`/id, no nesting. Expressible.
- **ai_fix** → returns a fixed *node that is itself a subtree*; `_find_node_by_id` recurses `node["children"]` and the prompt requires decision nodes to have ≥2 children. **Recursive, arbitrary depth.**
- **knowledge_flywheel flow-gen** → emits `tree_structure`, a decision-tree root with nested `children`/`options`, persisted as an opaque blob.
- **knowledge_flywheel enhancement** → flat `new_nodes[] + modified_options[]`; expressible but low-frequency and only fence-stripped.
**Decision:** Apply structured outputs to **flat-array outputs only** — i.e. `kb_conversion`. Wired via an optional `schema=` param on `AIProvider.generate_json` (`None` = legacy prompt-only behavior; Anthropic maps it to `output_config.format`, Gemini ignores it), with the two KB schemas + `_schema_for_target_type()` in `kb_conversion_service.py`, gated behind `settings.AI_KB_CONVERT_STRUCTURED_OUTPUT` (default **False**) pending a live constrained-decoding smoke-test in staging. The robustness fixes that motivated the work — `_extract_text_from_response` (skip non-text blocks, log `max_tokens`/`refusal`, raise on no-text) — live in the shared provider, so **all four** callers already benefit regardless of schema adoption.
**Rejected:**
- **Forcing schemas on ai_fix / flow-gen.** Their outputs are recursive/nested decision trees; a bounded-depth schema would reject valid deeper trees and break generation. Wrong architecture for marginal/zero benefit (flow-gen's tree is stored as a blob, never schema-validated downstream).
- **Wiring the flywheel enhancement site.** Flat and technically expressible, but low call frequency and only fence-stripping today — marginal benefit against the risk of a blind (un-live-tested) `additionalProperties: false` schema.
- **Deleting the fence-strip / repair helpers now.** `_strip_markdown_fences` / `parse_llm_json` must stay — they protect the recursive paths that can't use schemas. Only `_try_repair_json` (kb-only) becomes removable, and only *after* the flag is validated in staging.
**Consequences:**
- Structured outputs is the tool for flat JSON; recursive decision-tree outputs are excluded by design. New flat-JSON `generate_json` callers can opt in via `schema=`; recursive ones should not.
- `AI_KB_CONVERT_STRUCTURED_OUTPUT` must be smoke-tested against the live model (both target types) before production enablement. Open risk: whether Anthropic accepts optional (non-`required`) fields — if not, the schemas need every field in `required` with nullable types. The flag makes this fully reversible.
- Deferred cleanup: once the flag is validated, remove only `_try_repair_json` from the kb_conversion Anthropic path; leave the fence-strippers.
- Work lives on branch `feat/ai-structured-outputs` (commits `84a02a5`, `1388357`), based on `design/l1-workspace`.
---
## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override
**Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight.
**Decision:** Two-window model snapshotted into the refresh JWT at login. Defaults to Strict (3-day idle, 14-day absolute), bounded by env-var system min/max. Per-account override via two new `accounts` columns (NULL = use system default). Owner-only `GET/PATCH /accounts/me/security` endpoint with effective-value validation (partial-override case caught at the app layer because the DB CHECK can't see Settings). Sibling `POST /accounts/me/security/revoke-sessions` for `all|others`-scoped bulk revocation. Frontend: Strict/Standard/Custom presets, active-users list (name + email + last-login-ago), differentiated SessionExpiryToast (idle = warning amber with "Stay signed in" → `/auth/refresh`; absolute = info cyan, informational only), cyan info-tone banner on `/login?reason=session_expired`, auto-redirect after scope=all bulk-revoke. Error-detail taxonomy on the wire: `session_expired_idle`, `session_expired_absolute`, `invalid_refresh_token`. Grandfather path: legacy refresh tokens (no `auth_time` claim) get one free rotation under the new policy. Atomic-revoke-then-check on `/auth/refresh` so absolute-expired tokens can't be replayed.
8 commits on `feat/session-expiration-policy` branch (`92fa3bc``c7cd711`), ~1300 LoC backend + frontend including 28 backend tests. Plan + design review at `docs/plans/2026-05-13-session-expiration-policy.md` (initial design score 4/10 → final 9/10 via `/plan-design-review`; 7 design decisions locked).
**Rejected:**
- **Idle-only or absolute-only enforcement.** Idle without absolute is the current broken state (sliding forever). Absolute without idle is too strict — kicks users out daily.
- **Hard cutover on deploy (SECRET_KEY rotation).** Forces every pilot to log in again immediately; high support cost. Grandfather path is friendlier and adds ~50 lines of code.
- **Distinguish `session_revoked_by_admin` from `invalid_refresh_token` on the wire** for users whose sessions were killed via bulk-revoke. Requires tracking revocation reason per `refresh_tokens` row. Not worth the complexity for v1 — affected users see they're logged out, same as any other revoke.
- **Per-user device list with per-device revoke.** Refresh tokens don't carry device/user-agent metadata today. Account-wide bulk revoke covers the breach-response use case; per-device is a follow-up if pilots ask.
- **"Loose" preset (90d).** Strict default suggests we shouldn't ship a one-click loose option. Owners who want a loose policy can use Custom and own the choice explicitly.
- **Always-required `idle_minutes`+`absolute_minutes` (XOR-NULL invariant).** Forces owners who only want to override idle to also re-declare the absolute window, leaking the system default into account data. Partial overrides allowed; validated at the app layer against current defaults.
- **Reveal-on-Custom UI for the minute inputs.** Hidden-by-default-reveal-on-radio shifts page layout when Custom is selected. Always-visible-but-disabled is more stable and previews the Custom interaction.
- **Modal-stays-open-success-state for scope=all bulk-revoke.** User preferred auto-redirect-with-toast (more standard SaaS pattern); the toast acts as the success acknowledgment before /login loads.
**Consequences:**
- "Logged in forever" is fixed. Every user sees a hard 14-day re-auth at minimum (3-day idle in practice for typical usage).
- Account owners get a complete self-service surface for policy + bulk session control. New `/account/security` route, owner-gated.
- Audit-log entries on both mutations: `account.session_policy_update` and `account.sessions_revoked_bulk`. SOC2-ready.
- Frontend `idle_expires_at` + `absolute_expires_at` flow through the entire auth surface (`Token`, `OAuthCallbackResponse`, `authStore`, persistence). `useAuthSessionExpiry` hook is the single source for "is the session about to end."
- Future improvements (filed as follow-ups in plan §9): per-user device list (requires `refresh_tokens.last_used_at` column), super-admin global ceiling UI, per-user policy. None block current shipping.
- Cyan info-tone banner on `/login` is the first of its kind in the app; sets precedent for future neutral system messages.
---
## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover
**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode.
**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper:
- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag.
- `/auth/register` allows allowlisted emails to register without an invite code.
**Rejected:**
- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in.
- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then.
- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config.
**Consequences:**
- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag.
- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this.
- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover.
---
## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`)
**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer.
**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`:
1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers).
2. Rename the `plan_limits.plan='team'` row to `'enterprise'`.
3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support.
Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier.
New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms").
**Rejected:**
- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise``team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can.
- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice.
- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable.
- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price.
**Consequences:**
- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices.
- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones.
- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price.
- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename.
- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value.
---
## 2026-05-07 — Standardize backend Python on 3.12
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
**Rejected:**
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
**Consequences:**
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
---
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
**Rejected:**
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
**Consequences:**
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
---
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
1. `_build_escalation_package_enhanced` — runs in the background `enrich_escalation_async` task, builds a rich JSON payload that's saved to `ai_session.escalation_package`.
2. `_generate_ai_assessment` — also background, returns the magic-moment screen fields (`likely_cause`, `suggested_steps[]`, `confidence`).
3. `generate_status_update` — engineer-triggered when they click "Ticket Notes" / "Client Update" / "Email Draft" in the conclude modal, generates audience-specific PSA prose.
The user surfaced the smell: the engineer is *typically* generating a status update during the escalate flow, so the AI assessment work is being done twice with overlapping context and the engineer's PSA prose is being thrown away. Live test on 2026-04-29 also showed that bumping the assessment timeout 15s → 45s did NOT fix the empty-placeholder bug — meaning the architectural smell is also a demo blocker.
**Decision:** ONE structured AI call per escalation that produces a single payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Persist to `SessionHandoff`. The conclude modal's "Ticket Notes" button reads from the saved prose instead of calling the model. "Client Update" and "Email Draft" buttons trigger a cheap Haiku transformation over the saved prose (tone shift only, not a re-summarization).
Proposed payload shape (final form decided during implementation):
```json
{
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
"what_we_know": ["<one-liner>"],
"likely_cause": "<one sentence>",
"suggested_steps": ["<short step>"],
"confidence": "low | medium | high",
"audience_variants": {"client_update": null, "email_draft": null}
}
```
`audience_variants` filled lazily on first user request, cached.
**Rejected:**
- **Just bumping the timeout further.** Already tried 5s → 15s → 45s. The architectural redundancy is the real cost — even if Sonnet completed reliably, three calls per escalation is wasteful and creates three places where state can diverge.
- **Reusing the engineer's status update content as the AI assessment.** User's first instinct, but: status updates aren't always generated (engineer has to click), they're audience-specific (so you'd pick which one to copy), and they're prose without the structured fields the magic-moment screen needs. The right consolidation is the OTHER direction — generate ONE structured payload that the status-update buttons consume.
- **Switching the assessment to Haiku for speed.** Faster but solves only the latency symptom, not the redundancy. Doesn't help the conclude modal's status-update buttons.
**Consequences:**
- Magic-moment screen populates in ~5s instead of 25s+ (work happens in the foreground escalate path, not in a background task that races with the senior's pickup).
- Token spend per escalation drops by ~60% — one Sonnet call replaces two; the third (audience variants) becomes Haiku.
- Engineer's "Ticket Notes" button is instant — no model round-trip.
- Schema enforcement matters. The current `_generate_ai_assessment` returns freeform prose that the frontend stuffs into `assessment_text` because the structured fields aren't reliably parseable. The new call must use Anthropic's structured output / tool-use to enforce the schema.
- Migration concern: `ai_session.escalation_package` JSON column has live data on existing sessions. Keep it READABLE for backward compatibility; just stop *writing* the enhanced payload from `enrich_escalation_async`. If downstream queue summaries depend on it, dual-write the basic snapshot.
- Test fixtures (`test_handoff_manager.py`, `test_session_handoffs_api.py`) currently stub `_generate_ai_assessment` via `AsyncMock`. Updating the stubs is part of the rename.
- The frontend SSE assessment-ready subscription (added in `0f00ee5`) stays as-is — it just listens for the new event payload.
---
## 2026-04-28 — Tag the task-lane state with an owner chatId
**Context:** A recurring bug — every time the user returned to test escalation work, creating a new session would flash the previous session's task-lane data (questions, actions, "Tasks" pill counts) before the new session's AI response landed. The first attempt to fix it (`8914391`) added initializer-time guards (`incomingPrefill || isPickup`) that skipped the sessionStorage restore on mount. That covered exactly two entry paths and missed every other case: in-place URL navigation, mid-flight pickup, HMR re-runs, and the gap between `setActiveChatId(B)` and the AI response that finally populates B's questions/actions. The persistence effect made it worse by writing `{chatId: activeChatId, questions: activeQuestions}` — at any moment where activeChatId had flipped before the questions were updated, sessionStorage was stamped with `{chatId: B, questions: [A's data]}` and a subsequent restore would happily render A's data for B.
The root cause was that `activeQuestions` / `activeActions` / `showTaskLane` were three independent state slices implicitly assumed to be in sync with `activeChatId`. The synchronization was by convention, not by structure. Every code path that mutated them had to remember to call `resetSessionDerivedState` first; missing one created stale UI.
**Decision:** Add a `taskLaneOwnerChatId` state that records *which chatId the in-memory questions/actions belong to*, set at every site that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix), cleared in `resetSessionDerivedState`. The persistence effect writes ownerChatId as the chatId tag. Render is gated on `taskLaneOwnerChatId === activeChatId` and ANDed into all three render conditions (toolbar Tasks button, narrow-viewport floating drawer, main side panel). The mount-time `skipTaskLaneRestore` guard stays as belt-and-braces for the prefill/pickup entry-flash window, which the owner-gate alone doesn't cover.
**Rejected:**
- **More entry-path guards.** That's whack-a-mole — the next path nobody anticipated will reproduce the bug. The owner-gate makes the bug structurally impossible regardless of which path triggers it.
- **Combining the four state slices into a single tagged object.** Cleaner long-term but a bigger refactor with more touch points. The owner-tracking approach gets the structural guarantee with a minimal diff and keeps the existing setState patterns.
- **Inlining the comparison at every render site.** Works but proliferates the comparison; one named derived value (`taskLaneIsForActiveChat`) reads better and groups the gate with the persistence-effect / state declarations as a named concept.
**Consequences:**
- Stale task-lane data is structurally unable to display. The lane is hidden during any window where `ownerChatId !== activeChatId`, no matter what mutation path got you there.
- Adding new sites that populate `activeQuestions` / `activeActions` requires also setting `taskLaneOwnerChatId`. The pattern is documented in the commit message and visible in every existing populate site as a paired call.
- The mount-time `skipTaskLaneRestore` guard is now redundant in steady-state but kept for the few-hundred-ms flash window between component mount and the first sendPrefill / selectChat effect. Deleting it would re-introduce a (smaller) flash without strong reason.
- Future task-lane state slices (e.g. `facts`, `activeFix`) follow the same pattern: gate their visibility on the owner check via the existing render conditions. Tagging more slices with their own `*OwnerChatId` is a future refactor if the slices diverge.
---
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.
**Decision:** Split the old CLAUDE.md into `.ai/PROJECT_CONTEXT.md` (stable repo truth), agent-specific root files (`CLAUDE.md`, `AGENTS.md`) with a shared protocol block, and a small handoff toolkit (`CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`). Previous CLAUDE.md snapshotted in commit `e110fed` before the migration.
**Rejected:**
- Single symlinked CLAUDE.md/AGENTS.md — diverges silently, hides agent-specific tooling differences.
- Putting GitNexus/gstack content in AGENTS.md — Codex doesn't have those tools; would mislead the resume agent.
- Keeping the old CLAUDE.md as-is and adding AGENTS.md alongside it — duplicated truth, drift guaranteed.
**Consequences:**
- First read for either agent: `.ai/PROJECT_CONTEXT.md` + `.ai/CURRENT_TASK.md` + `.ai/HANDOFF.md`.
- Architectural changes in the repo require updating PROJECT_CONTEXT.md, not the root agent files.
- Git trailers differ per agent (`Claude Opus 4.7` vs `Codex`) — preserved in each root file.
- Legacy `SESSION-HANDOFF.md` deleted in the same commit; superseded by `.ai/HANDOFF.md`.

View File

@@ -1,68 +0,0 @@
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
# HANDOFF.md
**Last updated:** 2026-05-14
**Active task:** Phase O cutover for self-serve signup. All code blockers remain closed on `main`. **Still blocked on Stripe live-mode activation — root cause is EIN, not code.** User does not yet have an EIN for ResolutionFlow, LLC; Stripe requires a tax ID for live-mode activation. EIN application via IRS.gov was scheduled for 2026-05-13 — confirm status at next session start. Mailing-address decision (carried forward from 2026-05-12): user enters home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` mailing-address TODOs stay "available on request" until the P.O. Box is purchased. Stripe accepts an address update later without re-verification. Apex DNS at Namecheap is still missing (separate user-side issue, only matters once Stripe runs site-verification). Nothing on the code side blocks live-mode flip.
**Bug-pending-capture item (2026-05-12) — likely resolved:** Prior session noted "user reported finding a bug, will send screenshot next session." This session surfaced two concrete UX bugs that were fixed and merged (PR #168): the dashboard "Start a session" CTA was a dead link, and welcome step-2's PSA setup had a near-invisible "Connect now →" link that didn't even persist `primary_psa`. **Confirm with user at next session start whether the screenshot bug was one of these or something else still pending.**
## Where this session ended
Two PRs merged into main:
- **PR #166** (`fe0e692`) — docs/handoff doc updates from prior session. Squash-merged 2026-05-14.
- **PR #168** (`3a35121`) — session expiration policy + dashboard NextStep CTA fix + welcome step-2 PSA CTA reshape. Merge-committed 2026-05-14. Three notable additions:
- `feat(dashboard)` `8d79dd9` — The "Start a session" CTAs on NextStepCard and SetupChecklist used to `Link`-navigate to `/`, leaving the user on the same page (the StartSessionInput lives on the dashboard) with no visible response. Replaced with a `FOCUS_START_SESSION_EVENT` window event the StartSessionInput listens for: scrolls input to viewport top (`scrollIntoView({block:'start'})`), focuses the textarea (with `preventScroll:true` so it doesn't fight the smooth scroll), pulses a `rgba(96,165,250,…)` ring for 900ms. NextStepCard hides itself via local `locallyHidden` state on click so the user isn't double-prompted while typing. SetupChecklist gets the same event-dispatch treatment for its `ran_session` row.
- `feat(welcome)` `dc88797` — Welcome step-2 PSA CTA reshaped. Selecting a real PSA now swaps the single Continue + tiny "Connect now →" link for an explicit two-button choice: `Connect <PSA> now` (primary, blue — saves `primary_psa` then routes to `/account/integrations`) and `Connect later` (secondary outlined — saves `primary_psa` then continues to step 3). **Important pre-existing bug fixed**: the old subtle Link never actually persisted `primary_psa` before navigating away. Both new buttons do. "No PSA yet" and no-selection states still show the original single Continue. Skip-this-step and Skip-the-rest unchanged. Existing tests pass without edits (testids `welcome-step-2-connect-now` and `welcome-step-2-continue` reused).
- `docs:` `e5b2624` — added `docs/plans/2026-05-13-public-landing-routing-refactor.md`, `docs/architecture/` reports (god-node map + report 2026-05-06, workflows.json/html, workflows-analysis.html), `docs/tutorials/build-a-page.md`, and `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` at repo root.
`tsc --project tsconfig.app.json --noEmit` clean across all changes. Local vitest blocked by root-owned `node_modules/.vite-temp` (same env issue noted in prior handoffs); CI ran the suite green.
**Two issues filed for session leftovers:**
- **Issue #171** — Test coverage for the new welcome step-2 "Connect now" path (existing tests still pass but don't exercise the new button's save + redirect-to-integrations behavior).
- **Issue #172** — Repo hygiene: gitignore `core.[0-9]*` + `**/.remember/`, and delete the existing 20MB core dumps (`core.144926`, `core.145678`, `docs/architecture/core.1392564`) and `docs/architecture/.remember/`. Carried forward across multiple sessions.
Working tree clean except those persistent untracked items (intentionally left for issue #172).
Single alembic head: `4ce3e594cb87` (no schema changes this session).
## Resume point
**First thing next session:**
1. Confirm with user whether the "bug-pending-capture" screenshot bug from 2026-05-12 was one of the two PR #168 fixes or something else still pending.
2. Check EIN application status (filed 2026-05-13 via IRS.gov). If granted, unblocks the Phase O Stripe live-mode setup chain.
After that — **Phase O manual ops, all user-side, all gated on EIN landing first:**
1. **EIN application status check** (user, applied 2026-05-13).
2. **Stripe Dashboard live-mode** (once EIN is in hand):
- 3 Products (Starter, Pro, Enterprise). Monthly Prices for Starter ($19.99) + Pro ($29.99). No Prices on Enterprise (sales-led).
- Customer Portal with plan-switching disabled.
- Webhook at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with 5 events. Save live signing secret.
- **Business profile fields**: Customer service URL `https://resolutionflow.com/contact`. Refund/cancellation policy URL `https://resolutionflow.com/policies`. Terms `https://resolutionflow.com/terms`. Privacy `https://resolutionflow.com/privacy`. Phone `(470) 949-4131`. Mailing address = user's home address temporarily (private Stripe field; swap to P.O. Box later without re-verification). EIN = the newly-issued tax ID.
3. **Apex DNS fix at Namecheap** (re-add `@` ALIAS → `c9g7uku8.up.railway.app`, or re-add apex as a Railway custom domain). Becomes the next blocker once Stripe runs site-verification.
4. **Railway prod env**: `STRIPE_SECRET_KEY=sk_live_...`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` + `VITE_STRIPE_PUBLISHABLE_KEY` (frontend redeploy required — Vite bake-at-build, Lesson 60), `OAUTH_REDIRECT_BASE=https://resolutionflow.com`, `SELF_SERVE_ENABLED=false` (still false at this point), `INTERNAL_TESTER_EMAILS=<allowlist>`, prod Google + Microsoft OAuth credentials.
5. **Bootstrap prod super-admin** via `create_site_admin.py` (PR #167) — already done end-to-end on prod per 2026-05-12 user confirmation. Re-runnable if needed.
6. **Sync Stripe → DB**: `railway run python -m scripts.sync_stripe_plan_ids` (or via `railway ssh`). Verify `plan_billing` rows have `sk_live_*` price IDs.
7. **Internal validation (Phase O Task 46)**: 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
8. **Flag flip (Task 47)**: email pilots, set `SELF_SERVE_ENABLED=true` + `VITE_SELF_SERVE_ENABLED=true` (frontend redeploy). PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors.
## Open issues from prior session (non-code, user-side)
- **Apex DNS missing.** `resolutionflow.com` (apex) returns no A/CNAME at the authoritative DNS (Namecheap). When `www` was reconfigured in Railway, the apex record got dropped from the zone. `www` works (cert provisioned 2026-05-08 01:40 UTC). User to re-add apex record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`) or re-add the apex as a Railway custom domain. Railway path is more durable.
- **Edge HSTS sticky state on user's machine.** Browser remembers the earlier broken-cert visit. Fix: `edge://net-internals/#hsts` (delete `resolutionflow.com` and `www.resolutionflow.com`) + `#dns` clear host cache + `#sockets` flush.
## Carry-forward
- Annual pricing intentionally NOT implemented — user wants exit flexibility. Schema columns preserved as nullable. `sync_stripe_plan_ids.py` leaves annual fields NULL.
- `INTERNAL_TESTER_EMAILS` parsed comma-separated → normalized lowercase list. Anonymous callers always see the global flag — allowlist never leaks via unauthenticated request content (regression test enforces).
- Office-hours design doc now at `docs/` root (`abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`) as of this session. NOT yet adopted as roadmap — gated on 3 cold calls with external Directors of Onboarding.
- Mailing address fill-in: search for `TODO: replace with full mailing address` in `frontend/src/pages/ContactPage.tsx` and `frontend/src/pages/PoliciesPage.tsx` (one each) once P.O. Box is purchased.
- `backend/scripts/create_site_admin.py` is the durable site-admin bootstrap tool — idempotent. Three modes: `--send-reset`, `--print-reset`, `--promote-only`. Run from inside the deployed backend container via `railway ssh`.
- Bot-crawlability of legal pages: still SPA-rendered. Stripe didn't enforce content scraping last time (issue was DNS). If a future vendor review flags it, pre-render with `vite-plugin-prerender-spa` (~half day).
- Frontend env additions for cutover: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`, `VITE_STRIPE_PUBLISHABLE_KEY`.
- **Branch hygiene note (process learning):** PR #168 ended up bundling unrelated work — session expiration policy (the original scope of `feat/session-expiration-policy`) plus dashboard CTA fixes plus welcome step-2 reshape. The mixed scope was deliberate (user wanted it on the same PR), but worth flagging for future PRs: if onboarding-UX work continues, branch it separately from auth/session work.

View File

@@ -1,263 +0,0 @@
# PROJECT_CONTEXT.md — ResolutionFlow
> SaaS troubleshooting platform for MSPs. Stable architectural truth. Updated only when the repo's shape changes.
---
## Product & naming
Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
---
## SaaS shape
Multi-tenant by account. Primary role hierarchy: `super_admin` > `owner` > `engineer` > `viewer` — driven by `is_super_admin` + `account_role`. Never `role=='admin'` — use `is_super_admin`. Separate team-scoped admin gate exists orthogonally to the role hierarchy: `is_team_admin=True` + valid `team_id`, enforced by `require_team_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`, `require_account_owner`, `require_team_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
---
## Status
Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See [CURRENT-STATE.md](../CURRENT-STATE.md) for live status, [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) for phases.
---
## Tech stack
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
---
## Project structure
```
resolutionflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry
│ │ ├── api/endpoints/ # 50+ routers registered in api/router.py — auth/admin, trees/sessions, AI/chat, scripts, integrations, uploads, accounts, FlowPilot, etc.
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
│ │ ├── api/router.py # registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
│ │ ├── schemas/ # Pydantic
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, exceptions, registry, ticket_context, types)
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
│ │ └── services/knowledge_gap_service.py
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
│ └── tests/ # pytest integration
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios client + endpoint modules
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
│ │ ├── pages/
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
│ │ └── types/
│ └── (Tailwind v4 CSS-only config in src/index.css)
├── docs/plans/archive/ # pre-March 2026 plans
├── docs/connectwise/ # CW API reference + best-practices guides
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
├── .ai/ # dual-agent handoff system (see .ai/README.md)
├── CLAUDE.md · AGENTS.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
```
---
## Dev commands
Full setup in [DEV-ENV.md](../DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
```bash
docker compose -f docker-compose.dev.yml up -d # start stack
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
cd frontend && npm run dev
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
cd backend && alembic upgrade head # migrate
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
cd frontend && npm run build # stricter than tsc --noEmit — final check
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
python -m scripts.seed_trees # seed (from backend/)
```
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
**On hosts without native `python`/`node`/`npm`** (e.g. the code-server LXC), run commands inside the already-running containers instead:
```bash
docker exec resolutionflow_backend pytest --override-ini="addopts="
docker exec resolutionflow_backend alembic upgrade head
docker exec -w /app resolutionflow_frontend npm run build
docker exec -w /app resolutionflow_frontend npx tsc -b
```
---
## URLs & test users
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
---
## CI
Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
---
## Deployment (Railway)
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
---
## ConnectWise PSA
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
---
## Coding standards
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Commit format: `type: description` (feat/fix/refactor/docs/test/chore). Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly. (Agent-specific `Co-Authored-By` trailers live in CLAUDE.md / AGENTS.md.)
**After shipping:** update [CURRENT-STATE.md](../CURRENT-STATE.md) + [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md), `gh issue close #N` for resolved issues, add lessons only for non-obvious traps (otherwise let the code speak).
---
## Common tasks
- **New endpoint:** `endpoints/``router.py``schemas/` → tests → frontend API client.
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx``AccountLayout` has NO sidebar nav.
---
## Design system
**Source of truth: [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md).** Read before any visual change.
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
- Text: `text-heading``text-primary``text-muted-foreground``text-muted`.
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
- Mockups: `docs/mockups/` (HTML).
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
---
## Frontend patterns
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage``/ai-sessions/{id}/chat``unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
- **Node field priority** (copilot, summaries): `title``question``description``content``label`.
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
---
## Critical lessons
> Lessons 1-40 archived to [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
### Backend / data
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
### AI / FlowPilot
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
### Frontend / UI
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
### Env / infra
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
- **Dev env** — see [DEV-ENV.md](../DEV-ENV.md) for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
---
## Quick reference
| What | Where |
|---|---|
| Detailed status | [CURRENT-STATE.md](../CURRENT-STATE.md) |
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) |
| Design system | [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md) |
| Dev env | [DEV-ENV.md](../DEV-ENV.md) |
| Archived lessons | [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) |
| ConnectWise API | `docs/connectwise/` |
| GitHub issues | `gh issue list --state open` |
| Local API docs | <http://localhost:8000/api/docs> |
| Handoff system | [.ai/README.md](README.md) |

View File

@@ -1,42 +0,0 @@
# .ai/ — dual-agent handoff system
ResolutionFlow uses two coding agents: **Claude Code** (primary) and **OpenAI Codex** (resume when Claude hits session or weekly limits). This directory holds the shared state that lets either agent start a session with full context.
## Files
| File | Holds | Written when | Read when |
|---|---|---|---|
| [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) | Stable repo truth: stack, structure, SaaS shape, ConnectWise, coding standards, frontend patterns, critical lessons | Only when the repo's shape changes | Every session start |
| [CURRENT_TASK.md](CURRENT_TASK.md) | The single active task: goal, DoD, assumptions, out-of-scope | On task start; status updates during work | Every session start |
| [HANDOFF.md](HANDOFF.md) | Exact resume point: branch, where you left off, next steps, blockers | On session end / context-window limit | Every session start (most important) |
| [TODO.md](TODO.md) | Backlog of work NOT currently active | When deferring or queueing work | Only when `CURRENT_TASK.md` is `complete` |
| [DECISIONS.md](DECISIONS.md) | Append-only architectural decision log | When an architectural choice is made | Skim top entries each session |
| [SESSION_LOG.md](SESSION_LOG.md) | Append-only chronological history | On session end | Only when broader context is needed |
Agent-specific tooling lives at the repo root:
- [../CLAUDE.md](../CLAUDE.md) — Claude Code's tooling (GitNexus, gstack slash commands, Claude trailer)
- [../AGENTS.md](../AGENTS.md) — OpenAI Codex's tooling (grep/rg fallbacks, Codex trailer)
Both root files contain an **identical shared-protocol block**. If you edit one, edit the other.
## The handoff ritual
At session end (limit hit, task complete, or user stop): update `HANDOFF.md` to reflect the new resume point, update `CURRENT_TASK.md` status if it changed, append to `DECISIONS.md` if you made an architectural call, append a session entry to `SESSION_LOG.md`, and WIP-commit any dirty working tree with `wip(handoff): <one-line>` unless told otherwise. Don't push.
## How to invoke a resume
Tell the agent:
> Read CLAUDE.md (or AGENTS.md) and follow its instructions.
The agent will read its root file, which directs it to `.ai/PROJECT_CONTEXT.md`, `.ai/CURRENT_TASK.md`, and `.ai/HANDOFF.md` before doing anything else.
## Recovery
The previous monolithic CLAUDE.md is recoverable via:
```bash
git show pre-ai-handoff:CLAUDE.md
```
(Tag `pre-ai-handoff` on commit `e110fed` — the snapshot taken before this migration.)

View File

@@ -1,467 +0,0 @@
# SESSION_LOG.md
> Append-only chronological record. Newest entries at the top. Skim when broader context is needed.
> Entry format:
>
> ```
> ## YYYY-MM-DD HH:MM <timezone> — <agent> — <one-line summary>
> - What was accomplished
> - What was left for next session
> - Files touched
> ```
---
## 2026-05-14 ~04:00 UTC — Claude — PR #166 + #168 merged; dashboard CTA bug fixed; welcome step-2 PSA CTA reshaped
**Accomplished:**
- User reported the "Start a session" CTA on the dashboard onboarding card doing nothing after completing the welcome wizard. Root cause: `NextStepCard.tsx:80-82` had `ctaPath: '/'` and the card itself only renders on the dashboard at `/`. Clicking `<Link to="/">` while already on `/` is a react-router no-op. Same dead-link in `SetupChecklist.tsx` for the `ran_session` row.
- Designed and built the fix collaboratively (user wanted scroll-to-input + visual pulse rather than auto-navigate to `/pilot` or just hiding the card):
- Added `FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'` window event exported from `StartSessionInput.tsx`. The component listens via `useEffect`, on dispatch calls `wrapperRef.current?.scrollIntoView({behavior:'smooth', block:'start'})`, focuses the textarea with `preventScroll:true` (so it doesn't fight the smooth scroll), and sets a 900ms `nudge` state that swaps the inner wrapper's `focus-within:` ring classes for a louder `ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]`. Added `scroll-mt-6` to the outer ref'd div so the input doesn't hug the very top edge.
- `NextStepCard.tsx` — branched on `next.key === 'ran_session'`. Render a `<button>` that dispatches the event AND sets a new `locallyHidden` useState so the card disappears immediately on click (without calling the persisting `dismissOnboarding` API — that would kill all future onboarding nudges). All other CTAs keep the original `Link` element. Tests pass without changes (assertions only check text + testid).
- `SetupChecklist.tsx` — same `ran_session` branch (the checklist had the same dead-link bug if the user expanded "Show all setup steps").
- User then asked about the welcome wizard PSA flow — "is it supposed to take me to set up ConnectWise if I keep clicking next after picking it?" Read `WelcomeStep2.tsx`: the spec was intentionally "just pick what you use, we'll wire it up later" with a `text-xs text-muted-foreground` "Connect now →" link as the only credential-setup entry. The link was visually near-invisible AND had a bug: it was a `<Link to="/account/integrations">` that navigated WITHOUT calling `onboardingApi.updateStep`, so `primary_psa` was never persisted if the user clicked it.
- Proposed three fix options; user picked option 2 (explicit two-button branch). Implemented in `WelcomeStep2.tsx`:
- New `handleConnectNow` handler that calls `onboardingApi.updateStep({step:2, action:'complete', data:{primary_psa}})` then `navigate('/account/integrations')`. New `submitting === 'connect-now'` state value.
- When `showConnectNow` (real PSA selected): action row renders `[Connect <PSA> now (primary)] [Connect later (secondary)] [Skip this step (tertiary)]`. Reused the old `welcome-step-2-connect-now` testid on the new primary button. "Connect later" reuses the `welcome-step-2-continue` testid + handleContinue. PSA label derived dynamically from `PSA_OPTIONS`.
- When 'none' or no selection: original `[Continue] [Skip this step]` preserved.
- Removed the import of `Link` from `react-router-dom` and the entire `showConnectNow && <Link>` block.
- All existing tests pass unchanged (`tsc --noEmit` clean, locally; vitest blocked by root-owned `node_modules/.vite-temp` — same env issue noted previously; CI ran the suite green on the PR).
- Committed in two logical commits onto current branch (`feat/session-expiration-policy`): `feat(welcome): two-button PSA CTA in step-2` (`dc88797`) and `docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design` (`e5b2624`). Pushed. PR #168 CI ran green across `CI/backend`, `CI/frontend`, `CI/e2e`. PR #166 merged first (HTTP 200), then PR #168 once CI cleared (HTTP 200). `main` now at `3a35121`.
- Filed two issues for session leftovers:
- **#171** — Test coverage for the new `welcome-step-2-connect-now` path (existing tests still pass but don't exercise the new save + redirect behavior).
- **#172** — Repo hygiene: add `core.[0-9]*` and `**/.remember/` to `.gitignore`, delete the three 20MB core dumps + `docs/architecture/.remember/`.
**Left for next session:**
- Confirm with user whether the "bug-pending-capture" item from 2026-05-12 HANDOFF was one of the two fixes above (dashboard CTA dead-click, welcome step-2 ConnectWise confusion) or a third bug still pending. Likely covered, but worth asking.
- Phase O cutover remains gated on EIN — check status of 2026-05-13 IRS.gov application.
- Issues #171 and #172 sitting in the backlog when there's time.
**Files touched (all merged to main via PR #168 `3a35121` and PR #166 `fe0e692`):**
- `frontend/src/components/dashboard/StartSessionInput.tsx` (event listener, scroll/focus/nudge ring)
- `frontend/src/components/dashboard/NextStepCard.tsx` (event-dispatch button branch, `locallyHidden` state)
- `frontend/src/components/dashboard/SetupChecklist.tsx` (event-dispatch button branch for `ran_session` row)
- `frontend/src/pages/welcome/WelcomeStep2.tsx` (two-button PSA CTA + `handleConnectNow`)
- `docs/plans/2026-05-13-public-landing-routing-refactor.md` (new, untouched by Claude this session — user-authored)
- `docs/architecture/{god-node-map-2026-05-06.canvas, god-node-report-2026-05-06.md, workflows-analysis.html, workflows.html, workflows.json}` (new, generated reports)
- `docs/tutorials/build-a-page.md` (new, user-authored)
- `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (root, office-hours design doc — committed as-is from prior local state)
- `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` (this update)
---
## 2026-05-12 ~06:30 UTC — Claude — PR #167 (site-admin bootstrap script) merged; bug pending capture
**Accomplished:**
- User reported being unable to log into prod with `admin@resolutionflow.example.com` — that's the dev seed email (`.example.com` is a documentation TLD), only present in dev. Prod has no admin user at all because `seed_test_users.py` doesn't run in prod, self-serve is still gated, and even when it flips on signup creates `owner` roles not `super_admin`.
- Designed and built `backend/scripts/create_site_admin.py` — idempotent CLI script for creating or promoting a site-wide super-admin on any environment. Three modes: `--send-reset` (mails reset link), `--print-reset` (stdout reset link), `--promote-only` (promote existing user without creating). Creates an `Account` first, then a `User` with `is_super_admin=true`, `account_role='owner'`, `email_verified_at` stamped at creation, `password_hash=NULL` (forces the reset flow on first login). Uses `ADMIN_DATABASE_URL` (BYPASSRLS) — required because `users` is RLS-enabled and the script has no tenant context at bootstrap. Reset token mints via existing `create_password_reset_token` helper, hashes JTI into `password_reset_tokens` row matching the `/auth/password/forgot` shape.
- Smoke-tested all three paths in the dev container before pushing: fresh create on a new email (Account + User + reset URL emitted), idempotent re-run on same email (SKIP message + new reset URL), `--promote-only` on a user with `password_hash=NULL` (promotes + issues reset). Cleaned up the dev test row + account afterwards.
- Initial bug: had `used: false` in the `password_reset_tokens` INSERT — actual column is `used_at` (nullable timestamp, NULL means "not used"). Fixed before pushing.
- PR #167 opened, CI green, squash-merged into main as `e50a215`. Remote branch `feat/site-admin-script` auto-deleted.
- User confirmed end-to-end success on prod via `railway ssh --service=<backend>` then `python -m scripts.create_site_admin ...` ("we're good now"). Specific service name not captured. First prod super-admin row now exists in the prod DB.
- Stripe live-mode activation block traced to EIN, not code (user does not yet have an EIN for ResolutionFlow, LLC). Applying via IRS.gov 2026-05-13. Mailing-address decision: home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` stays "available on request". Stripe accepts address update later without re-verification.
- PR #166 (docs handoff for PR #164/#165 merges + EIN decision) still open from earlier in this same session — was never merged. This entry rebases the docs branch onto current main (which now includes PR #167) and adds the PR #167 narrative + bug-pending state so a fresh session has the full picture in one merge.
- User reported finding a bug in a UI surface but did not provide details — planning to send a screenshot via the VS Code extension GUI in the next session (CLI is unreliable for them). Next session: ask for the screenshot at session start, then triage.
**Left for next session:**
- Get the bug screenshot from the user, triage, fix or scope.
- Otherwise everything that was on the prior entry's left-for-next-session still stands: EIN application Tuesday 2026-05-13, then Stripe live-mode setup, apex DNS at Namecheap, Railway prod env vars, internal validation, flag flip.
**Files touched (all merged to main via PR #167 squash `e50a215`):** `backend/scripts/create_site_admin.py` (new, ~270 lines including docstring). Plus `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` on `docs/handoff-pr-165-merge` (PR #166, awaiting merge).
---
## 2026-05-12 05:30 UTC — Claude — PR #164 + #165 merged; Stripe activation reported blocked
**Accomplished:**
- Resumed from compacted context. Confirmed PR #164 (`feat/billing-plan-taxonomy`, head `2c9f5e9`) was already CI-green at session start and squash-merged into main as `3f04911` earlier in the session (occurred pre-compaction; reflected in the prior HANDOFF revision). Branch auto-deleted on remote.
- User raised the legal/contact pages question in conversation. Verified existing state of `frontend/src/pages/{PrivacyPage,TermsPage}.tsx` — both already contain real, dated content (last updated 2026-03-21) but are SPA-rendered. Discussed Stripe's site-review needs with the user and agreed to build a consolidated Customer Policies page plus a Contact page (now that the user has a business phone number) plus a Promotions stub to satisfy Policies §6.2 cross-reference. User authorized the work.
- Built PR #165 (`feat/stripe-legal-pages`, head `545b2ad`):
- **`/policies``frontend/src/pages/PoliciesPage.tsx`** (new). Consolidated Customer Policies doc, 8 sections with anchor IDs per subsection so Stripe (or a support email) can deep-link: customer service contact (with phone (470) 949-4131), return policy (n/a — SaaS), refund / dispute policy, cancellation policy, U.S. legal and export restrictions (Georgia governing law, OFAC / BIS compliance, sanctioned-jurisdiction exclusion), promotional terms (general + cross-ref to `/promotions`), changes-to-policies, relationship-to-other-agreements. Mailing address left as in-source `TODO` comment, rendered publicly as "available on request — email support@" until P.O. Box is purchased.
- **`/contact``frontend/src/pages/ContactPage.tsx`** (new). Phone **(470) 949-4131**, all four inboxes (`support@`, `sales@`, `billing@`, `security@`), response-time SLAs, mailing-address placeholder, link to `/contact-sales` for the lead-gen Calendly flow (distinct surface — kept both routes intentionally).
- **`/promotions``frontend/src/pages/PromotionsPage.tsx`** (new). One-paragraph stub stating no promotions currently active. Will be appended to when offers run; satisfies Policies §6.2's cross-reference.
- Routes wired in `frontend/src/router.tsx` as 3 new public lazy-loaded routes alongside existing `/privacy`, `/terms`, `/pricing`, `/contact-sales`.
- **`MarketingFooter``frontend/src/components/common/MarketingFooter.tsx`** (new, second commit). Extracted from the inline landing footer (26 lines → 1 line at the call site). Mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy / Terms / Policies / Contact) are reachable from every marketing surface — including the page Stripe's reviewer spends the most time on (`/pricing`). Reuses existing `landing-footer*` CSS in `frontend/src/styles/landing.css` — must be rendered inside a `.landing-page` wrapper because `--lp-*` vars are scoped there (documented in a JSX comment). All three current call sites already wrap in `.landing-page`, so landing renders pixel-identically and the two new mount sites match.
- **Privacy and Terms closing sections** updated to point at `/contact` + `/policies` with correct per-area inboxes (`security@` for Privacy, `support@` for Terms). Stale `hello@resolutionflow.com` mailto removed everywhere.
- `tsc --project tsconfig.app.json --noEmit` clean, `eslint` clean. Local `vite build` and `tsc -b` blocked by root-owned `node_modules/.tmp` and `node_modules/.vite-temp` cache directories — CI rebuilds from a clean env and was green.
- PR #165 opened at `gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/165`, CI passed, squash-merged into main as `ba45cfe`. Remote branch `feat/stripe-legal-pages` auto-deleted.
- User reports continued trouble activating Stripe live mode. After follow-up: the real blocker is the EIN — ResolutionFlow, LLC does not have one yet, and Stripe requires a tax ID before it will activate live mode. User is applying via IRS.gov on 2026-05-13. Updated HANDOFF.md to remove the earlier speculation list and record EIN as the named blocker, with the P.O. Box / mailing address called out as the likely-next blocker (Stripe live-mode also requires a business mailing address). Apex DNS at Namecheap is still pending but only matters after the business profile is accepted (site verification is a downstream step).
- Mailing-address decision: user is going with the home-address-temporarily approach for Stripe so live-mode isn't blocked on the P.O. Box. Home address goes into Stripe's **private** business profile only — the **public** `TODO: replace with full mailing address` in `ContactPage.tsx` and `PoliciesPage.tsx` stays as "available on request" until the P.O. Box is purchased. Stripe accepts updating the address later without re-verification, so swapping in the P.O. Box when it arrives is non-disruptive.
**Left for next session:**
- Check in on whether the EIN application went through and whether the P.O. Box / mailing address is sorted. Both are pure user-side ops; no code work to do until Stripe accepts the business profile.
- Once Stripe is activated: Stripe Dashboard live-mode product/price/webhook setup, Railway prod env vars, `railway run python -m scripts.sync_stripe_plan_ids` against prod, 9-scenario internal validation, flag flip.
- Apex DNS at Namecheap (still missing; only matters once Stripe runs its site-verification step).
- Mailing address TODO in `ContactPage.tsx` and `PoliciesPage.tsx` (one each) — fill in when P.O. Box is purchased.
**Files touched (all merged to main via PR #165 squash `ba45cfe`):** `frontend/src/pages/ContactPage.tsx` (new), `frontend/src/pages/PoliciesPage.tsx` (new), `frontend/src/pages/PromotionsPage.tsx` (new), `frontend/src/components/common/MarketingFooter.tsx` (new), `frontend/src/router.tsx`, `frontend/src/pages/LandingPage.tsx`, `frontend/src/pages/PricingPage.tsx`, `frontend/src/pages/ContactSalesPage.tsx`, `frontend/src/pages/PrivacyPage.tsx`, `frontend/src/pages/TermsPage.tsx`. Plus `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` on the `docs/handoff-pr-165-merge` branch (this entry).
---
## 2026-05-08 03:30 UTC — Claude — PR #164 self-serve cutover code blockers, doc refresh, page-title bug, DNS triage
**Accomplished:**
- Merged PR #162 (self-serve Phase 2 frontend) and PR #163 (seed users email-verified) into main via Gitea API squash merge. Created branch `feat/billing-plan-taxonomy` off the new main; pushed 5 commits closing the last code blockers for Phase O cutover. PR #164 opened at gitea pulls/164.
- Plan taxonomy reconciliation. Discovered the marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`; `BillingPlan` schema's `Literal["pro","starter","team","enterprise"]` could accept FK-violating values; `plan_billing` was unseeded. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro (`max_trees=10`, `sessions=75`, `users=1`, `ai=15/mo`, no KB Accelerator, no custom branding, no priority support). Code rename across schemas (`invite_code`, `billing`, `admin`, `subscription`), `Subscription` paid-plan/`has_pro_entitlement` checks, `admin_dashboard.py`, `admin.py`, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain (means "shared with my account") and intentionally untouched. 86/86 passing across subscription/billing/plan/invite/admin sweep after the rename. Conftest plan_limits seed + `_seed_plan_limits` helper made a true upsert.
- New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`), picks active monthly recurring price, leaves annual fields NULL by design. Works against test or live keys via `STRIPE_SECRET_KEY`. Run against test mode populated `plan_billing` for all 3 tiers in dev DB. Annual pricing intentionally skipped per user's exit-flexibility constraint.
- Stripe MCP work (test mode, `livemode=false`): archived leftover Enterprise `$500/mo` test price (had to clear the product's `default_price` first — Stripe blocks archive otherwise). Verified test-mode product set: Starter $19.99/mo, Pro $29.99/mo, Enterprise no price (sales-led).
- `INTERNAL_TESTER_EMAILS` allowlist. Phase O Task 46 needed it as a code blocker (flagged in prior SESSION_LOG as "backend support is NOT yet built"). `Settings.is_internal_tester` (case-insensitive membership) + `is_self_serve_active_for(email)` (returns global flag OR allowlist hit) centralize the check. New `get_current_user_optional` dep — best-effort auth that returns `None` instead of 401, used by `/config/public` so the same endpoint serves anonymous and authed. `/config/public` returns `self_serve_enabled=true` for authenticated allowlist members; `/auth/register` allows allowlisted emails without invite code. 5 regression tests including "anonymous callers always see the global flag" (prevents leak via unauthenticated request content).
- Stripe env passthrough: `docker-compose.dev.yml` now wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` into the backend container. New repo-root `.env.example`. `backend/.env.example` updated with the self-serve cutover vars.
- Page-title bug fix on `LandingPage.tsx`. Two JSX attribute strings (`title="..."`, `description="..."`) had `—` (six literal characters) — JSX attribute strings don't process JS escape sequences, so the browser tab and OG description rendered the literal text instead of an em dash. Replaced with the literal em dash character. Verified by grep — every other `\u...` in the codebase is inside a real JS string (`'...'` literal or `{...}` JSX expression) where escapes resolve at compile time. PageMeta default tagline updated from stale "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs" (matches index.html and brand positioning).
- Frontend taxonomy followups (caught by tsc -b after rebuild). The earlier taxonomy commit didn't propagate through frontend types: `types/account.ts`, `types/admin.ts`, `types/billing.ts`, `admin/AccountsPage.tsx` (state type, select onChange cast, `<option value="team">` rendered UI), `admin/InviteCodesPage.tsx` (PLAN_OPTIONS array, state type, onChange cast), `AccountSettingsPage.tsx` (`plan !== 'team'` check + CheckoutButton prop), `subscription/CheckoutButton.tsx` (prop type + planLabels). All updated to `'free' | 'pro' | 'starter' | 'enterprise'`. tsc clean. Lint clean (3 warnings only in auto-generated `coverage/`).
- Doc refresh commit (`docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover`). CURRENT-STATE bumped to 2026-05-07; added entries for PR #159164; refreshed What's In Progress / What's Next around Phase O. ROADMAP got a "Status as of 2026-05-07" preamble (months-stale historical content kept underneath as record); In Progress and What's Next sections updated. README fixed legacy `patherly_postgres` Docker command, project-tree path, `UI-DESIGN-SYSTEM.md` reference; added `AGENTS.md`, `PROJECT_CONTEXT.md`, `PRODUCT.md` to docs table. DECISIONS appended two entries (taxonomy reconciliation, allowlist).
- Office-hours session ran via `/office-hours` skill earlier in this session. Design doc saved at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captured the "documentation builder" thesis — cut branching Flows from pilot UI, focus product around FlowPilot + Day 1 onboarding checklist as navigational frame + 3 deep-capture procedures (M365 tenant build, Windows server build, credential vault) + Hudu/IT Glue/ConnectWise output. Founder is a Director-of-Onboarding at his own MSP (Andrea Henry); pre-build assignment is 3 cold calls with external Directors of Onboarding before scoping. NOT yet adopted as roadmap.
- DNS / cert triage: `www.resolutionflow.com` was unreachable (Railway "train hasn't arrived" page) — user added it as a custom domain in Railway, cert provisioned at 2026-05-08 01:40 UTC, `www` now serves 200 with valid Let's Encrypt SAN. Apex `resolutionflow.com` separately discovered to have NO A/CNAME at authoritative DNS (Namecheap per SOA `dns1.registrar-servers.com.`). When user reconfigured `www`, the apex record dropped from the zone. From Railway-edge IP both names work fine when DNS is forced (proven by `curl --resolve` returning 200 OK from user's box) — so the apex cert is also valid; the failure mode is purely DNS-level absence. User asked for HSTS clearance steps in Edge — provided `edge://net-internals/#hsts`, `#dns`, `#sockets` walkthrough plus Linux DNS flush options.
**Left for next session:**
- Verify PR #164 CI green, then squash-merge.
- Phase O manual ops sequence (Stripe Dashboard live-mode setup, Railway prod env vars including `INTERNAL_TESTER_EMAILS`, run `sync_stripe_plan_ids.py` against prod, internal validation Task 46, flag flip Task 47, PostHog dashboards, Sentry alert).
- User-side: re-add apex DNS record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`, or re-add apex in Railway), clear Edge HSTS state.
**Files touched (all on `feat/billing-plan-taxonomy`, all pushed):** `backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py` (new), `backend/scripts/sync_stripe_plan_ids.py` (new), `backend/app/{schemas/{billing,invite_code,admin,subscription}.py, models/subscription.py, api/{deps.py, endpoints/{auth.py, admin.py, admin_dashboard.py, config.py}}, core/config.py}`, `frontend/src/{components/{common/PageMeta.tsx, subscription/CheckoutButton.tsx}, hooks/useSubscription.ts, pages/{LandingPage.tsx, AccountSettingsPage.tsx, admin/{AccountsPage.tsx, InviteCodesPage.tsx}}, types/{account.ts, admin.ts, billing.ts}}`, `backend/tests/{conftest.py, test_admin_plan_limits.py, test_invite_plan.py, test_plans_public.py, test_config_public.py}`, `docker-compose.dev.yml`, `.env.example` (new), `backend/.env.example`, `CURRENT-STATE.md`, `03-DEVELOPMENT-ROADMAP.md`, `README.md`, `.ai/{DECISIONS.md, HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md}`.
---
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
- Executed Tasks 2744 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
- Backend (Phase I, Tasks 2731): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
- Frontend (Phases JN, Tasks 3244): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
- Phase O (Tasks 4547) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
---
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
- Rebuilt `frontend/src/data/guides.ts` from scratch as 43 problem-oriented Diátaxis how-tos under 10 categories. Single-outcome each, terse imperative steps, real UI labels (Create New, Sign in, Manage, Build New Script, Send Invite, Save Settings, Create Category, etc.). Added `category: CategoryId` and optional `relatedSlugs?: string[]` to the `Guide` interface; new `Category` type and `categories` const drive the hub layout. `GuidesHubPage` now renders category sections (auto-hides empty); `GuideDetailPage` renders a Related guides footer; `GuideCard` lost its misleading "N sections" subtitle.
- Fixed `GuideSection.tsx`: `step.tip` was rendered as plain text so `**bold**` markdown in tips rendered literally. Applied the same regex replacement used on `step.instruction`. Verified against `/guides/start-a-session` tip block.
- Authored 14 net-new how-tos for FlowPilot-era surfaces with no prior coverage: tasklane-keyboard-flow, view-what-we-know, ask-ai-mid-session, pause-and-leave-session, resolve-a-session, record-suggested-fix-outcome, escalate-a-session, post-docs-to-ticket, send-client-update, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate. Dropped change-teammate-role from scope — couldn't verify the role-change UI control without a non-owner test member.
- Verified owner-only surfaces with `pro@resolutionflow.example.com`: Membership inline form on `/account` (not a separate `/team-members` route), `/account/categories` real button is **Create Category** (not Add), `/account/chat-retention` real fields are **Retention Period (days)** + **Max Conversations** + **Save Settings**, `/account/integrations` form fields confirmed. Three guides corrected post-audit.
- Smoke-tested all 43 detail pages — every slug renders, no "Guide Not Found" fallthroughs.
- Added `100.64.78.44 docker-01` entry to `/etc/hosts` (user ran `sudo tee` from a normal terminal because the LXC `!` shell prefix can't drive interactive sudo). Should now persist across `/browse` sessions on this LXC.
- `docker exec -w /app resolutionflow_frontend npx tsc -b` clean.
- Files touched: `frontend/src/data/guides.ts`, `frontend/src/pages/GuidesHubPage.tsx`, `frontend/src/pages/GuideDetailPage.tsx`, `frontend/src/components/guides/GuideCard.tsx`, `frontend/src/components/guides/GuideSection.tsx`, `CHANGELOG.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`. Working tree dirty — user not yet asked to commit.
---
## 2026-05-01 21:55 UTC — Claude — Session-screen impeccable pass + tasklane keyboard flow shipped (PR #158)
- Ran the `/impeccable` skill against the assistant chat session screen (chat history / chat bar / TaskLane). Initial design-health score: 24/40 with explicit DESIGN-SYSTEM violations (gradient surfaces in WhatWeKnow + ProposalBanner, side stripes in TaskLane done states + every banner mode, accent borderTop on lane header, backdrop blur on handoff overlay).
- Walked through all 5 impeccable sub-passes (distill, quieter, layout, typeset, polish). Score after pass: 33/40 (+9). Biggest gains in Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), Recognition Rather Than Recall (2→4).
- Inline iterations on top of the impeccable steps: linked banner ↔ script-panel lifecycle (collapse hides both, dismiss closes both, any outcome closes both); collapsible WhatWeKnow with `sessionStorage` memory + auto-collapse-at-5-facts; full keyboard flow on TaskLane (Enter submits + auto-advances, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last task).
- Side fix: `ParameterizationPreview` was over-highlighting short parameter values (a `"D"` lit up every capital D in `Get-ADUser`/`Add-Type`/etc.). Added a word-boundary guard, conditional on whether the value itself starts/ends with a word character so values with leading punctuation (`"D:\\Folder"`) still match cleanly.
- Followups logged in `.ai/TODO.md`: `ConcludeSessionModal` multi-select for paused/escalated outcomes (real feature work — engineers often need ≥2 of Ticket Notes / Client Update / Email Draft), and `bg-card-hover` Tailwind drift in `CommandPalette` (silently broken classes — two-line fix).
- Branched as `feat/session-distill-quieter`, 4 commits (impeccable pass, parameterize fix, TODO followups, hint contrast + font-sans audit). PR #158 created via Gitea API (`$GITEA_TOKEN` env, no `gh` on this LXC). Merged into `main` as `5e10005`. Local branch deleted.
- Validation at every commit boundary: `docker exec -w /app resolutionflow_frontend npx tsc -b`, `npm run lint`, and `npm run build` all clean.
- Files touched: 14 frontend files (TaskLane, AssistantChatPage, ChatMessage, ProposalBanner, WhatWeKnow, WhatWeKnowItem, SuggestedFlowCard, ChatSidebar, ConcludeSessionModal, ChatTabStrip, ActionCardGroup, AddNoteButton, ParameterizationPreview), `.ai/TODO.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `CHANGELOG.md`, `CURRENT-STATE.md`.
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
- Resolved a tooling gap surfaced by Codex's prior session ("npm/python/python3 are not on the host path") by documenting that this code-server LXC uses bun + docker for the toolchain. The `docker exec resolutionflow_{backend,frontend}` form is now the canonical command pattern in `.ai/PROJECT_CONTEXT.md`.
- Got `$B`/Playwright Chromium running in the code-server LXC. After the user's restart cleared the AppArmor unprivileged-userns block, Chromium still aborted at the deeper `sandbox/linux/services/credentials.cc` layer because of the LXC namespace constraint. Workaround: launch browse with `CONTAINER=1` so it auto-adds `--no-sandbox`. Also added `100.64.78.44 docker-01` to code-server's `/etc/hosts` (via `docker exec -u 0`) so the headless browser could resolve the bake-in `VITE_API_URL`.
- Drove `/qa` against the dev stack at `http://100.64.78.44:5173`. No naturally-occurring `applied_pending` fix existed in the DB, so seeded session `4a558056-bcbd-4b51-925b-248d70eb318d` and fix `cd4ff2fd-751a-4bcb-8cfa-3c77b4864fb2` into the test state (un-resolved session, swapped supersession on the two fixes). Saved a restore script first; verified DB matches pre-test state after teardown.
- QA result: 5/7 scripted checks PASS with concrete DB + UI evidence. Banner renders correctly ("Awaiting verification" header, "Parked" tag, fix title + pending_reason, 4 actions). "Update reason" updates server-side. "It worked" → `applied_success` with `verified_at` stamped. "Dismiss" → `dismissed` with no terminal timestamp. Page-level Resolve auto-patches `applied_pending``applied_success` before the resolution flow opens. Page-level Escalate fires `EscalateInterceptDialog` with the generalized "still needs an outcome" copy. 2 entry-path checks (VerifyingBanner overflow, nudge "Still checking") deferred because they require live AI-generated chat state to drive; the mutating handlers behind those entry paths are verified via the tested transitions. Report at `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`.
- Pushed `feat/fix-pending-verification`. Polled Gitea actions runs 161; required `CI / frontend` and `CI / backend` plus `CI / e2e` all green. Merged via Gitea API as a merge commit (`3ba4532`).
- Post-merge cleanup: fast-forwarded local `main`, deleted `feat/fix-pending-verification` locally and on the remote. Wrote handoff updates on `chore/post-156-handoff` matching the prior `chore/post-153-handoff` pattern.
- Files touched (this session): `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/SESSION_LOG.md`, `AGENTS.md`, `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`, `.gstack/qa-reports/screenshots/01-08*.png`. Plus the two prior-session-authored commits committed by this session (5 source + 3 `.ai/` notes).
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
---
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
- **All 7 QA scenarios passed:**
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
- "I'll take it from here": claim → dismiss overlay → composer focused.
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
- Task lane copy button: toast + checkmark visual feedback.
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
- Backend tests: 17/17 pass.
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
---
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
- **Pickup landed on a blank chat surface.** Root cause: commit `8914391` had made `activeChatId` initialize from `urlSessionId`, which broke the selectChat-gating effect in `AssistantChatPage` (`urlSessionId === activeChatId` short-circuited fresh mounts). Symptom was `selectChat` never firing post-claim; messages, conversation history, and pickup-flow correctness all silently broken.
- **Picked-up session missing from sidebar.** Root cause: `loadChats` runs once at mount; pre-claim the session's `escalated_to_id` is null (the junior didn't specify a target), so `listSessions` doesn't return it. Post-claim `claim_session` sets `escalated_to_id` to teamadmin, but the sidebar list never refreshes.
- Fixes (commit `0d1b305`):
- Replaced the `urlSessionId === activeChatId` gate with a `loadedChatIdsRef` set so selectChat fires once per URL session per page lifecycle, regardless of whether activeChatId already matches.
- Added `loadChats()` call in `handleStartHere` after the claim succeeds so the sidebar reflects ownership.
- Three additional pieces folded into `0d1b305` from the same QA bash:
- **Enter-to-submit on the escalate forms.** Chat-input convention: plain Enter submits, Shift+Enter inserts a newline. Added optional `onSubmit` prop to `RichTextInput` (used by `EscalateModal`) and inline `onKeyDown` on the plain textarea in `ConcludeSessionModal`. The user explicitly asked for this — they want to type the reason and hit Enter without reaching for the mouse.
- **Dashboard `PendingEscalations` rows expand to preview.** Click a row to reveal escalation reason + step count + confidence tier + PSA ticket number. Pick Up button click-stops to still go directly to magic moment. Single expansion at a time.
- **`ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` bumped 15 → 45.** Backend logs showed Sonnet hitting the 15s timeout in field testing. Background-task architecture (e8ba74e) means this no longer blocks the user — only bounds before publishing `has_assessment: false`. **Did NOT fix the live demo.** Assessment placeholder still permanent in user's test.
- Surfaced an architectural smell: the escalation flow makes **three** Sonnet calls — `_build_escalation_package_enhanced`, `_generate_ai_assessment`, and `generate_status_update` (engineer-triggered) — all summarizing the same source material from slightly different angles. User correctly observed: status update is typically generated during the escalate flow anyway; reusing that content would consolidate.
- Decided the right consolidation: ONE structured AI call per escalation that returns both the magic-moment diagnostic fields (`likely_cause`, `suggested_steps[]`, `confidence`) AND PSA-ready prose. Magic moment populates immediately. Status update buttons become tone-shift transformations (Haiku) of the saved prose, not fresh summarizations. Drops to 1 call (~60% token reduction), eliminates the AI-summary placeholder bug because the work happens in the foreground escalate path. Full implementation plan written into CURRENT_TASK.md and DECISIONS.md.
- Session ended pre-consolidation: user is updating Claude Code CLI and starting a fresh session for clean context window. All work pushed to origin (`0d1b305`). PR #155 still draft.
- Test users for the next session (Acme MSP shared account, password `TestPass123!`): `engineer@` (junior) and `teamadmin@` (senior).
- Files touched: `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/components/common/RichTextInput.tsx`, `frontend/src/components/flowpilot/EscalateModal.tsx`, `frontend/src/components/assistant/ConcludeSessionModal.tsx`, `frontend/src/components/dashboard/PendingEscalations.tsx`, `backend/app/core/config.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
## 2026-04-28 02:00 EDT — Claude Code — Plan-locked wedge polish + structural task-lane fix
- Audited `docs/plans/2026-04-27-escalation-mode-wedge-design.md` against the branch and identified four locked-design / Codex-correction items not yet shipped: live AI assessment refresh, suggested-step chips, unread 6px dot on queue cards, and race-condition toast on claim conflict.
- Shipped all four in commit `0f00ee5`:
- **Live AI assessment refresh.** New `HandoffAssessmentReadyEvent` type and `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it tracks a handoff missing its AI assessment; on a matching event it calls `handoffsApi.listHandoffs(sessionId)`, finds the handoff by id, and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e` — without this, the senior had to manually reopen the Context overlay to see the AI assessment when the background task finished.
- **Suggested-step chips.** New `chipsHidden` state in `AssistantChatPage`; chip strip renders above the composer when the magic-moment dissolves and `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty. Click prefills input and focuses; first send via `handleSend` flips `setChipsHidden(true)`; explicit X button also hides. Per-session lifetime by design (Codex correction locked).
- **Unread 6px dot.** localStorage-backed seen set (`rf-escalation-seen`, capped at 200 entries) hydrated in `EscalationQueue`. Card render adds a 6px `bg-accent` dot when not in the seen set. `markSeen` called on Pick Up click AND on card body click (the "open" affordance). Hover deliberately doesn't clear (Codex correction). Pick Up button's onClick now calls `e.stopPropagation()` so it doesn't double-fire the card-open path.
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user` via `selectinload`, rejects different-user re-claims (idempotent for same-user double-clicks), and raises with `claimed_by_id` / `claimed_by_name` / `claimed_at`. The endpoint translates to HTTP 409 with structured `detail = {error: 'already_claimed', claimed_by_id, claimed_by_name, claimed_at}`. `AssistantChatPage.handleStartHere` extracts via `axios.isAxiosError`, formats `"Already claimed by {name} {time_ago}."` using the existing `timeAgo()` helper, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests (`test_claim_session_conflict_raises_already_claimed`, `test_claim_session_idempotent_for_same_user`).
- User then reported that the task-lane stale-flash bug was still happening despite the prior fix `8914391` — "every time we work on something that's related to this, when we go back to test we create a new session and then the task lane shows unrelated session data." The previous fix only covered mount-time entry paths (prefill + pickup); any in-place transition still flashed.
- Shipped structural fix in commit `665530f`. Introduced `taskLaneOwnerChatId` state that explicitly tags which chatId the in-memory `activeQuestions` / `activeActions` / `showTaskLane` values belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix). Cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: taskLaneOwnerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. The lane is structurally unable to display data tagged with a different chat. See DECISIONS entry. **Not yet verified in a real browser** — user is swapping computers and asked for the handoff first.
- The two commits `0f00ee5` and `665530f` are **local-only** at session end. The user did not explicitly authorize a push, so per the handoff rule the branch was left unpushed. First action on resume is `git push`.
- Tests: full handoff + escalation suite (`test_handoff_manager.py`, `test_session_handoffs_api.py`, `test_escalation_bus.py`, `test_flowpilot_analytics_escalations.py`) → 34 passed in 68.89s. Frontend `tsc -b` exit 0 after each commit.
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/types/ai-session.ts`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_handoff_manager.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
## 2026-04-27 22:30 EDT — Claude Code — Escalation Mode: unify /escalate through HandoffManager
- User pushed back on the dual-path proposal: "why would we want two different escalation methods? Should the new one just be the way we escalate regardless if we're using a PSA or not using a PSA?" Right answer. Unified everything through `HandoffManager`.
- Backend changes (commit `029680a`):
- `HandoffCreateRequest` gains optional `target_user_id`; rejects self-targeting.
- `HandoffManager.create_handoff` for intent='escalate' now does what the legacy `flowpilot_engine.escalate_session` used to: sets `session.escalation_reason` and `escalated_to_id`, builds the legacy AI-enhanced `escalation_package` via Sonnet (`_build_escalation_package_enhanced` lazy-imported with graceful fallback), and merges handoff metadata (`intent`, `handoff_id`, `snapshot`, `engineer_notes`) into it. Eager-loads `session.steps` + `session.user` via `selectinload` to dodge async lazy-load `MissingGreenlet` errors.
- New `HandoffManager.finalize_escalation`: generates `SessionDocumentation`, pushes to PSA, and runs `notify()` (bell-icon AppNotification + Slack/Teams external channels) — all pre-commit so persistent state lands atomically with the handoff. Pulls engineer name via a separate User query rather than relying on `session.user` lazy access.
- `dispatch_escalation_notifications` keeps only the fire-and-forget IO (bus publish + per-user emails) post-commit. Found and fixed an in-flight bug: had originally put `notify()` inside dispatch (post-commit), which left `Notification` rows uncommitted — moved into `finalize_escalation` (pre-commit).
- `/handoff` endpoint passes `target_user_id` through and calls `finalize_escalation` pre-commit.
- `/escalate` is now a thin shim: owner-only session lookup → `create_handoff(intent='escalate')``finalize_escalation` → commit → `dispatch_escalation_notifications` → return `SessionCloseResponse`. `flowpilot_engine.escalate_session` is no longer called by any endpoint.
- `pickup_session` accepts both `requesting_escalation` (legacy in-flight) and `escalated` (new canonical) so existing queue items migrate seamlessly.
- Escalation queue list (`/escalation-queue`) and sidebar count match either status.
- Frontend: `useFlowPilotSession` optimistic update flips status to `escalated` instead of `requesting_escalation` so the page state matches the unified backend response.
- Verified end-to-end live against the running dev stack: a single legacy `/escalate` call from `engineer@` produced status=`escalated`, a `SessionHandoff` row (`ea9b375a…`, intent='escalate'), a `SessionDocumentation`, a PSA push attempt (`no_psa` since no ticket), AND an `AppNotification` for `teamadmin@` with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. Backend test suite: `1103 passed in 259.63s` with `-n auto`. Frontend `tsc -b` clean.
- The legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is now effectively dead for any new escalation (magic-moment takes over via the handoff record), but stays in place during the transition for legacy in-flight `requesting_escalation` sessions. Slated for cleanup after pilots run a couple of weeks on the unified path. `flowpilot_engine.escalate_session` is similarly orphaned and can be deleted at the same time.
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/api/endpoints/sidebar.py`, `backend/app/schemas/session_handoff.py`, `backend/app/services/flowpilot_engine.py`, `backend/app/services/handoff_manager.py`, `frontend/src/hooks/useFlowPilotSession.ts`.
## 2026-04-27 21:50 EDT — Claude Code — Escalation Mode: bell-icon notification fix; push + draft PR
- User ran a live escalation test via the EscalateModal (legacy `/escalate` path) and reported that clicking the bell-icon notification "just clears the notification instead of taking me to the session". Diagnosed: navigation IS happening, but the notification link template was `/pilot/{session_id}` without `?pickup=true`, so the senior landed on `FlowPilotSessionPage` with no pickup mode. `loadSession` then hit `GET /ai-sessions/{id}` which 404'd because the senior wasn't owner / `escalated_to_id` / picked-up handler. The user perceived the resulting error state as the action having done nothing.
- Two-part backend fix shipped in `641853a`. (1) `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow (handoff-based or legacy SessionBriefing). (2) `GET /ai-sessions/{id}` access policy: any account member can now read a session's detail when status is `requesting_escalation` or `escalated`. Tenant boundary enforced by RLS — the owner-only guard was overly restrictive for explicitly-shared in-transit states. After-pickup access (handler / `escalated_to_id`) checks still apply for active/resolved sessions.
- Verified end-to-end live: re-login as senior engineer (non-owner, non-target) and `GET /ai-sessions/{escalated-session-id}` returns 200 with full detail. Backend regression with broader subset (`test_escalation_bus`, `test_handoff_manager`, `test_session_handoffs_api`, `test_flowpilot_analytics_escalations`, `test_sessions`, `test_session_sharing`) → 94 passed in 43.26s.
- Pushed `feat/escalation-metric-endpoint` to Gitea. Opened **draft PR #155** against `main` via Gitea API ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Title prefixed `WIP:` so Gitea marks it `draft: true`. PR body links the design + test-plan artifacts and mirrors the test plan as a checklist with visual QA + e2e demo flow as the unchecked items.
- Open question for next session: EscalateModal still calls the legacy `/escalate` endpoint, not the new `/handoff` path. The wedge demo flow (junior escalates → magic-moment renders) is cleaner if EscalateModal goes through `/handoff`. Legacy path does PSA documentation push that the handoff path doesn't, so a parallel path (legacy escalate also creates a handoff record) is probably the right call rather than full migration.
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/services/notification_service.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 21:30 EDT — Claude Code — Escalation Mode: magic-moment handoff-context screen on pickup
- Continued the same session that shipped the live-arrival SSE subscription. Added the magic-moment screen on top.
- New `frontend/src/components/flowpilot/HandoffContextScreen.tsx`: presentational 4-section view (header with problem summary + domain + step count + escalated-time + priority badge; "What's been tried" with engineer notes + step-count affordance; "AI assessment" with likely_cause / suggested_steps / confidence badge; "Start here" CTA). Confidence badge accepts both numeric (0..1) and string ("low"/"medium"/"high") shapes — backend emits the latter, the frontend type says `number`, runtime handles both. Renders an explicit "assessment unavailable — model didn't respond in time" branch when `ai_assessment_data` is null (the 5s timeout from `9bdd995` fired). `prefers-reduced-motion` swaps `animate-slide-up` for `animate-fade-in`. ARIA `role=dialog` + `aria-modal=true` + focus on primary CTA on mount + Esc dismiss when used as a re-openable overlay.
- Integration in `frontend/src/pages/FlowPilotSessionPage.tsx`: on `/pilot/:id?pickup=true`, fetch the handoff list via `handoffsApi.listHandoffs` (account-scoped via RLS, no claim required) and find the latest unclaimed escalate handoff. If found, render the screen and skip `loadSession` (the senior would 404 pre-claim because they aren't yet `escalated_to_id`). "Start here" calls `handoffsApi.claimHandoff`, drops the `?pickup=true` query, and dismisses the screen — the existing `loadSession` effect then fires because the senior is now `escalated_to_id`. New "Context" toolbar button on active sessions (visible only when the senior arrived via the magic-moment flow this session — handoff lookup on demand) re-opens the screen as a dismissible overlay.
- Verified end-to-end against the running dev stack: `listHandoffs` returns the unclaimed handoff with full payload (engineer_notes, snapshot keys); `claimHandoff` flips session status from `escalated``active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds. `tsc -b` exit 0. No backend changes; backend tests still `32 passed in 18.91s`.
- Deferred to TODOs in `CURRENT_TASK.md`: suggested-step chips below the chat input (Codex correction; threads through to `FlowPilotMessageBar`); `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim (today's snapshot is just `problem_summary, problem_domain, status, step_count, confidence_tier`); toolbar "Context" button visibility on revisited active sessions; owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
- Branch state: 3 new commits (`b8627f4` SSE subscription, `f65b657` handoff doc bump, `8e9d22e` magic-moment screen). Branch is unpushed — next session pushes + opens draft PR.
- Files touched this slice: `frontend/src/components/flowpilot/HandoffContextScreen.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 21:00 EDT — Claude Code — Escalation Mode: frontend SSE subscription in EscalationQueue
- Picked up `feat/escalation-metric-endpoint` after the Codex test-stabilization pass. Confirmed green starting state: focused backend subset `32 passed in 18.78s` with `-n auto`.
- Implemented the live-arrival frontend slice. Added `streamEscalations(handlers, signal)` to `frontend/src/api/aiSessions.ts` — fetch-based `ReadableStream` reader (native `EventSource` can't send auth headers) that parses SSE frames (event/data/comment lines), buffers partial frames across chunks, ignores `: keepalive` heartbeats, dispatches `ready` and `handoff_created` events. Added `HandoffCreatedEvent` and `EscalationStreamHandlers` types in `frontend/src/types/ai-session.ts` mirroring the backend bus payload.
- Rewrote `frontend/src/components/flowpilot/EscalationQueue.tsx`. SSE subscription with `AbortController` + exponential-backoff reconnect (1s → 30s cap, attempt counter resets on `ready`). On `handoff_created` the component refetches the queue, diffs against the previous IDs via a `sessionsRef`, prepends new arrivals (newest-first) above established cards (oldest-first preserved). New IDs are tagged for 800ms so the locked 200ms slide-in animation plays before cleanup. Tab-title flash: captures `document.title` at mount, prefixes `(N)` while `document.hidden`, clears on `focus` / `visibilitychange`, restores on unmount. `prefers-reduced-motion: reduce` swaps `animate-slide-in-bottom` for `animate-fade-in`. ARIA: `role="region"` + `aria-live="polite"` on the list, `aria-label="N escalations awaiting pickup"` on the heading; Pick Up button bumped to `py-2.5` to clear the 44px touch floor.
- Verified end-to-end against the running dev stack. `tsc -b` exit 0. Vite HMR'd the new component without errors. Raw SSE handshake against `/api/v1/ai-sessions/escalations/stream` returned 200 with `text/event-stream; charset=utf-8` plus the locked headers (`cache-control: no-cache`, `x-accel-buffering: no`). Subscriber received the `ready` frame on connect; after posting a handoff via the API, the subscriber received the `handoff_created` frame with the full payload — wire format matches the parser exactly. Backend regression: same focused subset still `32 passed in 18.91s`.
- Not yet verified (would need a real browser session): the slide-in animation visually plays, the tab title actually updates, the reduced-motion media-query path, AbortController cancellation on unmount, backoff after a real network blip. Wire contract is confirmed; these are visual/timing-dependent and follow from correct parser + state machine.
- Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) is sitting in the engineer's queue from the verification step. Harmless; useful as visual demo data.
- Left for next session: the magic-moment handoff-context screen — 4 sections (problem summary / what's been tried / AI assessment / Start here CTA), loads on Pick Up, dissolves into the regular FlowPilot session view. Must render gracefully when `ai_assessment` is `None` (per the 5s assessment timeout from Codex's earlier fix).
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/types/ai-session.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 EDT — Claude Code — Escalation Mode wedge: design through SSE backend (8 commits)
- One long session that produced the entire planning artifact stack and most of the backend for the Escalation Mode wedge. Output of `/office-hours` (8 founder-signal session, top-tier YC archetype indicators), `/plan-eng-review` (scope reduced from "2-3 weeks greenfield" to "~6-9 days integration + metric + polish" once the existing handoff_manager surface was inventoried), `/plan-design-review` (6/10 → 9/10 with magic-moment screen, hero metric placement, and real-time arrival visual locked), and `/codex review` (12 findings, 6 applied — two-metric framing, notification routing, claim auth gate moved in-scope, unread-state fix, "Start here" CTA reframe, per-channel delivery model; 5 rejected including the full-scope reduction Codex pushed for).
- Branched `feat/escalation-metric-endpoint` off `main` @ `c0ed6d9`. Stack at session end: `d51e95c` plan + test-plan artifacts; `52f6d03` `GET /analytics/flowpilot/escalations` endpoint with 9 tests including multi-tenant isolation; `7a5b853` claim-endpoint role gate; `07d0db9` email dispatch on escalate with graceful-degradation regression; `9f0bfd4` `EscalationMetricCard` mounted above the queue list; `a283d0d` mid-flight `.ai/` refresh; `87bd0b7` WIP commit for SSE pub/sub bus + endpoint + 7 bus unit tests + 1 dispatcher integration test + 2 endpoint tests; `ba46fc5` paused-for-Codex-review handoff. Codex picked up from `ba46fc5` and added `bc15952` / `fff8338` / `9bdd995` (test stabilization + assessment latency bound).
- Pause was forced by a runaway local test loop: multiple stale `pytest` processes were left inside `resolutionflow_backend` after several aborted runs and contended on the same Postgres test schema. Codex diagnosed and fixed (see entry above).
- Frontend: thin slice — added `getEscalationMetrics` to `flowpilotAnalyticsApi`, the `EscalationMetricCard` component (loading / error / zero-data states + avg + median + conversion-rate + the inline two-metric disclaimer), and mounted it above `EscalationQueue`. `tsc -b` clean.
- Plan-stage UI decisions locked into the design doc and the codebase: dedicated 4-section magic-moment screen on Pick Up that dissolves into FlowPilot; queue stat-card + dedicated owner analytics page for the hero metric (in two places, not one); 200ms slide-in + tab-title flash on real-time arrival, no sound, respects `prefers-reduced-motion`; unread dot clears on open/claim/dismiss, NOT on hover (Codex correction). Claim role gate moved in-scope per Codex (not deferred to TODO).
- Two TODOs added: peer-tech escalation (deferred to v2 once a pilot asks); mobile/responsive design (also v2; pre-PMF wedge demo targets desktop). Claim role gate's TODO entry was struck through in the same session because it shipped in `7a5b853`.
- Plan and test-plan artifacts copied into `docs/plans/` under the `YYYY-MM-DD-name-design.md` / `-test-plan.md` convention so they live alongside the existing project plans, not just in `~/.gstack/projects/`.
- Left for next session: frontend SSE subscription in `EscalationQueue.tsx` (fetch-based ReadableStream — native EventSource can't send auth headers; match `streamDocumentation` in `frontend/src/api/aiSessions.ts`), then the magic-moment handoff-context screen, then push + draft PR. Default Claude Code model is being switched from Opus 4.7 1M-context to Opus 4.7 (200k) for the next session — the resume docs are sized to be self-sufficient under the smaller window.
- Files touched (committed): `docs/plans/2026-04-27-escalation-mode-wedge-design.md`, `docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`, `backend/app/api/endpoints/flowpilot_analytics.py`, `backend/app/schemas/flowpilot_analytics.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/app/core/escalation_bus.py` (new), `backend/tests/test_flowpilot_analytics_escalations.py` (new), `backend/tests/test_escalation_bus.py` (new), `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/types/flowpilot-analytics.ts`, `frontend/src/api/flowpilotAnalytics.ts`, `frontend/src/components/flowpilot/EscalationMetricCard.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/EscalationQueuePage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/TODO.md`.
## 2026-04-27 19:50 EDT — Codex — Stabilize Escalation Mode SSE backend tests
- Diagnosed slow backend tests on `feat/escalation-metric-endpoint`. Multiple stale pytest processes were still alive inside `resolutionflow_backend` and held `resolutionflow_test` transactions open, blocking later per-test schema resets on `DROP SCHEMA public CASCADE`.
- Reproduced a deterministic hang in `test_escalations_stream_returns_sse_content_type`: HTTPX `ASGITransport` buffers the full response body before returning, so an infinite SSE response never yielded the initial chunk and kept the auth DB dependency transaction open.
- Fixed `stream_escalations` to release auth dependencies before the long-lived stream body with `Depends(..., scope="function")`.
- Reworked the SSE handshake test to call `stream_escalations()` directly and consume one generator yield, then close it; kept viewer role-gate coverage through the API client.
- Stubbed `_generate_ai_assessment()` in handoff manager/API tests so escalation handoff tests no longer wait on the real AI path.
- Normalized account IDs inside `EscalationBus` so string UUIDs and `UUID` objects hit the same subscriber bucket; added a regression test.
- Verified focused backend subset: serial `31 passed in 46.95s`; xdist `31 passed in 17.80s`. Confirmed no lingering pytest processes or test DB sessions afterward.
- Follow-up in the same session: fixed the product latency risk by adding `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` (default 5s) around escalation AI assessment generation. If the optional assessment times out, handoff creation continues with no assessment. Added regression coverage; focused xdist subset now `32 passed in 17.77s`.
- Left for next session: continue frontend SSE subscription in `EscalationQueue.tsx`, then the magic-moment handoff-context screen.
- Files touched: `backend/app/api/endpoints/session_handoffs.py`, `backend/app/core/config.py`, `backend/app/core/escalation_bus.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_escalation_bus.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/TODO.md`.
## 2026-04-26 03:50 EDT — Claude Code — Ship AssistantChatPage prefill `currentChatRef` fix; close out PR #150
- User reported a troubleshooting-session bug: after answering a subset of task-lane questions and clicking *Send N of M Responses*, no AI response appeared. Traced to `AssistantChatPage`: the dashboard prefill effect set `activeChatId` after creating a new chat session but never updated `currentChatRef.current`. The `currentChatRef.current !== sentForChatId` guard in `handleSend` and `handleTaskSubmit` then bailed silently on every later request and discarded the AI's reply. The user message was already pushed to the chat before the await, so the user saw their answers but nothing else.
- Fix: one-line addition mirroring `handleNewChat` and `handleResumeNew` — assign `currentChatRef.current = session.session_id` immediately after `setActiveChatId(session.session_id)` in the prefill effect. Branched off `origin/main` as `fix/tasklane-prefill-ref`; PR #153 opened on Gitea.
- Authored a Playwright regression test `frontend/e2e/assistant-chat-prefill.spec.ts` that drives the real dashboard prefill flow against the real backend, stubs `/ai-sessions/*/chat` with `page.route` for deterministic turn-1/turn-2 responses, and asserts the second AI message renders. Confirmed the test fails on unfixed code at the exact assertion (`Got it — based on your answer…` never appears) and passes once the fix is restored.
- Verified locally inside `mcr.microsoft.com/playwright:v1.58.2-noble` against the running dev stack: new spec passes, adjacent `flowpilot-chat` spec still passes, `tsc -b` clean. `resume.spec` and `history.spec` failures observed are pre-existing real-backend fixture collisions, unrelated to this change.
- First CI run on PR #153 failed on infrastructure issues already addressed by PR #150: backend hit `Bind for 0.0.0.0:5432 failed: port is already allocated`, frontend hit `actions/upload-artifact@v4 not supported on GHES`. PR #150 was already merged (commit `87bb20b` on `main`). Rebased `fix/tasklane-prefill-ref` onto new `main` (force-push `1a8cb06``1559feb`), resolved a `.ai/TODO.md` conflict by keeping both backlog item sets, kicked off CI on the rebased SHA.
- Confirmed `CI / backend (pull_request)` is now in branch protection's required-status-checks list (added during PR #150 close-out). `CI / e2e (pull_request)` left as not-required pending one more clean PR run as the threshold.
- Recorded the broader silent-return concern in TODO backlog: the `currentChatRef.current !== sentForChatId` guard is applied across `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview`. PR #153 fixes one symptom but the same pattern can mask other drift. Either log a Sentry breadcrumb on the mismatch path or distinguish "expected stale" (chat switch) from "unexpected stale" (ref never updated) so the latter alerts.
- First CI run on the rebased SHA passed backend and frontend but failed e2e: the new prefill regression test couldn't render the task-lane question text. Diagnosed via the job log: `POST /api/v1/ai-sessions` calls `_require_ai_enabled()` and returns 503 when no provider key is set. The e2e CI job had neither `ANTHROPIC_API_KEY` nor `GOOGLE_AI_API_KEY` in env. Locally the dev backend has a real key, hence the local pass. The Playwright `page.route` stub on `/chat` was correct but never had a chance to fire because the upstream session-creation call was 503-ing.
- Fix: added a stub `ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests` to the e2e job env in `.gitea/workflows/ci.yml`. The Playwright stub still intercepts the actual `/chat` call in the browser, so the backend never contacts Anthropic — the gate just needs to clear. Documented the convention in a workflow comment so future AI-touching e2e tests know what to expect. Pushed `11fe32f`; CI went all-green.
- Merged PR #153 as `68fcdc6` on `main`. Local feature branch and remote both deleted via Gitea's `delete_branch_after_merge`.
- Opened a small follow-up `chore/post-153-handoff` PR to refresh the now-stale `.ai/` files (this entry, plus `CURRENT_TASK.md` rolling forward to "no active task — pick from `TODO.md`" and `HANDOFF.md` updating to the post-merge home position). The `data-testid` audit at the top of `TODO.md` "Up next" or the `currentChatRef` silent-return audit added in this session's backlog are the natural next pickups.
- Files touched: `frontend/src/pages/AssistantChatPage.tsx` (the one-line fix + comment), `frontend/e2e/assistant-chat-prefill.spec.ts` (new regression test), `.gitea/workflows/ci.yml` (stub `ANTHROPIC_API_KEY` for e2e), `.ai/TODO.md` (silent-return follow-up entry, plus conflict resolution preserving PR #150's backlog additions), `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` (this entry).
## 2026-04-25 16:41 EDT — Codex — Stabilize PR #150 e2e selectors
- Investigated the remaining PR #150 failure after backend and frontend CI were green. The e2e resume smoke test was not failing because of product behavior; it used `.bg-card` plus text filtering and matched the tree filter `<select>` before the intended session card.
- Added stable test IDs to flow session, tree, and share cards, then updated affected e2e tests to target those cards instead of Tailwind class names.
- Hardened the CI workflow by making Postgres healthchecks authenticate as `postgres` and baking `VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}"` into the e2e frontend build.
- Verified with `git diff --check`, frontend build in Docker, no remaining `.bg-card` e2e selectors, and focused Playwright runs in an Actions-like Ubuntu container: resume spec passed, then history/library/library-start/resume/shares passed (`6 passed`).
- Left for next session: push this WIP commit to PR #150, watch CI, merge when all three jobs are green, then enable backend branch protection and consider the e2e gate after a reliable green run.
- Files touched: `.gitea/workflows/ci.yml`, `frontend/e2e/history.spec.ts`, `frontend/e2e/library-start.spec.ts`, `frontend/e2e/library.spec.ts`, `frontend/e2e/resume.spec.ts`, `frontend/e2e/shares.spec.ts`, `frontend/src/components/library/TreeGridView.tsx`, `frontend/src/components/library/TreeListView.tsx`, `frontend/src/pages/MySharesPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
## 2026-04-25 12:00 America/New_York — Claude Code — Mock final AI-provider test, cache CI deps, parallelize backend with pytest-xdist
- Diagnosed why CI was still red despite Codex's local 1076 passed: a single test (`test_record_decision_persists_and_bumps_state_version`) needed `ANTHROPIC_API_KEY` because the `decision: draft_template` path calls `TemplateExtractionService` → AI provider. Patched `_extract_template_parameters` with an `AsyncMock` so the test no longer depends on AI availability. Verified.
- Pushed Codex's WIP commit `49f8856` to PR #150 (had been local-only per handoff protocol).
- PR #150 (`fix/ci-workflow-config`) extended with cheap CI wins: `actions/cache@v3` for pip + npm in all three jobs; dropped `--cov-report=term-missing` (the custom display step parses JSON); added `--maxfail=10` so structural breakage exits fast.
- PR #151 (`fix/ci-pytest-xdist`) opened, stacked on #150: pytest-xdist with per-worker DB isolation. `conftest.py` reads `PYTEST_XDIST_WORKER`, computes a per-worker DB URL like `…_gw0`, and synchronously CREATEs the DB on first import. The per-test `DROP SCHEMA public CASCADE` then operates on the worker's isolated DB. Verified locally: backend suite went from 22m 27s serial → 4m 28s parallel (8 workers), 1076 passed in both cases. ~5× speedup.
- Decided NOT to do per-test transactional rollback (bigger refactor); captured for future TODO consideration.
- Left for next session: watch CI on both PRs, merge in order (#150 first, #151 second), then enable `CI / backend (pull_request)` as a required status check on main.
- Files touched: `backend/tests/test_session_suggested_fixes_api.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/TODO.md`.
## 2026-04-25 06:12 EDT — Codex — Fix backend suite to green
- Fixed the real backend failures left after the CI-infra cleanup: tenant-scoped seed drift, missing production `account_id` writes, public route mounting for survey/share links, Script Builder library saves, resolution output async loading, AI search schema metadata, disabled-AI fixture leakage, and prompt marker guardrails.
- Added backend CI/dev system packages required by WeasyPrint PDF export.
- Stabilized the pytest harness for pytest-asyncio/asyncpg teardown ResourceWarnings under `filterwarnings = error`.
- Verified `pytest --override-ini="addopts=" -q` inside `resolutionflow_backend`: `1076 passed, 35 deselected in 1347.41s`.
- Left for next session: commit/push if needed, check and merge PR #150 when Gitea CI is green, add backend CI as a required branch-protection check, and rerun frontend lint if final DoD requires it.
- Files touched: `.gitea/workflows/ci.yml`, `backend/Dockerfile.dev`, `backend/app/api/endpoints/folders.py`, `backend/app/api/endpoints/script_builder.py`, `backend/app/api/endpoints/shares.py`, `backend/app/api/router.py`, `backend/app/models/ai_session.py`, `backend/app/schemas/user.py`, `backend/app/services/assistant_chat_service.py`, `backend/app/services/resolution_output_generator.py`, `backend/app/services/script_builder_service.py`, `backend/pytest.ini`, `backend/tests/conftest.py`, and focused backend tests.
## 2026-04-25 02:00 America/New_York — Claude Code — Land FlowPilot + PSA, recover CI from 488 errors to ~4
- Started session by completing pending FlowPilot Phase 9 QA: ran `/qa` against the seeded fixtures, found and fixed four latent layout/state bugs (`ResolutionNotePreview` off-screen, `TemplateMatchPanel` deadlock when TaskLane closed, `EscalateInterceptDialog` clipped above viewport, `seed_test_users.py` `cancel_at_period_end` NOT NULL crash). Added a new fixture seeder `backend/scripts/seed_phase9_qa_fixtures.py` that pre-bakes the four backend states the AI orchestrator needs to emit, so future QA can exercise all 7 conditional Phase 9 components without depending on stochastic AI behavior.
- Discovered PR #141 (PSA ticket management) and `feat/flowpilot-migration` had 5 overlapping files but only 2 real conflicts (`CLAUDE.md`, `AssistantChatPage.tsx`). Conflicts were both additive — concatenated rather than chose-a-side.
- Merged PSA first (PR #141), then merged FlowPilot (PR #147), each through Gitea API. `tsc -b` clean and visual smoke-test confirmed PSA's Tickets sidebar coexists with Phase 9 ProposalBanner.
- Discovered main had been merging through a broken CI gate for several merges. Initially recommended "stop the line, fix CI before shipping." After scoping the actual rot (~50% of tests red, ~600 errors on a clean run), reversed the recommendation: ship the queue first because FlowPilot itself carried significant test-infra repairs that would be duplicated work on a fresh recovery branch.
- PR #148: two surgical fixes to main (network_diagrams JSONB `server_default` triple-quote bug, deprecated session-scoped `event_loop` fixture in conftest). +78 passing / -114 errors.
- PR #149: frontend lint `20 errors → 0`, `requirements-dev.txt` pytest pin bumped to satisfy `pytest-asyncio==0.24.0`'s `pytest>=8.2`, and a one-line `from app import models as _models` in conftest that registers all ~60 models with `Base.metadata` before `create_all`. The conftest fix collapsed 484 of the remaining 488 backend errors. `1018 passed / 4 errors / 54 failed` after.
- Enabled Gitea branch protection on `main`: PR-only merges, `CI / frontend (pull_request)` required, force-push blocked, no review required.
- Discovered CI on the merge commit STILL showed red despite local pytest being mostly green. Root cause: workflow only set `DATABASE_URL`, but conftest reads only `DATABASE_TEST_URL` (per `dab740d`'s safety hardening). 638 connection-refused errors on every fixture setup. Plus `actions/upload-artifact@v4` not supported by Gitea Actions. PR #150 fixes both.
- Left for next session: merge PR #150 once CI confirms green, add `CI / backend (pull_request)` to required status checks, then root-cause and fix the 54 real backend test failures (one sample seen — `test_user` fixture leaking across calls causing duplicate-email violations).
- Files touched (committed): `backend/scripts/seed_test_users.py`, `backend/scripts/seed_phase9_qa_fixtures.py` (new), `backend/app/models/network_diagram.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `frontend/src/components/pilot/ResolutionNotePreview.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/components/pilot/ScriptBuilderTab.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `frontend/src/pages/TicketsPage.tsx`, `frontend/src/hooks/useFlowPilotSession.ts`, `frontend/src/hooks/useMediaQuery.ts`, `frontend/src/components/dashboard/TicketQueue.tsx`, `frontend/src/components/network/nodes/DeviceNode.tsx`, `frontend/src/components/network/nodes/GroupNode.tsx`, `frontend/src/components/routing/AssistantSessionRedirect.tsx` (new), `frontend/src/router.tsx`, `.gitea/workflows/ci.yml`, `.claude/settings.json` (new), `.claude/hooks/check-gstack.sh` (new), `.gitignore`, `CLAUDE.md`, `.gstack/qa-reports/phase9-*/` (QA artifacts).
- Net merges to main: PR #141 (PSA), PR #147 (FlowPilot), PR #148 (CI fixes part 1), PR #149 (CI fixes part 2). PR #150 still open at session end.
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
- Seeded `CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`.
- Deleted legacy `SESSION-HANDOFF.md` (superseded).
- Left for next session: first real feature task should replace the seed `CURRENT_TASK.md` and update `HANDOFF.md` with real resume state.
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.

View File

@@ -1,25 +0,0 @@
# TODO.md
> Backlog of work NOT currently active. Read only when `CURRENT_TASK.md` status is `complete`.
> Format: `- [ ] short description — optional link to issue/PR`
## Up next
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
## Backlog
- [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. Not blocking CI today.
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions``Session History`, `Account Settings``Account Management`, `/assistant``/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
- [ ] **AssistantChatPage `currentChatRef` guard is a silent return**`handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.
- [ ] **Allow peer-tech to escalate a colleague's session.** Today `POST /ai-sessions/{session_id}/handoff` in [endpoints/session_handoffs.py:48](backend/app/api/endpoints/session_handoffs.py#L48) filters by `AISession.user_id == current_user.id`, so only the session owner can escalate. Real MSP shops have peer hand-offs: Junior A is on lunch, Junior B sees the session is stuck and should be able to escalate it. Auth tweak: switch from session-owner check to `require_engineer_or_admin` + same-account scope. Add a `handed_off_by` audit column (already exists on `SessionHandoff`) so the original-owner-vs-actual-escalator distinction is preserved. Surfaced from /plan-eng-review on the Escalation-Mode wedge plan; v1 wedge demo doesn't need this (solo-founder pilot), but capture for v2 once 3+ pilots are live and a peer-claim need surfaces.
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Block skill usage when gstack is not installed globally.
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
cat >&2 <<'MSG'
BLOCKED: gstack is not installed globally.
gstack is required for AI-assisted work in this repo.
Install it:
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
Then restart your AI coding tool.
MSG
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
exit 0
fi
echo '{}'

View File

@@ -1,15 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
}
]
}
]
}
}

View File

@@ -1,12 +0,0 @@
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
POSTGRES_PORT=5433
SECRET_KEY=
ANTHROPIC_API_KEY=
GOOGLE_AI_API_KEY=
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
INTERNAL_TESTER_EMAILS=internaltest@resolutionflow.com

View File

@@ -1,238 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
# No host port mapping. Tests connect to `postgres:5432` (the service
# container's docker-network DNS name), not `localhost:5432`. With
# multiple Gitea runners on the same homelab box, host-port mapping
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
# second fails with "port is already allocated".
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
# not consulted after the dab740d test-isolation hardening). The CI test
# DB is the same postgres service, so point DATABASE_TEST_URL at it
# explicitly — without this, conftest falls back to localhost:5432 and
# all tests fail at fixture setup with "connection refused".
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
TEST_DB_NAME: resolutionflow_test
DB_APP_ROLE_PASSWORD: app_secret_ci
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install system dependencies
run: |
apt-get update
apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev
- name: Install dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run Alembic migrations
run: cd backend && alembic upgrade head
- name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py
- name: Run tests with coverage
# `-n auto` parallelizes across all runner cores via pytest-xdist.
# conftest.py creates a per-worker DB (resolutionflow_test_gw0,
# resolutionflow_test_gw1, …) so the per-test DROP SCHEMA doesn't
# race across workers. Master/serial runs keep the base DB.
# term-missing dropped — the custom "Display coverage summary" step
# below parses coverage.json and prints the same info more concisely.
# --maxfail=10 short-circuits on structural breakage so we don't burn
# 25 minutes when a fixture explodes.
run: cd backend && python -m pytest --override-ini="addopts=" -n auto --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
- name: Display coverage summary
if: always()
run: |
cd backend
python -c "
import json
with open('coverage.json') as f:
data = json.load(f)
total = data['totals']['percent_covered_display']
print(f'Total coverage: {total}%')
print()
print('Module coverage:')
for fname, fdata in sorted(data['files'].items()):
pct = fdata['summary']['percent_covered_display']
if float(pct) < 80:
print(f' WARNING {fname}: {pct}%')
else:
print(f' OK {fname}: {pct}%')
"
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: cd frontend && npm ci
- name: Lint
run: cd frontend && npm run lint
- name: Test with coverage
run: cd frontend && npm run test:coverage
- name: Build
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
# Build artifact intentionally NOT uploaded. The e2e job below builds
# its own frontend rather than downloading one from this job, so there
# is no need for the cross-job artifact handoff (which previously broke
# on actions/upload-artifact@v4 GHES support and forced a v3 pin).
# Decoupling also lets e2e start immediately rather than waiting for
# this job to finish — important on a multi-runner setup.
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
# No host port mapping. Tests connect to `postgres:5432` (the service
# container's docker-network DNS name), not `localhost:5432`. With
# multiple Gitea runners on the same homelab box, host-port mapping
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
# second fails with "port is already allocated".
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
# AI-touching endpoints (POST /ai-sessions, /chat, /respond, etc.) are
# gated by `_require_ai_enabled()`, which returns 503 when no provider
# key is set. Tests that exercise those flows stub the AI calls in the
# browser via `page.route`, so the backend never actually contacts
# Anthropic — but the gate still has to pass. A stub value is enough.
ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install backend dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Build frontend
# Building inline (instead of downloading an artifact from the
# frontend job) drops the cross-job dependency, so e2e can start
# immediately on a free runner. Adds ~1-2 min of build time, but
# eliminates the artifact-upload mechanism entirely (no more
# v3/v4 GHES headaches) and saves ~5 min of waiting.
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}" npm run build
- name: Install Playwright browser
run: cd frontend && npx playwright install --with-deps chromium
- name: Run Playwright smoke tests
run: cd frontend && npm run test:e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |
frontend/playwright-report
frontend/test-results
if-no-files-found: ignore

View File

@@ -1,19 +0,0 @@
name: Mirror to GitHub
on:
push:
branches:
- '**'
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Push to GitHub
run: |
cd /tmp
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
cd repo
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
git push github --all --force
git push github --tags --force

View File

@@ -1,43 +0,0 @@
name: Runner Probe
on:
workflow_dispatch:
jobs:
probe:
runs-on: ubuntu-latest
steps:
- name: Runner labels and OS
run: |
echo "=== OS ==="
uname -a
cat /etc/os-release 2>/dev/null || true
- name: Python versions
run: |
echo "=== Python ==="
which python3 && python3 --version || echo "python3 not found"
which python && python --version || echo "python not found"
ls /usr/bin/python* 2>/dev/null || true
- name: Node versions
run: |
echo "=== Node ==="
which node && node --version || echo "node not found"
which npm && npm --version || echo "npm not found"
ls /usr/bin/node* 2>/dev/null || true
ls ~/.nvm/versions/node/ 2>/dev/null || echo "no nvm versions"
- name: Docker
run: |
echo "=== Docker ==="
which docker && docker --version || echo "docker not found"
docker info 2>/dev/null | grep -E "Server Version|Operating System" || true
- name: User and home
run: |
echo "=== User ==="
whoami
echo "HOME=$HOME"
echo "PATH=$PATH"

View File

@@ -31,16 +31,14 @@ jobs:
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
TEST_DB_NAME: resolutionflow_test
DB_APP_ROLE_PASSWORD: app_secret_ci
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.12
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.11"
cache: pip
cache-dependency-path: |
backend/requirements.txt
@@ -49,14 +47,6 @@ jobs:
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run Alembic migrations
run: cd backend && alembic upgrade head
- name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.
# See: docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md Section 3f
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
@@ -143,10 +133,10 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.12
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.11"
cache: pip
cache-dependency-path: |
backend/requirements.txt

18
.gitignore vendored
View File

@@ -207,11 +207,7 @@ marimo/_lsp/
__marimo__/
# Claude Code (local config, agents, settings)
.claude/*
!.claude/settings.json
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/check-gstack.sh
.claude/
.agents/
# Database dumps
@@ -237,15 +233,3 @@ package.json
package-lock.json
.worktrees/
.gstack/
# Core dumps from crashed processes (e.g. core.12345)
core.[0-9]*
**/core.[0-9]*
.gitnexus
# graphify knowledge graph outputs
graphify-out/
.graphify_python
# remember skill runtime state (hook logs, PIDs)
.remember/

View File

@@ -1 +0,0 @@
3.12.13

View File

@@ -1,25 +1,11 @@
# Development Roadmap
> **Last Updated:** May 7, 2026
> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name)
> **Last Updated:** March 18, 2026
> **Product:** ResolutionFlow (repo: patherly)
> **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients
---
## Status as of 2026-05-07
The historical phase content below (Phase 1 through Phase 5) is preserved as a factual record. **This section is the live status overlay — read it first.**
**Where we are:** Pre-PMF, Go-to-Market Validation. Backend feature-complete (50+ endpoints, 100+ tests). FlowPilot session UX is the daily-driver surface and recently went through PR #155 (escalation wedge), #156 (`applied_pending` non-terminal status), #158 (impeccable pass + tasklane keyboard flow), #159 (Diátaxis User Guides), #160 (sidebar IA + account redesign).
**Currently in flight:** Self-serve signup cutover. Phase 1 backend (#161) and Phase 2 frontend (#162) merged. PR #164 (open) closes the last code blockers — plan taxonomy reconciliation (`team``enterprise`, add `starter`) and `INTERNAL_TESTER_EMAILS` allowlist for the soft cutover. After merge, remaining work is **manual operations only**: Stripe Dashboard live-mode setup, Railway prod env vars, internal validation pass, public flag flip. See `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` Phase O for the checklist.
**Product thesis being tested:** "We're not a documentation app. We are the documentation builders." Captured in `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (office-hours design doc). Pre-build assignment: 3 calls with external Directors of Onboarding (cold, no friendly contacts) to validate the framing before adopting it as the public positioning.
**What's not yet decided:** Whether to formally cut branching Flows from the pilot UI surface in favor of a Project (linear procedure) + FlowPilot + Documentation-Builder positioning. Discussed in /office-hours but no implementation work scheduled — gated on the 3 external validation calls.
---
## Completed Work
### Phase 1: MVP
@@ -86,26 +72,13 @@ The historical phase content below (Phase 1 through Phase 5) is preserved as a f
| Task | Status | Notes |
|------|--------|-------|
| Self-serve signup cutover (Phase O) | In Progress | PR #164 merge → Stripe live-mode Dashboard setup → Railway prod env vars → internal validation → public flag flip. Code blockers cleared by #164 (taxonomy + `INTERNAL_TESTER_EMAILS` allowlist). |
| External validation of documentation-builder thesis | Not started | 3 calls with external Directors of Onboarding (cold). Decision gate before scoping a "Day 1 onboarding checklist" build. |
| ConnectWise PSA Integration (Advanced) | Deferred | Core complete — ticket linking, note posting, member mapping, ticket context retrieval. Callback webhooks deferred until pilot signal demands them. |
| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions |
| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review |
---
## What's Next
### Phase O Cutover (Weeks 0-1)
| Step | Status |
|---|---|
| Merge PR #164 (taxonomy reconciliation + allowlist) | Open, CI green |
| Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) | Manual op |
| Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` for frontend redeploy) | Manual op |
| Run `python -m scripts.sync_stripe_plan_ids` against prod backend; verify `plan_billing` has `sk_live_*` price IDs | Manual op |
| Internal validation pass (9 scenarios from Phase O Task 46) | Manual op |
| Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) | Manual op |
| PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors | Manual op |
### Near-Term Priorities (from Stack Priorities Plan)
| Feature | Status | Description |
@@ -113,7 +86,7 @@ The historical phase content below (Phase 1 through Phase 5) is preserved as a f
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled |
| Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals |
| Search and recall improvements | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) |
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
### 3A: Quick Wins & UX (Priority: Medium)

View File

@@ -1,61 +0,0 @@
# AGENTS.md — ResolutionFlow
You are OpenAI Codex, the resume agent for ResolutionFlow. Claude Code is the primary coding agent; you step in when Claude hits session or weekly limits.
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
> The protocol section below is byte-identical to the shared block in CLAUDE.md. If you edit one, edit the other.
## Shared protocol
### Startup ritual (every session)
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
3. Read `.ai/HANDOFF.md` — exact resume point.
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
5. Run `git log --oneline -15` and `git status`.
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
### Handoff ritual (session end — limit hit, task complete, or user stop)
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
2. If `CURRENT_TASK.md` status changed, update it.
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
4. Append a session entry to `.ai/SESSION_LOG.md`.
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
### Writing rules for .ai/ files
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
### Project principle
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
## Codex-specific notes
### Tooling you do NOT have
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow. If `python`/`npm` aren't on PATH, the host runs services in Docker — use the `docker exec resolutionflow_{backend,frontend} …` form documented in `.ai/PROJECT_CONTEXT.md` rather than installing toolchains.
- **No `/codex` second-opinion command.** You are Codex.
### Git trailer
Every commit: `Co-Authored-By: Codex <noreply@openai.com>`
### Model selection
Handled on OpenAI's side. Do not attempt to set Anthropic model aliases for your own runtime. (The repo's application code still uses Anthropic aliases like `claude-sonnet-4-6` via `settings.get_model_for_action()` — that's runtime config for the product, not your agent.)
### Reviewing Claude's work
When you resume from a Claude session, assume some decisions may have been informed by GitNexus queries or gstack commands whose output isn't in the handoff. If a decision looks unverified from the `.ai/` files alone, either:
- re-verify with `grep`/`rg`/file reads, or
- flag it in `HANDOFF.md` under "Open questions" so Michael or Claude can confirm on the next handoff.
Do not assume tooling output that isn't written down.

View File

@@ -2,83 +2,39 @@
All notable changes to ResolutionFlow are documented here.
## [0.1.0.0] - 2026-04-16
### Added
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
### Fixed
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
- Assignment search and "Load More" removed from resource manager in favor of direct member list
## [Unreleased]
### Changed
- **In-product User Guides rewrite** — replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories (Getting started, Working a pilot session, Closing out a session, Documentation & sharing, Authoring flows, Reusable assets, AI assistance, PSA integrations, Account & team admin, Analytics). Dropped three deprecated guides (Maintenance Flows, AI Assistant page, Flow Assist sparkle button — UI no longer exists). Renamed Step Library → Solutions Library to match canonical product terminology. Corrected sidebar entry-path references throughout (Dashboard → Home, All Flows → Flows, Sessions → History, Analytics → Data, etc.). Added `category` and optional `relatedSlugs` to the Guide schema; `GuidesHubPage` now renders category sections; `GuideDetailPage` shows a "Related guides" footer when set. Authored 14 net-new how-tos covering FlowPilot-era surfaces with no prior coverage: tasklane keyboard flow, what-we-know panel, ask-the-AI mid-session, pause-and-leave, resolve a session, record a suggested-fix outcome, escalate (Escalation Mode), post docs to a ConnectWise ticket, share a client update mid-session, build a script with Script Builder, open an AI-suggested flow, pin a flow, and invite a teammate. Fixed a long-standing rendering bug where `**bold**` markdown in `step.tip` rendered literally instead of bolded — the same regex replacement now runs on tips as on instructions. Killed the misleading "N sections" subtitle on guide cards (single-section how-tos make the count noise).
## [2026-04-04] Network Diagram Editor UX Improvements
### Added
- Snap-to-grid (20px) on Network Diagram canvas — nodes align consistently when dragged
- NodeResizer on group nodes (subnet/VLAN/site/DMZ) — select a group and drag its handles to resize
- Group node dimensions now saved to and restored from the backend on reload
### Fixed
- Connection edges now render as straight lines instead of orthogonal bent paths
- ISP device now appears inside the Cloud category in the sidebar instead of a standalone "Internet" section; respects search and item count
- Group nodes now restore correctly as `type: 'group'` on diagram load (previously loaded as `type: 'device'`, breaking group display after save)
---
### Added
- **TaskLane keyboard-first answer flow** (#158) — Enter submits and auto-advances to the next pending task; Shift+Enter inserts a newline; Esc cancels; after the last task, focus jumps to the Send Responses button so the engineer can fire the whole batch with one more keystroke. Mouse path also auto-advances. Subtle hint row (`⏎ submit · ⇧⏎ newline`) under each open input teaches the shortcut.
- **Collapsible "What we know" section** (#158) — TaskLane's facts list is now a collapsible section with per-session memory in `sessionStorage`. Auto-collapses on first render at ≥5 facts so Questions and Diagnostic Checks stay above the fold; engineer's explicit toggle always wins.
- **Escalation Mode wedge** (#155) — when an engineer escalates, the senior tech who claims the session lands on a magic-moment handoff-context screen with the structured briefing visible in seconds (no scrolling, no chat re-read). Live SSE pushes new arrivals to anyone watching the queue, atomic claim resolves race conditions, the queue auto-excludes the claimed session, the claiming user retains chat ownership for AI briefings, and a new analytics endpoint tracks post-claim time-to-first-action so you can see real minutes recovered (paired with a manual baseline — see CURRENT_TASK.md two-metric framing).
- **Suggested-fix "Awaiting verification" outcome** (#156) — when a fix needs external confirmation (client power-cycle, AD replication, license sync) you can park it in `applied_pending` instead of forcing a worked / didn't / partial verdict. The new PendingBanner shows the parked status with worked / didn't / update reason / dismiss actions. The "Still checking" nudge records pending with a reason instead of just silencing. Page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending the same way it intercepts verifying/partial. Resolution notes and escalation packages frame the pending state honestly (provisional fix; leading hypothesis with what's being waited on).
- Tree Templates + Import/Export marketplace (#66)
- Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
### Changed
- **Assistant Chat session screen — UX overhaul** (#158, "impeccable" pass) — removed the duplicate "Suggested checks" chip strip in favor of the TaskLane as the single source of truth; added an inline `Next steps · N pending` cue above the latest action-bearing AI bubble; consolidated the session header to two visible primary actions (Resolve + Escalate) plus a kebab for Context / New Ticket / Update Ticket / Pause; centered the messages column to `max-w-3xl` to match the composer; unified chat-bubble radii to `rounded-xl`; dropped every banned decoration (3px side stripes, gradient surfaces, accent borderTop, backdrop blur, pulse rings, bordered avatar boxes) for a single decoration channel per surface; unified 14 distinct text sizes into a 5-step scale (10/11/12/13/14px); split the ambiguous `MessageCircleQuestion` icon into `Pencil` (write affordance for question Answer CTA) and `HelpCircle` (universal help icon for the per-check explainer); audited and dropped redundant `font-sans` classes across the screen.
- **Suggested-fix banner ↔ script panel are now linked** (#158) — collapsing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; dismissing the banner closes both surfaces. Recording any outcome on a fix (Dismiss, It worked, Didn't work, Mark partial, Waiting to verify) closes the script panel alongside the banner state transition.
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
- **Account settings page** — audit fixes for improved consistency and usability
- **PSA documentation formatting** — removed duplicate timing blocks and AI confidence sections; added client-facing communication context guidance
- **Status update generation** — fixed option label lookup to use human-readable labels instead of machine values
- **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot
- **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
### Fixed
- **`ParameterizationPreview` over-highlight on short parameter values** (#158) — the tokenizer matched highlight values via raw substring with no word-boundary check, so a single-char value like `"D"` (a drive letter) lit up every capital D in identifiers like `Get-ADUser`, `Add-Type`, `Disable-`. Added a word-boundary guard that's conditional on whether the value itself starts/ends with a word character, so values with leading/trailing punctuation (e.g. `"D:\\Folder"`) still match cleanly when adjacent to whitespace.
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
- Dark text rendering on blue accent step-number badges across all flow types
- Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation
- Stale async results in Assistant Chat (selectChat) no longer clobber new session task lane
- Sentry DSN hardcoded fallback removed — now uses environment variable only
- Option label resolution in status update context generation
- "Sorry something went wrong" errors in chat when rendering unsupported message types
- Task Lane stale data when creating new chat or resuming from concluded session
- Chat ref invalidation race condition between handleNewChat and async data loads
- Images now properly display in chat message history instead of blank placeholders
---

542
CLAUDE.md
View File

@@ -1,74 +1,520 @@
# CLAUDE.md ResolutionFlow
# CLAUDE.md - Patherly / ResolutionFlow Project Context
You are Claude Code, the primary coding agent for ResolutionFlow. OpenAI Codex is the resume agent when you hit session or weekly limits.
> **Last Updated:** March 27, 2026
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
---
> The protocol section below is byte-identical to the shared block in AGENTS.md. If you edit one, edit the other.
## Project Overview
## Shared protocol
**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for MSP professionals**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes, and generate professional ticket documentation.
### Startup ritual (every session)
**Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients.
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
3. Read `.ai/HANDOFF.md` — exact resume point.
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
5. Run `git log --oneline -15` and `git status`.
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
**SaaS Context:** Multi-tenant design — teams represent MSP companies, trees shared within teams, tiered access (super_admin, team_admin, engineer, viewer).
### Handoff ritual (session end — limit hit, task complete, or user stop)
### Branding
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
2. If `CURRENT_TASK.md` status changed, update it.
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
4. Append a session entry to `.ai/SESSION_LOG.md`.
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
| Context | Name Used |
|---------|-----------|
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
| Backend, frontend UI, production URLs | **ResolutionFlow** |
### Writing rules for .ai/ files
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
- **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned.
- **Accent color:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9).
- **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB.
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows.
### Project principle
## Implementation Principles
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
- Prefer correct architecture over minimal diff
- If two approaches exist, implement the one that scales, not the one that's faster to write
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
## Claude-specific tooling
---
### GitNexus code intelligence
## Current State
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
- **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional
- **Database:** PostgreSQL with Docker, 98 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
| Tool | When |
|---|---|
| `gitnexus_query({query})` | Find code by concept when you don't know where to look |
| `gitnexus_context({name})` | Callers/callees of a symbol before touching it |
| `gitnexus_impact({target, direction})` | Blast radius before editing shared symbols |
| `gitnexus_rename({symbol_name, new_name, dry_run: true})` | Safe multi-file rename |
### What's In Progress
**Use for:** core shared symbols (`flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks), cross-file renames, unfamiliar bug traces, refactor safety. **Skip for:** new endpoints, isolated fixes, changes you can read in one file.
- GTM validation: Shadow & Ship — founder dogfooding for 2 weeks, then 5 colleague pilot
- Solutions Library spec written (`docs/plans/2026-03-23-solutions-library-design.md`), implementation post-pilot
- Remaining open issues: #66 Templates + Import/Export, #60 Recurring Issue Detection, #58 Step Feedback Flag
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
---
### gstack skills
## Tech Stack
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
### Backend
Available commands:
- **Framework:** Python FastAPI
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
- **Migrations:** Alembic
- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based)
- **Validation:** Pydantic v2
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
- **Planning & review:** `/autoplan`, `/plan-eng-review`, `/plan-design-review`, `/plan-ceo-review`, `/plan-devex-review`, `/devex-review`, `/review`, `/cso`, `/office-hours`
- **Design:** `/design-consultation`, `/design-shotgun`, `/design-html`, `/design-review`
- **Browser & QA:** `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/setup-browser-cookies`
- **Ship & deploy:** `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/setup-deploy`, `/document-release`
- **Debug & investigate:** `/investigate`, `/careful`, `/freeze`, `/guard`, `/unfreeze`
- **Other:** `/codex` (OpenAI second opinion), `/setup-gbrain`, `/retro`, `/learn`, `/gstack-upgrade`
### Frontend
### Git trailer
- **Framework:** React 19 + Vite + TypeScript
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
- **State:** Zustand (with immer + zundo for undo/redo)
- **Routing:** React Router v7
- **API Client:** Axios with token refresh interceptor
- **Icons:** Lucide React
Every commit: `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`
---
### Model aliases
## Key Project Structure
Always use alias form (`claude-sonnet-4-6`, `claude-opus-4-6`, etc.) via `settings.get_model_for_action()`. Never hardcode a dated model ID.
```
patherly/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
│ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
│ │ ├── api/router.py # Route registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── alembic/ # Database migrations (001-029+)
│ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios client + endpoint modules
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
│ │ ├── pages/ # All page components
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
│ │ └── types/ # TypeScript interfaces
│ └── (Tailwind v4: CSS-only config in src/index.css)
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
├── CLAUDE.md # This file
├── CURRENT-STATE.md # Detailed feature status
├── LESSONS-LEARNED.md # (Deprecated — consolidated into CLAUDE.md)
└── docs/plans/ # Design docs & implementation plans
```
---
## Environment Variables
### Backend (`backend/.env`)
```bash
APP_NAME=ResolutionFlow
DEBUG=true
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly
SECRET_KEY=<openssl rand -hex 32>
ACCESS_TOKEN_EXPIRE_MINUTES=5
REFRESH_TOKEN_EXPIRE_DAYS=7
REQUIRE_INVITE_CODE=true
```
### Frontend (`frontend/.env.local` - optional)
```bash
VITE_API_URL=http://localhost:8000
```
---
## ConnectWise PSA Integration
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
### Best Practices Documentation
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
### Reference Files (read in this order)
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
### Integration Architecture
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
### Key Implementation Rules
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
- All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
- Design for the Autotask integration following the same service layer pattern (future PSA)
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
---
## Development Commands
```powershell
# Start PostgreSQL
docker start patherly_postgres
# Backend (from backend/)
source venv/bin/activate # Linux/Mac
# .\venv\Scripts\Activate # Windows
uvicorn app.main:app --reload
# Frontend (from frontend/)
npm run dev
# Run tests (from backend/)
pytest --override-ini="addopts="
# First time only: create test database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build
# Database migrations
cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069).
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1
# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py).
# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations.
# Access PostgreSQL
docker exec -it patherly_postgres psql -U postgres -d patherly
# Seed data
cd backend && pip install httpx && python -m scripts.seed_trees
# CI/CD debugging
gh run list --limit 5 # Recent CI runs
gh run view <id> --log-failed # Failed job logs
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
```
### URLs
- Frontend: <http://localhost:5173>
- Backend API: <http://localhost:8000>
- API Docs: <http://localhost:8000/api/docs>
### Test Users (seeded via `scripts/seed_test_users.py`)
- All share password: `TestPass123!`
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
---
## Critical Lessons Learned
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
### Active Lessons (41+)
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Actions route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers.
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
**49. Full-stack features — verify both ends:** Check the full data flow: schema → endpoint → API client → hook → store → UI.
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: use alias form (`claude-sonnet-4-6`).
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
**57. Node field priority:** `title``question``description``content``label`. See `copilot_service.py`.
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` or `VITE_PUBLIC_*` env var must be added as `ARG` + `ENV` in `frontend/Dockerfile` for Railway deploys. Railway env vars are runtime-only unless explicitly passed through as Docker build args. Without this, `import.meta.env.VITE_*` resolves to `undefined` in production builds.
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — there is no intake form screen or "Start" button. Variables are filled inline during execution. Troubleshooting flows DO have a start screen with ticket/client fields. Don't write tests or UI that assume a Start button on procedural flows.
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="/home/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`.
**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Not "Decision Tree Platform". This tagline appears on login, register, and the HTML `<title>`. The old "Decision Tree Platform" was internal jargon misaligned with user-facing branding.
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` (bottom-sheet on mobile, centered on desktop) and `max-w-full sm:max-w-lg` (full-width on mobile). The shared `Modal.tsx` does this correctly — custom modal implementations must follow the same pattern. See `PrepareSessionModal.tsx` for the fix pattern.
**80. TopBar search collapses to icon on mobile:** Full search bar (`hidden sm:block`) shows on desktop; magnifying glass icon button (`sm:hidden`) shows on mobile (<640px). Both open the same CommandPalette. Don't add `w-full` search bar without the mobile icon fallback.
**81. Never use `transition: all` in landing.css:** Specify exact properties: `transition: background 0.3s, border-color 0.3s, box-shadow 0.3s, transform 0.3s, opacity 0.3s`. `transition: all` animates layout properties and causes jank.
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
**93. FlowPilot actions live in the page header, not a bottom bar:** `FlowPilotSessionPage` renders Resolve/Escalate/Share Update in the header bar. Desktop: inline buttons + `⋯` overflow (Pause/Close). Mobile: single `⋯` menu. The bottom only has the message input. `FlowPilotActionBar` component still exists but is no longer used in the main session flow.
**94. Frontend chat uses unified_chat_service, not assistant_chat_service:** `AssistantChatPage` calls `/ai-sessions/{id}/chat``unified_chat_service.py`. The old `assistant_chat_service` endpoints were removed (only retention settings remain at `/assistant/retention`). When tracing chat features, start from `aiSessionsApi.sendChatMessage``ai_sessions.py``unified_chat_service.py`. Never wire chat features into `assistant_chat.py`.
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links).
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
**98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`.
**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components.
**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements.
**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active.
**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message.
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
**104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
## RBAC & Permissions
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
- **Backend deps:** `get_current_active_user(user, db)` (any active + auto-downgrades expired trials), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
- **Never use** `role == "admin"` — use `is_super_admin` instead
- **Frontend:** `usePermissions()` hook for all permission checks
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
---
## Design System
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode planned.
- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`)
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color.
- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity
- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`)
---
## Frontend Patterns
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
- **Custom step flow:** `CustomStepModal``PostStepActionModal``ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
- **Session sharing:** `ShareSessionModal` manages share links, `SharedSessionPage` renders public/account views. Helper utils in `lib/sessionShare.ts`. Share URLs use `/shared/sessions/:token`.
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
- **Dashboard cockpit:** `QuickStartPage` is the copilot-first launchpad. Greeting + "What are you troubleshooting?" + ChatGPT-style `StartSessionInput` (auto-growing textarea, paste images, drag-drop files, attach button, paste logs, suggestion chips). Below: `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible "Dashboard" section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
- **Sidebar sections:** Amber "New Session" button → Home → RESOLVE (History) → KNOWLEDGE (Flows with Solutions Library sub-item, Scripts) → INSIGHTS (Data). Footer: Account, Pin/Unpin. No help/guides/feedback in sidebar — accessible via TopBar.
---
## Common Tasks
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head`
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
---
## Coding Standards
### Python
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
### TypeScript
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
### Git
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- Large features: commit per phase with `npm run build` validation
### After Completing Work
When a feature, fix, or significant piece of work is finished and merged/committed:
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
4. **Update `CLAUDE.md`** if the work introduced new patterns, lessons learned, or changed project structure
---
## gstack (Browser & Workflow Skills)
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
**Available skills:**
| Skill | Purpose |
|-------|---------|
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
| `/plan-design-review` | Design plan review (UI/UX critique) |
| `/design-consultation` | Create a design system / DESIGN.md |
| `/review` | Pre-landing PR code review |
| `/ship` | Ship workflow (tests, review, PR creation) |
| `/browse` | Headless browser for QA testing and site dogfooding |
| `/qa` | Systematic QA testing + auto-fix bugs found |
| `/qa-only` | QA report only (no fixes) |
| `/design-review` | Visual QA — find and fix design inconsistencies |
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
| `/retro` | Weekly engineering retrospective |
| `/investigate` | Systematic debugging with root cause analysis |
| `/document-release` | Post-ship documentation updates |
| `/codex` | Second opinion via OpenAI Codex CLI |
| `/careful` | Safety guardrails for destructive commands |
| `/freeze` | Restrict edits to a specific directory |
| `/guard` | Full safety mode (careful + freeze) |
| `/unfreeze` | Remove edit restrictions |
| `/gstack-upgrade` | Upgrade gstack to latest version |
---
## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
- Auto-deploys on push to `main`
- PR environments auto-created (need manual domain generation in Railway dashboard)
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
- Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`
---
## Future Roadmap
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
---
## Quick Reference
| What | Where |
|------|-------|
| API Docs | <http://localhost:8000/api/docs> |
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
| GitHub Issues | `gh issue list --state open` |
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |

View File

@@ -2,43 +2,19 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** May 7, 2026
> **Last Updated:** April 4, 2026 (evening)
---
## Active Phase: Go-to-Market Validation (Pre-PMF) — Self-serve cutover (Phase O) in flight
Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (Phase O) is gated on manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass against prod test mode, then the public flag flip. Plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.
---
## Recently shipped (post-0.1.0.0)
- **2026-05-13 — `feat/session-expiration-policy` (open)** Session expiration policy series — 8 commits, fixes the "logged in forever" bug and adds owner-side controls. Migration `b269a1add160` adds `accounts.session_idle_minutes` + `session_absolute_minutes` (NULL = use system default, defaults Strict 3d/14d via `Settings.SESSION_*_MINUTES_DEFAULT`). Refresh-token JWT carries `auth_time` + `idle_max` + `abs_max` claims (seconds) snapshotted at every login entry point (`/auth/login`, `/auth/login/json`, both OAuth callbacks). `/auth/refresh` enforces absolute cap (`now >= auth_time + abs_max` → 401 `session_expired_absolute`), atomic-revoke-then-check prevents replay. Error-detail taxonomy on the wire distinguishes `session_expired_idle` / `session_expired_absolute` / `invalid_refresh_token`. New owner-only `GET/PATCH /accounts/me/security` returns `{idle_minutes, absolute_minutes, effective_*, *_min/max, active_users}` with audit logging on PATCH. `POST /accounts/me/security/revoke-sessions` bulk-revokes refresh tokens for the account (`scope: "all" | "others"`), audited. Frontend: new `/account/security` page (Strict/Standard/Custom presets, active-users list with name + email + last-login-ago, count-aware revoke buttons + confirmation modal), `useAuthSessionExpiry` hook + top-of-app `SessionExpiryToast` (differentiated by idle vs absolute), cyan info-tone banner on `/login?reason=session_expired`. Plan + design review in `docs/plans/2026-05-13-session-expiration-policy.md` (initial 4/10 → 9/10 via `/plan-design-review`). 28 backend tests; tsc clean. Pending: open PR, merge, document follow-up issues (per-user device list, super-admin global ceiling UI).
- **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team``enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed.
- **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the Phase 2 plan: backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Single alembic head `c6cbfc534fad` (no new migrations in Phase 2). Squash-merged as `f1be3ab`.
- **2026-05-?? — PR #161** Self-serve signup backend (Phase 1). `plan_billing` sibling table for Stripe + catalog metadata, `sales_leads` and `stripe_events` tables, `complimentary` status with `has_pro_entitlement`, `BillingService.start_trial` wired into `/auth/register`, `/billing/checkout-session`, Stripe webhook handler with idempotency via `stripe_events`, Google + Microsoft OAuth callbacks with `oauth_identities` linking, `require_verified_email_after_grace` + `require_active_subscription` guards, bulk-create + soft-revoke invite endpoints, account-invite email-match enforcement, pilot complimentary backfill, `accounts.team_size_bucket` + `primary_psa` for wizard. Squash-merged as `f918b76`.
- **2026-05-02 — PR #159** In-product User Guides rewrite to Diátaxis how-tos. Replaced 15 feature-dump guides with 43 problem-oriented how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles guides (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`. Browser-verified against engineer + owner login.
- **2026-05-?? — PR #160** Post-PR-159 UI cleanup — sidebar IA + account redesign. Squash-merged as `a8b22cf`.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
## Active Phase: Go-to-Market Validation (Pre-PMF)
---
## What's Complete
### Core Platform
- FastAPI project structure with 35+ API endpoints
- PostgreSQL database with Docker, 75+ Alembic migrations
- FastAPI project structure with 55+ API endpoints
- PostgreSQL database with Docker, 100+ Alembic migrations
- User authentication (JWT, register, login, refresh, logout, invite codes)
- Refresh token rotation with JTI-based revocation
- Trees CRUD with full-text search (FTS index)
@@ -53,7 +29,7 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
### Frontend Core
- React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`)
- **Charcoal Design System** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette with sidebar-darkest approach
- **Charcoal Design System v6** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette; accent color is electric blue (#60a5fa), replacing ember orange
- **Brand fonts:** Bricolage Grotesque (headings), IBM Plex Sans (body), JetBrains Mono (code)
- Authentication UI (login, register, email verification)
- Tree library/browsing page with grid/list/table views
@@ -154,6 +130,36 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
- Enhanced PSA metrics: time entries, hours logged, push success funnel, daily trend chart
- 13 new backend tests for coverage and flow quality endpoints
### Conversational Branching (Complete)
- SessionBranch, ForkPoint, SessionHandoff, SessionResolutionOutput models + migration (4 tables, 13 columns)
- BranchManager service, BranchAwarePromptBuilder, HandoffManager service with integration tests
- Branch API endpoints: `session_branches.py`, `session_handoffs.py`, `session_resolutions.py`
- Integrated into `unified_chat_service.py` and AI session step creation
- Frontend: BranchNode, ForkCard, BranchMap, BranchRevivalCard, BranchTransitionBar, HandoffModal, ResolutionOutputPanel components
- Wired into FlowPilotSession and `useFlowPilotSession` hook
### Script Library Enhancements (Complete)
- ParameterizeAndSavePanel replaces SaveToLibraryDialog — accepts `script_body` and `parameters_schema` in save flow
- "New from Script" button on ScriptLibraryPage for one-click script creation from template
- Default tab is "All Scripts" (previously filtered to owned scripts)
- Ownership filter state preserved across category and search changes
- Backend: `save-to-library` endpoint accepts `script_body` + `parameters_schema`
### AI Vision Support (Complete)
- Image uploads (paste/drag-drop) wired into AI assistant chat via `upload_ids`
- Server-side image resize before sending to Claude (Pillow, 1568px max, PNG→JPEG)
- `storage_service.resize_image_for_vision()` handles vision pipeline
- Images are NOT stored in conversation history (text-only history)
### Mid-Session Status Updates (Complete)
- AI assistant can generate `status_update` steps (step_type added to CHECK constraint)
- Status update generation wired into `unified_chat_service.py`
- Frontend renders status update cards in session view
### Search & Recall + Evidence-Rich Sessions (Complete)
**Evidence:**
@@ -187,14 +193,7 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
- SQL wildcard escaping in tag search
- PSA credentials encrypted at rest (Fernet)
### Tenant Isolation (Phases 1-4 Complete)
- PostgreSQL RLS enabled across tenant-scoped tables in phased rollout
- `account_id` propagation completed across core content, sessions, analytics, notifications, shares, and remaining Phase 4 tables
- Global platform tables correctly excluded from tenant RLS where they have no `account_id` (`script_categories`, `platform_steps`, `template_trees`)
- Runtime bootstrap paths updated to use BYPASSRLS/admin sessions where needed (auth/user mutations, startup service account, background jobs, seed scripts)
- Preview Railway backend and frontend deployments green for PR 136 after the Phase 4 fixes
### Copilot-First Dashboard (March 2026)
### Copilot-First Dashboard (MarchApril 2026)
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)
- Chat-style input with paste images, drag-drop files, attach button, paste logs
@@ -204,9 +203,33 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
- Unified Command Palette (Cmd+K) — merged QuickLaunch into omnibar
- "Solutions Library" rename (from "Step Library") site-wide
- Maintenance flows hidden from UI for pilot (backend still supports them)
- Landing page copy rewrite: "Resolve tickets faster. Notes write themselves."
- Spring bounce hover animation on dashboard cards
- Charcoal color palette: sidebar `#10121a`, page `#1a1c23`, cards `#22252e`
- Charcoal color palette: sidebar `#0e1016`, page `#16181f`, cards `#1e2028`
- **Landing page redesign** — scroll-driven reveal animations, live chat animation, FAQ section, improved trust signals; copy: "Resolve tickets faster. Notes write themselves."
- **Session History redesign** — tabbed layout with Load More pagination
- **Edit Procedure page** — layout and color system overhaul
- **TaskLane UX** improvements in assistant chat; persistence across page reload
- TaskLane answers persist in sessionStorage; correct behavior on all three chat paths (send, prefill, resume)
- **Action bar consolidation** — Deduplicated actions across FlowPilot/Cockpit headers and chat toolbars; chat toolbar now only has input tools (Attach, Paste Logs, Tasks)
- **ViewToggle redesigned** as persistent tab bar with bottom-border active indicator and ARIA attributes (FlowPilot/Cockpit switcher)
- **Standardized action naming** across all session pages: Resolve (emerald), Update (blue), Close (rose), Pause (muted)
- **ConcludeSessionModal copy refresh** — Forward-facing action verbs, "Close & Generate" CTA, consistent outcome labels
- Deleted unused FlowPilotActionBar component (227 lines dead code)
### Network Diagrams (In Progress)
- Network diagram editor with React Flow (@xyflow/react v12) canvas
- Device node system: 27 device types across 7 categories (network, compute, storage, cloud, endpoint, infrastructure, security)
- Custom device type creation via DeviceToolbar
- Connection edges with 6 types (ethernet, fiber, wifi, vpn, vlan, wan) — color-coded, dashed for wireless/VPN
- Properties panel for editing device and connection details
- AI-assisted diagram generation (describe network → auto-layout)
- Auto-save every 30 seconds, manual save, JSON export
- **React Flow UI Components** — Cherry-picked and Charcoal-restyled: BaseNode (structured header/content/footer slots), BaseHandle (styled connection handles), LabeledHandle (named port labels), NodeStatusIndicator (status border effect: emerald/red/yellow), NodeTooltip (hover details via NodeToolbar), LabeledGroupNode (subnet/VLAN/site/DMZ containers), AnimatedSvgEdge (traffic flow visualization)
- Grouping category in toolbar: Subnet, VLAN, Site, DMZ drag-drop to canvas
- Traffic flow toggle on edges (switches between static and animated)
- Context menu with copy/paste/duplicate/select all shortcuts
- Drop position uses `screenToFlowPosition()` for correct placement at any zoom/pan level
- **Bug fix:** PropertiesPanel inputs now work — selection uses IDs instead of stale object snapshots
### Maintenance Flows (Hidden from UI)
@@ -231,30 +254,17 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
## What's In Progress
- **Self-serve cutover (Phase O):** PR #164 (open) closes the last code blockers — taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. After merge, remaining work is purely manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass with Andrea Henry + 2-3 external Directors of Onboarding, then `SELF_SERVE_ENABLED=true` flip with frontend redeploy.
- **Stripe live-mode setup:** Test-mode is fully wired (3 products, monthly prices for Starter/Pro, Enterprise sales-led, `plan_billing` seeded via `sync_stripe_plan_ids.py`). Live mode requires manual Dashboard config — same script handles seeding live IDs.
- **GTM Validation:** Shadow & Ship — founder uses product for real MSP tickets daily, then hands logins to 5 colleagues.
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot.
- **GTM Validation:** Shadow & Ship — founder uses product for 2 weeks, then hands logins to 5 colleagues
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot
---
## What's Next (Priority Order)
### Phase O Cutover (Weeks 0-1)
- Merge PR #164
- Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events)
- Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`)
- Run `sync_stripe_plan_ids.py` against prod backend; verify `plan_billing` has `sk_live_*` price IDs
- Internal validation pass (9 scenarios from Phase O Task 46 plan)
- Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`)
- PostHog dashboards + Sentry alert at >1/hour Stripe webhook errors
### Pilot Phase (Weeks 1-2)
- Founder dogfooding: use ResolutionFlow for real MSP tickets daily
- 3 calls with external Directors of Onboarding to validate the documentation-builder thesis (cold pitch, no friendly contacts)
- Collect feedback on copilot-first experience and self-serve onboarding flow
- Collect feedback on copilot-first experience
- Fix issues discovered during real usage
### Post-Pilot (Weeks 3-4)
@@ -279,21 +289,22 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
### Start Development
```bash
# Start PostgreSQL (Docker Compose)
docker compose up -d
# Start PostgreSQL (Docker — container name resolutionflow_postgres, port 5433, DB resolutionflow)
docker start resolutionflow_postgres
# Backend (from backend/)
source venv/bin/activate
uvicorn app.main:app --reload
# Frontend (from frontend/)
# Frontend (from frontend/, requires Node 20)
npm run dev
```
### URLs
- Frontend: http://192.168.0.9:5173
- Backend API: http://192.168.0.9:8000
- API Docs: http://192.168.0.9:8000/api/docs
- Frontend: http://46.202.92.250:5173 (or https via Traefik reverse proxy)
- Backend API: http://46.202.92.250:8000
- API Docs: http://46.202.92.250:8000/api/docs
- Dev env runs on Hostinger VPS (46.202.92.250) with Traefik + HTTPS; see [DEV-ENV.md](DEV-ENV.md)
### Run Tests
```bash

View File

@@ -1,671 +1,262 @@
# ResolutionFlow Dev Environment Setup & Operations Guide
# ResolutionFlow Dev Environment Setup & Operations Guide
> **Scope:** Stand up a working ResolutionFlow dev environment from scratch on any Linux host (VPS, on-prem Proxmox LXC/VM, bare metal). Self-contained — do not read another doc to get the dev stack running.
> **Last rewritten:** April 2026, post-Hostinger-VPS deprecation, ahead of Proxmox migration.
> **Audience:** You (returning to the project), a teammate, or a fresh Claude Code session.
## Server Overview
If you're picking up mid-migration and need to know what code state is on the current branch, read `docs/FlowAssist_Migration/MIGRATION-HANDOFF.md` first.
- **Provider:** Hostinger KVM VPS (srv1522117)
- **IP Address:** 46.202.92.250
- **OS:** Ubuntu 24.04 LTS
- **CPU:** 2 vCPU cores
- **RAM:** 8GB
- **Disk:** 100GB NVMe SSD
- **Swap:** 4GB (`/swapfile`, swappiness=10)
---
## Architecture
## 1. What this project needs, regardless of host
All services run as Docker containers on the host, managed via SSH or from the VS Code Server integrated terminal.
These are non-negotiable. If your host can't provide them, fix that before anything else.
```
Host (root@srv1522117)
├── Traefik → reverse proxy + auto SSL (Let's Encrypt)
├── VS Code Server → browser IDE at https://code.resolutionflow.com
└── ResolutionFlow Stack
├── resolutionflow_frontend → Vite/React on port 5173
├── resolutionflow_backend → FastAPI/Uvicorn on port 8000
└── resolutionflow_postgres → PostgreSQL 16 + pgvector on port 5432
```
| Component | Required version | Notes |
|---|---|---|
| **Linux** | any mainstream distro | Ubuntu 22.04+ / Debian 12+ tested; Alpine fine for containers |
| **Python** | 3.11+ | Backend and migrations |
| **Node.js** | 20.19+ | Vite 7 fails on older versions — CLAUDE.md Lesson 63 |
| **PostgreSQL** | 16 | `gen_random_uuid()` + `jsonb` + RLS are all leaned on |
| **Docker + Docker Compose** | recent | Only if you are running Postgres and/or backend as containers |
| **Git** | recent | |
## Access URLs
Optional but recommended:
| Tool | Why |
| Service | URL |
|---|---|
| **code-server** | Browser-based VS Code; how this project has historically been edited |
| **`gh` CLI** | Mirror repo is on GitHub via Gitea; `gh` reads issues and PRs |
| **bun** | Required for the gstack `/browse` + `/qa` skills (CLAUDE.md Lesson 82) |
| **`npx gitnexus analyze`** | Code-graph for Phase 2+ work that touches `unified_chat_service` |
| **Claude Code CLI** | If you want to run Claude Code locally on the host |
| VS Code Server | https://code.resolutionflow.com |
| Frontend (dev) | http://46.202.92.250:5173 |
| Backend API | http://46.202.92.250:8000 |
| API Docs | http://46.202.92.250:8000/docs |
---
## 2. Architectural shape
The project is three services plus your editor. Keep these facts in mind regardless of topology:
## Docker Layout
```
Your browser
├─► code-server (editor, optional — usually port 8080 or behind TLS)
├─► frontend (Vite) (dev server, port 5173)
└─► backend (FastAPI) (dev server, port 8000)
└─► PostgreSQL (port 5432)
/docker/
├── traefik/
├── docker-compose.yml → Traefik reverse proxy
└── .env → ACME_EMAIL for Let's Encrypt
└── vscode/
├── docker-compose.yml → VS Code Server
└── .env → CODE_PASSWORD
```
**The frontend calls the backend by URL at runtime.** The frontend does not proxy through the backend. Whatever URL your browser uses to reach the backend is what `VITE_API_URL` must be set to, **baked in at build time**. Changing `VITE_API_URL` requires rebuilding the frontend.
**The backend calls the database by URL at runtime.** The URL depends on where Postgres is relative to the backend — Docker service name if both are in the same compose network, `localhost` if Postgres is native on the same host, or a DNS name if they're in separate containers/VMs.
**CORS is configured explicitly.** The backend's `CORS_ORIGINS` list must include every origin your browser will use to reach the frontend. A missing origin shows up as failed preflight requests.
---
## 3. Topology choices — pick one before you start
The project is agnostic to topology, but each shape has different setup steps.
### Option A — all-in-one LXC/VM/host (simplest)
Postgres, backend, and frontend all run on one Linux host. code-server runs on the same host or a sibling. No Docker required. Best for a single-developer Proxmox LXC.
### Option B — Docker Compose on one host
Postgres, backend, and frontend run as Docker containers on one host. code-server runs outside the compose network (on the host or in another container). This is how the old Hostinger VPS was configured. Best if you want reproducible container images.
### Option C — split services across containers/VMs
Postgres in one container/VM, backend and frontend in another, code-server in a third. Most complex; requires explicit networking between them. Use only if you have a specific reason.
**Pick one and stick with it for the entire setup.** Mixing Options A and B halfway through is where setup runs off the rails.
---
## 4. Per-host configuration
These values are specific to your host. Fill them in once and reference them by name throughout the rest of the doc.
Project lives inside the VS Code Server Docker volume:
```
DEV_HOST = <hostname or IP your browser uses, e.g. dev.internal, 10.0.0.42>
DEV_HOST_SCHEME = <http or https; http is fine for internal dev, https if behind a TLS proxy>
FRONTEND_PORT = 5173
BACKEND_PORT = 8000
POSTGRES_PORT = 5433 # host-side port. 5433 is the recommended default on any shared host to avoid collision with a host-level Postgres. The container's internal port stays 5432.
POSTGRES_DB_NAME = resolutionflow
POSTGRES_USER = postgres
POSTGRES_PASSWORD = <local-dev-password; anything, this is not prod>
SECRET_KEY = <openssl rand -hex 32 — generate fresh per host, do not reuse>
ANTHROPIC_API_KEY = <from https://console.anthropic.com>
GOOGLE_AI_API_KEY = <optional, only if using Gemini as a fallback>
/var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow/
```
Store these somewhere you can copy from during setup. Do not commit them.
## VS Code Server
> **Naming note:** the canonical database name is `resolutionflow`. If you see `patherly` in a config file, that's drift from an earlier rename and is being swept in a separate commit — use `resolutionflow`. CLAUDE.md tracks the live-code files that still reference `patherly`.
- **Container user:** `coder` (UID 1000)
- **Home directory:** `/home/coder`
- **Project location:** `/home/coder/resolutionflow`
- **Host volume path:** `/var/lib/docker/volumes/vscode_vscode-data/_data`
- **Access URL:** `https://code.resolutionflow.com`
- **HTTPS:** Auto-provisioned via Traefik + Let's Encrypt
---
### Compose File Location
`/docker/vscode/docker-compose.yml`
## 5. Setup procedure
## Traefik
Run these in order. Stop at the first failure and investigate.
Handles reverse proxying and automatic SSL for all services. HTTP automatically redirects to HTTPS.
### 5.1 Install system dependencies
### Adding A New Service Behind Traefik
Add these labels to any new Docker service:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.<n>.rule=Host(`subdomain.resolutionflow.com`)"
- "traefik.http.routers.<n>.entrypoints=websecure"
- "traefik.http.routers.<n>.tls.certresolver=letsencrypt"
- "traefik.http.services.<n>.loadbalancer.server.port=<port>"
```
Also create an A record in DNS pointing the subdomain to `46.202.92.250`.
## ResolutionFlow Dev Stack
### Important: No Docker Inside VS Code Container
The VS Code Server container does NOT have Docker. All `docker compose` commands must be run via SSH as root on the host.
### Environment Files
| File | Purpose |
|---|---|
| `.env` | Root — Docker Compose interpolation (`SECRET_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_AI_API_KEY`, `POSTGRES_PORT`) |
| `backend/.env` | Backend source of truth — all FastAPI settings, API keys, DB URLs, CORS |
| `frontend/.env` | Frontend — `VITE_API_URL` pointing to backend |
### Critical Remote Access Config
**`frontend/.env`:**
```
VITE_API_URL=http://46.202.92.250:8000
```
**`backend/.env`:**
```
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
FRONTEND_URL=http://46.202.92.250:5173
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
```
Note: `DATABASE_URL` uses `@db:5432` (Docker service name), not `@localhost`.
**`docker-compose.dev.yml`:**
```yaml
- VITE_API_URL=http://46.202.92.250:8000
```
### Starting the Dev Environment
SSH into host as root:
```bash
# Ubuntu / Debian
sudo apt update && sudo apt install -y \
git curl build-essential \
python3.12 python3.12-venv python3-pip \
postgresql-client # not the server — only if running Postgres natively
# Node 20 via nvm (survives container rebuilds if stored in a volume)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
nvm install 20
nvm alias default 20
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
docker compose -f docker-compose.dev.yml up -d
```
For Option B (Docker Compose), also:
### Running Migrations (Fresh Database)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # log out and back in for this to take effect
```
### 5.2 Clone the repo
```bash
git clone https://gitea.resolutionflow.com/chihlasm/resolutionflow.git
# or the GitHub mirror:
# git clone https://github.com/chihlasm/resolutionflow.git
cd resolutionflow
# Check out the working branch if you're continuing mid-migration.
git fetch origin
git checkout feat/flowpilot-migration
```
### 5.3 Start PostgreSQL
**Option A (native Postgres on the host):**
```bash
sudo apt install -y postgresql-16
sudo -u postgres psql -c "CREATE DATABASE resolutionflow;"
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
# Adjust pg_hba.conf if you need non-local connections.
```
**Option B (Postgres via Docker Compose):** The repo has a `docker-compose.dev.yml` at the root. Check its Postgres service for the container name, port mapping, and volume. The local compose defaults use container name `resolutionflow_postgres`, database `resolutionflow`, and host-side port `5433` (mapped to the container's internal `5432`) — see CLAUDE.md Lesson 65. The host-side `5433` is the recommended default on any shared host: it keeps the port free for a host-level Postgres if you ever need one. The compose file also defines explicit `command:` directives on both `backend` and `frontend` to force `--host 0.0.0.0`, and expects the caller to pass `REPO_ROOT` (see 5.4) for bind-mount resolution. Confirm what the compose file actually says on your branch before trusting these values.
```bash
docker compose -f docker-compose.dev.yml up -d db
docker compose -f docker-compose.dev.yml logs db # wait for "ready to accept connections"
```
**Verify:**
```bash
# From the host (Option A) or the backend container/LXC (Option B):
psql -h <db-host> -p <POSTGRES_PORT> -U postgres -d resolutionflow -c "SELECT now();"
```
### 5.4 Write the `.env` files
The repo expects three env files. Create each one:
**`backend/.env`** — backend source of truth:
```bash
APP_NAME=ResolutionFlow
DEBUG=true
# DB URLs — `<db-host>` is `localhost` for Option A, the Docker service name
# (e.g. `db`) for Option B, or the DB container/VM hostname for Option C.
DATABASE_URL=postgresql+asyncpg://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
DATABASE_URL_SYNC=postgresql://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
# Auth
SECRET_KEY=<SECRET_KEY>
ACCESS_TOKEN_EXPIRE_MINUTES=5
REFRESH_TOKEN_EXPIRE_DAYS=7
REQUIRE_INVITE_CODE=true
# AI providers
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
# FlowPilot MCP telemetry — leave on so the Phase 0.5 baseline data keeps accruing
ENABLE_MCP_MICROSOFT_LEARN=true
# CORS + frontend URL
FRONTEND_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>"]
```
**`frontend/.env.local`** — frontend build-time config:
```bash
VITE_API_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>
```
Optional PostHog (CLAUDE.md Lesson 64 — enables product analytics locally):
```bash
VITE_PUBLIC_POSTHOG_KEY=<from PostHog project settings>
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
**Repo root `.env`** — only needed for Option B (Docker Compose interpolation):
```bash
SECRET_KEY=<SECRET_KEY>
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
POSTGRES_PORT=<POSTGRES_PORT>
# Absolute host-side path to the repo root. REQUIRED whenever docker-compose is
# invoked from inside a container (e.g. a code-server container with the host
# Docker socket mounted in). Without it, the bind mounts in
# docker-compose.dev.yml (`${REPO_ROOT}/backend:/app`, `${REPO_ROOT}/frontend:/app`)
# resolve against the CLI's CWD — a path the host daemon cannot see — and
# Docker silently creates empty directories there instead of mounting the code.
# If you run docker compose directly on the host shell, you can set this to `.`
# or the absolute path of the repo; being explicit is safer either way.
REPO_ROOT=/absolute/path/to/resolutionflow
```
> **Never commit any `.env` file.** The `.gitignore` already covers this.
### 5.5 Run the backend setup
**Option A (native):**
```bash
cd backend
python3.12 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Migrate the DB to head.
alembic upgrade head
```
**Option B (Docker):**
```bash
docker compose -f docker-compose.dev.yml up -d backend
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
```
**Expected alembic head** (as of `feat/flowpilot-migration`): `f07010f17b01`. If `alembic current` shows anything else after `upgrade head`, something has gone wrong — stop and investigate.
### 5.6 Seed test users
### Seeding Test Users
```bash
# Option A
cd backend && source venv/bin/activate
python -m scripts.seed_test_users
# Option B
docker exec resolutionflow_backend python -m scripts.seed_test_users
```
Test users (all share password `TestPass123!`):
Test accounts (password: `TestPass123!`):
| Email | Role |
|---|---|
| `admin@resolutionflow.example.com` | super admin |
| `teamadmin@resolutionflow.example.com` | team admin |
| `engineer@resolutionflow.example.com` | engineer |
| `pro@resolutionflow.example.com` | solo pro |
| Email | Role | Plan |
|---|---|---|
| admin@resolutionflow.example.com | Owner | Team |
| pro@resolutionflow.example.com | Owner | Pro |
| teamadmin@resolutionflow.example.com | Owner | Team |
| engineer@resolutionflow.example.com | Engineer | Shared |
### 5.7 Run the backend
**Option A:**
```bash
cd backend && source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
**Option B:** Already running from `docker compose up -d backend`. Tail logs:
```bash
docker compose -f docker-compose.dev.yml logs -f backend
```
**Verify:** `curl <DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>/api/docs` — OpenAPI docs page loads.
### 5.8 Run the frontend
**Option A:**
```bash
cd frontend
npm install
npm run dev -- --host 0.0.0.0 --port 5173
```
**Option B:**
### Rebuilding After Config Changes
**Frontend** (Vite bakes env vars at build time — requires rebuild):
```bash
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
docker compose -f docker-compose.dev.yml up -d --build frontend
```
**Verify:** Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>` in your browser. Log in with one of the test users. Navigate to `/pilot` — the FlowPilot session page should render.
---
## 6. Verification — proof the env actually works
Run these after setup. Every item has a concrete expected outcome.
### 6.1 Database schema is at the right version
**Backend** (restart only):
```bash
# Option A
cd backend && source venv/bin/activate && alembic current
# Option B
docker compose -f docker-compose.dev.yml run --rm backend alembic current
```
Expected: `f07010f17b01 (head)` on the `feat/flowpilot-migration` branch. On `main`, expected: `074 (head)`.
### 6.2 Alembic reversibility
```bash
alembic downgrade -1 # should complete cleanly
alembic upgrade head # should return to f07010f17b01
```
If either step fails, the migration has a bug and Phase 2 cannot start.
### 6.3 Prompt-cache hit verification (the deferred Phase 0 TODO)
`backend/app/core/ai_provider.py` module docstring has a `TODO(phase0-verify)` note describing this. Procedure:
1. Confirm `AI_PROVIDER=anthropic` and `ANTHROPIC_API_KEY` is set in `backend/.env`.
2. Start the backend with log level INFO or lower.
3. In the UI, open `/pilot` and send a chat message. Wait a few seconds for the response.
4. Send a second chat message in the same session, within 5 minutes of the first.
5. In backend logs, grep for lines containing `anthropic.cache`:
```bash
# Option A
grep 'anthropic.cache' <log-path>
# Option B
docker compose -f docker-compose.dev.yml logs backend | grep 'anthropic.cache'
```
6. Expected: two `anthropic.cache` log events. First has `cache_creation_input_tokens > 0`. Second has `cache_read_input_tokens > 0`.
7. If the second shows zero reads, inspect the prompt prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding with any Phase 2 work.
### 6.4 Frontend build is TypeScript-clean
```bash
cd frontend
npx tsc -b # no errors
npm run build # no errors
```
CLAUDE.md Lesson 105 notes that `npm run build` may fail with an `EACCES` on `dist/` inside code-server — that is a Docker filesystem permission issue, not a real build error. Use `npx tsc -b` to verify TypeScript cleanliness in that case.
### 6.5 `/assistant` → `/pilot` redirect
Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>/assistant/<some-real-session-id>` in the browser. Expected: URL changes to `/pilot/<that-id>`; the FlowPilot session page renders. Bare `/assistant` redirects to bare `/pilot`.
### 6.6 Dispatcher de-branching
Navigate to the dashboard. Click a session in `ActiveFlowPilotSessions` or `RecentFlowPilotSessions`. Expected: routes to `/pilot/:id` regardless of the session's `session_type` value. (Check the browser URL bar.)
### 6.7 CORS
Open the browser DevTools Network tab, navigate to any backend-hitting page. Expected: no CORS errors. If you see "blocked by CORS policy," the missing origin needs adding to `backend/.env`'s `CORS_ORIGINS`.
---
## 7. Runbook
Day-to-day commands after setup is complete.
### Restart services
```bash
# Option A
# backend — Ctrl-C and re-run uvicorn
# frontend — Ctrl-C and re-run npm run dev
# Option B
docker compose -f docker-compose.dev.yml restart backend
docker compose -f docker-compose.dev.yml up -d --build frontend # rebuild required if VITE_* changed
docker compose -f docker-compose.dev.yml down && docker compose -f docker-compose.dev.yml up -d # full restart
```
### Apply a new migration
**Full restart:**
```bash
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.dev.yml up -d
```
## Installed Tools (Inside VS Code Server Container)
Installed in `/home/coder` — persists via Docker volume:
- **nvm** — Node version manager
- **Node.js 20.x** — via nvm, default alias set
- **npm** — latest
- **GitHub CLI (gh)** — authenticated via personal access token
- **Claude Code CLI** — `@anthropic-ai/claude-code` (global npm)
### Permanent Tool Installs
Tools installed via `apt` inside the container do NOT survive container rebuilds. To add permanently, modify the VS Code Server Docker image and rebuild.
Temporary (session only):
```bash
sudo apt update && sudo apt install -y <tool>
```
## SSH Access
```bash
# Option A
cd backend && source venv/bin/activate && alembic upgrade head
# Option B
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
ssh root@46.202.92.250
```
### Create a new migration
Key auth configured via `~/.ssh/authorized_keys` on host.
## Useful Commands
### Check all running containers
```bash
# Option A
cd backend && source venv/bin/activate
alembic revision -m "short description" # manual, preferred per CLAUDE.md Lesson 77
# OR
alembic revision --autogenerate -m "description" # pulls in drift; review carefully
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
Never pass `--rev-id` — let Alembic generate the hex hash.
### Inspect the database
### View container logs
```bash
# Option A (native Postgres)
psql -h localhost -p 5432 -U postgres -d resolutionflow
# Option B (Docker)
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
docker logs <container_name> --tail 30 -f
```
### Run tests
### Restart VS Code Server
```bash
# Option A
cd backend && source venv/bin/activate
pytest --override-ini="addopts="
# Option B
docker compose -f docker-compose.dev.yml run --rm backend pytest --override-ini="addopts="
cd /docker/vscode && docker compose restart
```
First time only, create the test database:
### Restart Traefik
```bash
# Option A
sudo -u postgres psql -c "CREATE DATABASE resolutionflow_test;"
# Option B
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
cd /docker/traefik && docker compose restart
```
### View backend logs
### Restart dev stack
```bash
# Option A: wherever you ran uvicorn
# Option B
docker compose -f docker-compose.dev.yml logs -f --tail=100 backend
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.dev.yml up -d
```
Structured events to grep for:
- `anthropic.cache` — prompt-cache hit/creation telemetry (Phase 0.1)
- `mcp.turn` — per-turn MCP availability/invocation (Phase 0.5)
- `mcp.fallback` — MCP silent-retry fallback fired (Phase 0.5)
---
## 8. Troubleshooting
### CORS errors in the browser
The backend did not accept the origin your browser used. Check `backend/.env`'s `CORS_ORIGINS` — it must include the exact scheme + host + port the browser sent. Restart the backend after editing.
### `VITE_API_URL` points at the wrong place
The frontend was built with a stale value. Rebuild the frontend. Option B: `docker compose up -d --build frontend`. Option A: restart `npm run dev`.
### `alembic upgrade head` fails with "target database is not up to date"
Your DB migration chain is out of sync with the code. On a dev box, the safe recovery is to drop the DB and re-migrate from scratch:
### Check swap
```bash
# Option A
sudo -u postgres psql -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
cd backend && source venv/bin/activate && alembic upgrade head
# Option B
docker exec resolutionflow_postgres psql -U postgres -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
free -h && swapon --show
```
Only do this on a dev box — it destroys all local data.
### `alembic heads` shows more than one head
Only on a local branch that has diverged from `origin/main`. Production `main` has a single head. If this happens on a fresh clone, one of your local migration files has the wrong `down_revision`. Inspect each file's `down_revision` and reconnect the chain.
### Frontend build fails with "EACCES: permission denied" on `dist/`
Filesystem permission issue inside the code-server container (CLAUDE.md Lesson 105). TypeScript compilation itself completes — use `npx tsc -b` to verify cleanliness without needing to write to `dist/`.
### Backend/frontend containers start but `/app` is empty (no code mounted)
Almost always a `REPO_ROOT` problem. `docker-compose.dev.yml` uses `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app` bind mounts. If `REPO_ROOT` is unset, or set to a path that doesn't exist *on the Docker host* (not inside the code-server container), Docker silently creates an empty directory at that path and mounts it — the containers come up but have no source code. Symptom: backend returns import errors, or frontend serves a default Vite page. Fix: set `REPO_ROOT` in the repo-root `.env` to the absolute host-side path to the repo, then `docker compose down && docker compose up -d`. See 5.4 for the full note. This matters specifically when `docker compose` is invoked from inside a container (e.g. code-server with the host Docker socket mounted) — the CLI's CWD is container-local but the daemon resolves paths against the host filesystem.
### Frontend shows "Blocked request. This host is not allowed" in the browser
Vite 5+ ships DNS-rebinding protection that rejects any `Host:` header not in `server.allowedHosts`. The browser's hostname must be in that list. Edit `frontend/vite.config.ts` — the `server.allowedHosts` array should include every hostname you reach the dev server from (e.g. `'docker-01'`, `'localhost'`, `.ts.net` as a wildcard for Tailscale MagicDNS). Restart the Vite dev server (for Option B: `docker compose restart frontend`). This is unrelated to CORS — Vite blocks the request before any app code runs.
### `docker` command not found inside code-server
If your code-server is itself inside a container, Docker is probably not exposed to it. CLAUDE.md Lesson 103 was written for this case on the old VPS. On Proxmox, the fix depends on topology — either SSH to the host to run Docker commands, or mount the host's Docker socket into the code-server container.
### Backend returns 500 with `InsufficientPrivilegeError: new row violates row-level security policy`
RLS is enabled on a table your code wrote to without the right `account_id`. CLAUDE.md Lessons 107, 108, 110 cover this family of bugs. The fix is always at the service layer: make sure every model creation passes `account_id=` explicitly, and that startup routines that touch tenant-isolated tables use `_admin_session_factory()` rather than `get_db()`.
### Anthropic cache reads are zero on the second turn
Something in the cached prefix is changing between turns. Inspect the system-block list and the first N history messages for timestamps, `datetime.now()`, unsorted dict keys in JSON prompts, or varying tool-list order. The `anthropic.cache` telemetry shows exactly how many tokens were read vs created — use it to narrow down the invalidator.
---
## 9. Security posture for dev environments
This doc is about dev, not production. But:
- Never commit `.env` files. The `.gitignore` covers this.
- `SECRET_KEY` should be generated per-host, not reused across environments.
- `ANTHROPIC_API_KEY` is billable — rotate if leaked into logs or chat.
- Postgres on a dev host should not be exposed to the internet. Bind it to `127.0.0.1` or to a private network interface only.
- If you expose the frontend or backend publicly (for teammates to test against), put it behind TLS with a real certificate. Do not let dev credentials travel over plain HTTP on the public internet.
---
## 10. What's not in this doc
- **Production deployment.** This is a dev-env doc. Production lives on Railway — see `CLAUDE.md`'s Deployment section.
- **How to set up Traefik or any particular reverse proxy.** Whichever proxy you use is your choice; the dev stack just needs something that routes `<host>:5173` and `<host>:8000` to the right services. **Direct port exposure over a private network** (Tailscale, WireGuard, a VPN, or a LAN behind a firewall) is a fully supported option for dev and is what the homelab reference topology in Section 11 uses — no reverse proxy, no TLS, just `http://<host>:5173` and `http://<host>:8000` reachable only from the private network. That's a perfectly reasonable choice; it's just not the only one.
- **How to configure code-server itself.** Install it however you prefer (native, Docker, LXC); point it at the repo, and the rest of this doc applies.
- **Where to host the Proxmox instance.** Up to you.
If something in this doc turns out to be wrong on your host, fix the doc. This is a living document — the whole point of rewriting it from the Hostinger-specific version was to make it survive host changes.
---
## 11. Reference topology: homelab Proxmox + code-server (Option B)
This section documents the first concrete host instantiation since the April 2026 host-agnostic rewrite. It's a worked example, not the canonical topology — Section 3's Option A/B/C framing still stands. If your setup looks different, follow Sections 110 and ignore this appendix.
### 11.1 Host
- **Hypervisor:** Proxmox (homelab).
- **VM:** `docker-01`, Debian 13, running Docker Engine + Docker Compose natively.
- **Tailscale IP:** `100.64.78.44`. MagicDNS hostname: `docker-01` (and the full `.ts.net` FQDN).
- **code-server:** runs on the same VM in its own container, with the host's Docker socket mounted in so it can drive `docker compose`. Its workspace bind-mounts the repo at `/opt/docker/code-server/workspace/resolutionflow`.
This is a concrete instance of Option B from Section 3: Postgres, backend, and frontend all run as containers from `docker-compose.dev.yml`; the editor lives outside that compose network.
### 11.2 Access pattern — direct port over Tailscale, no reverse proxy
The browser reaches the dev stack directly:
- Frontend: `http://docker-01:5173`
- Backend: `http://docker-01:8000`
- Backend API docs: `http://docker-01:8000/api/docs`
There is **no Caddy, no Traefik, no nginx, no TLS, no basic auth** in front of either service. The tailnet provides the wire encryption and access control — only devices on the tailnet can resolve `docker-01` or reach `100.64.78.44`, and Tailscale ACLs decide which of those devices are allowed to connect.
Why this choice:
- **Zero routing config to maintain.** There is no proxy rulebook to keep in sync with new services. Add a container, expose a port, you're done.
- **Backend-to-backend services stay private.** Redis, Celery workers, the planned ConnectWise proxy, the MCP server — none of them need to be reachable from the browser, so none of them need proxy rules. They stay inside the `resolutionflow` Docker network and talk by service name. The proxy would only ever have carried frontend and backend traffic, so the proxy's value was small relative to its maintenance cost.
- **Debuggability.** `curl http://docker-01:8000/api/docs` from any tailnet device works without auth headers, TLS handshakes, or DNS shenanigans.
Tradeoff: **this only works because every client device is on the tailnet.** If someone needed to test from a non-tailnet device, they'd either join the tailnet or we'd need to front the stack with a proxy. For the current single-developer setup, the tailnet-only assumption holds.
### 11.3 Per-host config values (as actually configured on `docker-01`)
Plugging these into Section 4's template:
```
DEV_HOST = docker-01
DEV_HOST_SCHEME = http
FRONTEND_PORT = 5173
BACKEND_PORT = 8000
POSTGRES_PORT = 5433 # host-side; container-internal stays 5432
POSTGRES_DB_NAME = resolutionflow
POSTGRES_USER = postgres
POSTGRES_PASSWORD = postgres # local-dev only
SECRET_KEY = <generated per host; do not reuse>
ANTHROPIC_API_KEY = <from console.anthropic.com>
GOOGLE_AI_API_KEY = <unset; Anthropic is sole provider in dev>
```
And the repo-root `.env` that `docker-compose.dev.yml` interpolates from:
### Check disk
```bash
SECRET_KEY=<redacted>
ANTHROPIC_API_KEY=<redacted>
POSTGRES_PORT=5433
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
df -h
```
### 11.4 Why `REPO_ROOT` is non-optional on this host
code-server runs inside a container. When you open a terminal in code-server and run `docker compose -f docker-compose.dev.yml up -d`, the Docker CLI talks to the *host* daemon via the mounted socket — but the CWD it reports (`/config/workspace/resolutionflow`) is a path that only exists inside the code-server container. The host daemon has never heard of it.
Relative bind mounts like `./backend:/app` therefore resolve against a path the host can't see, and Docker silently creates empty directories there rather than erroring out. The containers come up, but `/app` is empty.
`docker-compose.dev.yml` sidesteps this by using `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app`. `REPO_ROOT` must be set to the absolute path **on the host** (`/opt/docker/code-server/workspace/resolutionflow`), not the path inside the code-server container. Same contents, different mount point, different name.
If you ever run `docker compose` directly from a host shell (SSH'd into `docker-01`), set `REPO_ROOT` to `.` or the absolute host path. Being explicit is always safe; leaving it unset is the failure mode.
### 11.5 Vite `server.allowedHosts` — required for `docker-01` to resolve
Vite 5+ rejects any `Host:` header not in `server.allowedHosts` (DNS-rebinding protection). `frontend/vite.config.ts` has:
```ts
server: {
host: '0.0.0.0',
allowedHosts: ['docker-01', '.ts.net', 'localhost'],
...
}
```
- `docker-01` — the MagicDNS short name the browser uses day-to-day.
- `.ts.net` — wildcard for the full Tailscale MagicDNS FQDN, in case anyone uses it.
- `localhost` — for the "am I serving anything at all" smoke-test from inside the container.
If you move this setup to a different host, add that host's hostname to `allowedHosts` or the browser will see "Blocked request. This host is not allowed." See Section 8's troubleshooting entry for the full symptom/fix.
### 11.6 CORS origins on this host
The `backend` service's `CORS_ORIGINS` environment variable is pinned in the compose file to:
```
["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
```
The last two are what make browser calls from tailnet clients work — they cover both MagicDNS (`docker-01`) and the raw Tailscale IP. If you add a new hostname to reach the frontend from, also add the matching origin here and restart the backend.
### 11.7 Compose file shape (as of this writing)
`docker-compose.dev.yml` has been through a round of cleanup for this topology. Specifics worth knowing if you're comparing against older revisions of the file:
- **No Traefik labels.** They were removed — nothing in this topology uses Traefik.
- **No Hostinger-VPS-era origins** in `CORS_ORIGINS`.
- `Dockerfile.dev` for both `backend` and `frontend` is still the build source — this didn't change.
- Explicit `command:` directives on both `backend` (`uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`) and `frontend` (`npm run dev -- --host 0.0.0.0 --port 5173`) — this guarantees `--host 0.0.0.0` regardless of what's baked into the image, so the services listen on all interfaces and are reachable from outside the container.
- `REPO_ROOT` is interpolated into both service volume mounts (see 11.4).
If you're adapting the file for a different host, the things most likely to need editing are `REPO_ROOT` (see 11.4), `CORS_ORIGINS` (see 11.6), `FRONTEND_URL`, `VITE_API_URL`, and `POSTGRES_PORT` if you want something other than `5433`.
### 11.8 End-to-end sanity check for this topology
From any device on the tailnet:
### Check memory + container usage
```bash
# Backend reachable
curl -sSf http://docker-01:8000/api/docs >/dev/null && echo OK
# Frontend reachable
curl -sSf http://docker-01:5173 >/dev/null && echo OK
# Alembic head matches the branch expectation
docker exec resolutionflow_backend alembic current
# expect f07010f17b01 on feat/flowpilot-migration, 074 on main
# Postgres is alive inside the compose network
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT now();"
free -h && docker stats --no-stream
```
All four passing = the dev environment is live end-to-end.
## DNS Records (resolutionflow.com)
| Type | Name | Value | Purpose |
|---|---|---|---|
| A | code | 46.202.92.250 | VS Code Server |
## Security Notes
- UFW is inactive — Traefik and Docker manage port exposure
- All public-facing services run through Traefik with valid HTTPS certs
- PostgreSQL port 5432 is exposed on all interfaces — restrict if needed in production
- Rotate API keys (Anthropic, Voyage) if ever exposed in logs or chat
- Never commit `.env` files to Git
## VS Code Server Browser Tips
- **Command Palette:** `F1`
- **Terminal:** Ctrl+`
- **Rename file:** `F2`
- **Go to definition:** `F12`
- **Find references:** `Shift+F12`
- **Context Menu:** `Alt + Right Click`

View File

@@ -11,10 +11,10 @@
## Quick Start
```bash
# Prerequisites: Docker, Python 3.12, Node.js 20+
# Prerequisites: Docker, Python 3.11+, Node.js 20+
# Start PostgreSQL (and the rest of the dev stack)
docker compose -f docker-compose.dev.yml up -d
# Start PostgreSQL
docker start patherly_postgres
# Backend
cd backend
@@ -105,17 +105,16 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi
## Project Structure
```
resolutionflow/
patherly/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (50+ endpoints)
│ │ ├── api/endpoints/ # Route handlers (35+ endpoints)
│ │ ├── core/ # Config, database, permissions, security
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ └── services/psa/ # PSA provider abstraction layer
│ ├── alembic/ # Database migrations
│ ├── scripts/ # Seed + sync scripts (incl. sync_stripe_plan_ids.py)
│ └── tests/ # Integration tests (100+)
├── frontend/
│ ├── src/
@@ -123,19 +122,13 @@ resolutionflow/
│ │ ├── pages/ # Page components
│ │ ├── store/ # Zustand stores
│ │ └── types/ # TypeScript interfaces
├── .ai/ # Dual-agent handoff system (PROJECT_CONTEXT, HANDOFF, etc.)
├── docs/ # Design docs, plans, ConnectWise reference
├── brand-assets/ # SVGs, brand guide
├── CLAUDE.md # AI assistant project context (Claude Code)
├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md)
├── CLAUDE.md # AI assistant project context
├── CURRENT-STATE.md # Detailed feature status
├── DESIGN-SYSTEM.md # Visual + interaction design system
├── PRODUCT.md # Design intent and brand personality
└── CHANGELOG.md # Release history
```
> The on-disk repo path is `resolutionflow/`. `patherly` is the legacy internal name — still appears in some Railway service names and the prod DB name. Treat as an alias, not canonical.
---
## Running Tests
@@ -156,13 +149,10 @@ npm run build
| Document | Purpose |
|----------|---------|
| [CLAUDE.md](CLAUDE.md) | Project context for Claude Code |
| [AGENTS.md](AGENTS.md) | Project context for Codex (shared protocol with CLAUDE.md) |
| [.ai/PROJECT_CONTEXT.md](.ai/PROJECT_CONTEXT.md) | Stable architectural truth |
| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development |
| [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status |
| [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap |
| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) |
| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality |
| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) |
| [DEV-ENV.md](DEV-ENV.md) | Development environment setup |
| [CHANGELOG.md](CHANGELOG.md) | Release history |

70
SESSION-HANDOFF.md Normal file
View File

@@ -0,0 +1,70 @@
# Session Handoff — Design System v4 Migration
> **For the next Claude session:** Read this file completely, internalize the context, then delete it (`rm SESSION-HANDOFF.md`). This is a one-time context transfer.
---
## What Was Done This Session
### 1. FlowPilot Message Bar + AI Script Builder (MERGED to main)
- PR #118 merged. Always-visible message bar in FlowPilot sessions, AI Script Builder at `/script-builder`, library reorg (My/Team Scripts tabs), FlowPilot-to-Script-Builder handoff, session abandon/close, unified session history.
- Eng review completed: normalized `script_builder_messages` table, typed content helpers, 6 edge case tests.
### 2. Design System v4 Migration (PR #119, open, branch: `refactor/design-system-v4`)
- Complete frontend redesign from glassmorphism to flat dark theme (Sentry/PostHog-inspired)
- **CSS Foundation:** New color tokens in `index.css`, all via CSS custom properties. Light mode ready (just needs `.light` class values).
- **Icon Rail Sidebar:** 72px rail with 5 grouped icons (Home, Work, Knowledge, Insights, Help). Full-height resizable drawer on hover. Pin-to-expand to 260px. Mobile hamburger overlay.
- **Component Sweep:** ~200 files migrated. All hardcoded hex replaced with semantic Tailwind tokens (bg-card, text-foreground, border-border, etc.).
- **Landing Page:** Flat surfaces, no glow, solid buttons.
- **Interactive Shadows:** Dark-mode-aware — elevated surfaces + faint cyan accent glow (black shadows invisible on dark bg).
- **Stat Cards:** 3px colored left borders.
- **Tab Toggles:** Active state uses `tab-active-shadow` (elevated bg + faint glow).
### 3. GTM Strategy (from /office-hours)
- Shadow & Ship approach: Michael uses ResolutionFlow on real tickets for 2 weeks, then hands logins to 5 MSP colleagues. Key metric: unprompted return.
- Design doc at `~/.gstack/projects/patherly-patherly/`
---
## What Needs To Be Done Next
### Immediate (Design System v4 polish)
1. **Home icon color fix:** The Home icon in the sidebar shouldn't have a cyan background when not active. Instead, the Home icon itself should always be cyan (brand accent), and only show the `bg-accent-dim` background when the route is actually `/`. Michael specifically requested this.
2. **Visual QA pass:** Michael hasn't done a full page-by-page walkthrough yet. Expect feedback on individual pages once he does.
3. **`font-label` cleanup:** ~10 files still reference `font-label` (deprecated alias for `font-mono`). Each needs inspection — some should be `font-mono`, others `font-sans text-xs`.
4. **Inline `style` attributes:** ~29 instances still use hardcoded hex in inline styles (sidebar, drawer, badges). Should be converted to CSS variable references or Tailwind classes where possible.
### Before Merging PR #119
- Run migrations: `docker exec resolutionflow_backend alembic upgrade head` (new tables from the Script Builder PR are on main now)
- Full visual QA with backend running
- Test mobile responsive (hamburger menu)
- Test FlowPilot session with new message bar + action bar positioning
### Future
- **Light mode toggle:** CSS variables are ready. Need to add `.light` class values in `index.css` + toggle in user settings/account page.
- **Script Builder testing:** The AI Script Builder hasn't been tested end-to-end with the backend running yet.
---
## Key Files to Know
| File | What it does |
|------|-------------|
| `DESIGN-SYSTEM.md` | Single source of truth for all design decisions |
| `frontend/src/index.css` | CSS tokens, component utilities, shadow patterns |
| `frontend/src/components/layout/Sidebar.tsx` | Icon rail + drawer + pinned sidebar |
| `frontend/src/components/layout/AppLayout.tsx` | CSS Grid shell |
| `frontend/src/components/dashboard/StartSessionInput.tsx` | The Guided/Chat toggle |
| `frontend/src/components/dashboard/PerformanceCards.tsx` | Stat cards with colored borders |
## Key Lessons From This Session
- The component sweep agents missed `editor-ai/`, `guides/`, `maintenance/`, `scripts/`, `settings/` directories and `text-brand-dark` references. Always do a final grep audit after sweeps.
- `bg-[#hex]` hardcoding defeats the purpose of CSS variables. We had to do a second pass to replace 3,200+ hardcoded values with semantic tokens.
- Black shadows (`rgba(0,0,0,...)`) are invisible on dark backgrounds. Use elevated surfaces + faint accent glow instead.
- The sidebar flyout needed `position: fixed` to escape the CSS Grid cell clipping — `absolute` positioning was hidden behind the main content area.
- Flyout hover timing: individual item `onMouseLeave` was killing the flyout before the mouse reached the drawer. Only the outer wrapper should handle `onMouseLeave`.
---
> **After reading this file:** Save relevant context to your session memory, then run `rm SESSION-HANDOFF.md` and `git add -A && git commit -m "chore: remove session handoff file"`.

View File

@@ -1 +0,0 @@
0.1.0.0

View File

@@ -1,171 +0,0 @@
# Design: Documentation Builder — Day 1 Onboarding Wedge
Generated by /office-hours on 2026-05-07
Branch: feat/self-serve-signup-phase-2
Repo: chihlasm/resolutionflow
Status: DRAFT
Mode: Startup
## Problem Statement
ResolutionFlow has two authoring surfaces — branching Flows (decision trees) and linear Projects (procedures). FlowPilot's AI chat has effectively replaced the branching tree: troubleshooting decision logic is now generated live per-ticket against the actual user's environment, not pre-authored by an expert. Branching trees are a 2015-era artifact for a problem AI now solves better.
That leaves a gap. Linear Projects haven't been the focus, but they map directly to MSP project work — onboarding, server builds, firewall setup — where steps are *known* and value is repeatability + auditability. Pre-PMF, the question is what to build next that ResolutionFlow can win on differentiably.
The thesis surfaced in this session: **execution IS documentation.** Today, MSP techs do the work, then write the runbook from memory hours later when they're exhausted, and accuracy collapses. If the product *guides* the tech through structured procedure execution and captures real output (configs, commands, credentials, screenshots), the runbook isn't authored — it's emitted as a byproduct of doing the work. The execution log IS the runbook.
Position: **"We're not a documentation app. We are the documentation builders."** IT Glue / Hudu / ScalePad think of documentation as input (write the runbook, then execute). ResolutionFlow inverts it: execute, and the runbook writes itself.
## Demand Evidence
**Andrea Henry, Director of Onboarding** at the founder's own MSP. Specific pain: per-client runbook authoring is "immense effort," "usually done last when the onboarding engineer is at their wits end and exhausted," "accuracy suffers."
The role itself is a demand signal. "Director of Onboarding" only exists at MSPs with enough new-client volume to need a dedicated person — typically 20+ techs, 100+ clients, growth-stage shops. That's a buyer with a budget, not an end-user pleading with their boss.
**Caveat:** Andrea is a prospect inside the founder's own company. Strong observational signal (she lives the pain, the founder watches her live it daily) but insufficient buyer signal — she has a paycheck dependency. External validation is required before this thesis is durable. See "The Assignment."
## Status Quo
Current MSP workflow for new client onboarding:
1. Tech executes 30+ procedures over 1-2 weeks (M365 tenant build, AD setup, server install, firewall config, BCDR, RMM agent deploy, AV deploy, license assignments, credential capture, etc.).
2. Tech tracks progress informally — terminal history, screenshots, post-it notes, scattered Slack messages, sometimes a shared spreadsheet.
3. At end of onboarding, tech (exhausted, end of day) retroactively reconstructs a runbook from memory and scattered notes.
4. Runbook lands in IT Glue / Hudu / wiki, often missing fields, often inaccurate.
5. Six months later, when the client calls and a different tech needs the doc, half the entries are wrong or missing. Senior techs redo work to verify reality. Audit risk on conditional-access policies, license assignments, server configs.
Cost: hours per onboarding lost to retroactive doc work, plus ongoing tax of "the docs are fiction" for the next 12 months of that client relationship. At an MSP with 5+ new clients per month, this is a real labor sink.
## Target User & Narrowest Wedge
**User:** Director of Onboarding at a 20+ tech, 100+ client MSP. Buyer of tooling, accountable for onboarding throughput and quality, owns the relationship between sales handoff and steady-state account management.
**Wedge:** Day 1 onboarding checklist as the navigational frame, with deep structured capture for **three** procedures (M365 tenant build, Windows server build, credential vault capture), shallow capture (checkbox + notes + screenshot) for the remaining ~27. Output publishes to Hudu, IT Glue, and ConnectWise.
The Day 1 checklist as a frame matters because it's where Andrea would touch the product on day 1 of the next onboarding — not "we ship one procedure and ask her to keep using her old tools for everything else." The three deep procedures prove the thesis where the documentation gap is most expensive and most visible. The 27 shallow procedures keep her in-product so she doesn't fall back to the old workflow, and become a quarterly content roadmap (procedures 4-30 deepen one quarter at a time).
## Constraints
- Pre-PMF, small team. Cannot ship 30 procedures × 3 output systems as v1.
- ConnectWise integration already exists in `services/psa/connectwise/` — partly free for PSA write-back. Hudu and IT Glue APIs are net-new integration work.
- Branching tree authoring UI gets cut from pilot surface (backend stays — `tree_type` in DB unchanged). Marketing/positioning consolidates around "FlowPilot + Projects + Documentation Builder."
- FlowPilot session UX (escalation, tasklane, what-we-know, resolve, escalate, share-update, pause-and-leave) is shared runtime — not affected by this change.
- Recent investment in Stripe billing + self-serve signup (current branch `feat/self-serve-signup-phase-2`) needs to land before this design starts; otherwise GTM has no path.
## Premises
1. "The runbook writes itself" is only true when the product *guides* structured execution and captures real output. Checkbox + notes = checklist tool, not documentation builder. **Confirmed.**
2. Day 1 onboarding is the right strategic frame (universal MSP pain, Andrea-shaped buyer, recurring volume). **Confirmed.**
3. First ship is **frame + deep capture on 3 procedures**, not all 30. The other 27 stay shallow in v1, deepen over time. **Confirmed.**
4. Output targets v1: Hudu, IT Glue, ConnectWise. Autotask deferred to v2. Halo / Kaseya BMS post-PMF. **Confirmed.**
5. External validation is non-negotiable. 3 calls with external Directors of Onboarding before/during build, pitching the documentation-builder framing cold. If 0 of 3 light up, revise the thesis. **Confirmed.**
6. Branching trees cut from pilot UI. Backend retains `tree_type`. All positioning consolidates. **Confirmed.**
## Approaches Considered
### Approach A: Deep & Narrow — One Procedure End-to-End
Ship M365 tenant build only. Full Graph API capture, three-system output. Other 29 procedures outside the product.
- **Effort:** S (4-6 weeks). **Risk:** Low.
- **Pros:** Thesis proven on one thing. Fastest to v1. Lowest risk of overbuild.
- **Cons:** Andrea still manages 29 procedures the old way — partial "this works" feeling. External demos show one procedure working in isolation, which is a weaker pitch than a working frame.
### Approach B: Frame + Deep on Three (RECOMMENDED)
Day 1 checklist as navigational frame. Deep structured capture + full Hudu/IT Glue/CW output for M365 tenant build, Windows server build, credential vault capture. Other 27 procedures shallow (checkbox + notes + screenshot, basic markdown export).
- **Effort:** M (10-14 weeks). **Risk:** Medium.
- **Pros:** Andrea uses it on day 1 of next onboarding for everything. Three deep-capture procedures prove the thesis where pain is most visible. Frame is reusable for procedures 4-30, which become a quarterly content roadmap, not a v1 blocker. Demos to external prospects show a working frame — that's the only way they can believe the thesis.
- **Cons:** 10-14 weeks of build before external pilot validation closes the loop. Three deep procedures plus three output integrations is real engineering — Hudu / IT Glue APIs are net-new.
### Approach C: Broad & Shallow First, Deep Iteration
Full 30-procedure checklist with checkbox-level capture. Basic markdown runbook from checkbox state + free-text + screenshots. Publishes to Hudu / IT Glue / CW as a single doc. Iterate procedure-by-procedure to add deep capture over Q3-Q4.
- **Effort:** S-M (6-8 weeks v1). **Risk:** High.
- **Pros:** Fastest to "Andrea uses it for the whole onboarding." Output integrations stand up once.
- **Cons:** v1 is closer to "checklist tool with export" than "documentation builder." Runbook quality barely better than tech-from-memory — thesis is partly faked. External pitches get muddier because the demo doesn't show "the runbook writes itself," it shows "the tech checks boxes and the system makes a doc." Hard to recover positioning once the market sees v1.
## Recommended Approach
**Approach B — Frame + Deep on Three.**
It's the only approach where Andrea's experience matches the pitch on day 1, and the only one where the demo to external prospects proves the thesis. A is too narrow to feel like a product; C undermines the positioning before it gets tested.
## Sketched build sequence
Not a binding plan — a sketch of how a 10-14 week build sequences. Refine in `/plan-eng-review`.
1. **Weeks 1-2 — Cut and consolidate.**
- Hide branching tree authoring UI from pilot surface. Backend (`tree_type`) untouched. Marketing copy + DESIGN-SYSTEM.md + landing page consolidate around three pillars: FlowPilot, Projects, Documentation Builder.
- Procedural editor lives, gets primary nav slot.
- Run the 3 external Director-of-Onboarding calls in parallel. Block build progression on signal.
2. **Weeks 3-5 — Day 1 frame.**
- New project type: "Client Onboarding." Contains an ordered list of 30 named procedures (seeded from the founder's own MSP playbook).
- Per-procedure state: not started / in progress (claimed by tech) / complete. Hand-off between techs. Per-tech assignment. Progress tracking visible to Andrea.
- 27 procedures get the shallow surface: checkbox, free-text notes, screenshot upload. Time spent. Tech who completed.
3. **Weeks 6-9 — Three deep procedures.**
- **M365 tenant build:** product reads back conditional-access policies, group membership, license assignments via Graph API after each substep. Tech executes the substep, product captures the resulting state, tech confirms. Output: structured asset.
- **Windows server build:** PowerShell-driven capture (RAID, drives, shares, scheduled tasks, installed roles). Output: structured asset.
- **Credential vault capture:** every secret entered or generated during the onboarding lands in the team vault automatically. No tech 1Password leakage. Output: structured asset + vault entries.
4. **Weeks 10-12 — Output integrations.**
- Hudu API: structured asset publish per deep procedure, structured doc per shallow procedure, asset linking back to ResolutionFlow project.
- IT Glue API: same shape, IT Glue's asset model.
- ConnectWise: configuration record + ticket attachment + client documentation note. Reuse `services/psa/connectwise/`.
5. **Weeks 13-14 — Internal pilot + external pilot.**
- Andrea runs next onboarding through it. Watch, don't help. Capture every break.
- 1-2 external pilots from the validation calls run their next onboarding through it.
- Decision gate: ship to GA or pivot.
## Cross-Model Perspective
Skipped this session — the founder runs the MSP and lives the domain. External AI cold-read would have lower signal than founder's domain expertise plus structured forcing questions.
## Open Questions
1. **Hudu vs. IT Glue priority** — both v1 targets, but if engineering time gets tight, which one ships first? Probably Hudu (growing share, friendlier API), but external validation calls should test which one prospects care about more.
2. **Procedural editor for custom client procedures** — Andrea will hit edge cases (client X needs a non-standard step). Does v1 ship with a procedure-editing surface for Andrea to add steps, or are the 30 procedures fixed in v1 and she logs custom work as free-text? Recommend: fixed in v1, editor in v1.5.
3. **Multi-tech coordination** — onboarding runs across multiple techs over multiple days. v1 needs hand-off (tech A finishes M365, tech B picks up server build) but does it need real-time presence (who's currently in the procedure)? Recommend: hand-off yes, presence v1.5.
4. **Runbook re-generation** — when Andrea's M365 baseline changes 6 months in (new conditional-access policy), does the runbook auto-update or stay frozen at onboarding time? This is the IT Glue / Hudu live-doc question and matters a lot. Punt to v2 explicitly; v1 ships a snapshot at onboarding completion.
5. **Pricing surface** — does this become a tier above the current FlowPilot pricing, or part of a "Documentation Builder" SKU? GTM call, not a build call, but flag for `/plan-ceo-review`.
6. **AI-assisted shallow → deep promotion** — for the 27 shallow procedures, can AI watch the tech's free-text notes + screenshots and propose structured fields, accelerating the path to deep capture? Probably yes; mark as a research thread for Q3.
## Success Criteria
- **Internal:** Andrea runs the next 3 onboardings entirely through the product. Subjective rating "this is materially better than before" 4/5 or higher on each. Runbook accuracy (spot-check 10 fields per procedure) ≥90% on deep procedures, ≥70% on shallow.
- **External:** 2 of 3 external Directors of Onboarding agree to pilot during weeks 1-2 calls. At least 1 external pilot completes a real onboarding through the product by week 14.
- **Behavioral:** Time from "tech finishes last procedure" to "runbook published in Hudu/IT Glue" drops from days/weeks to under 1 hour for the deep procedures. Zero retroactive runbook authoring sessions.
- **Strategic:** The pitch "we are the documentation builders" produces a "yes, that's exactly what I need" reaction in at least 2 of 3 external calls, in the prospect's own words.
## Distribution Plan
Web service, existing Railway deployment pipeline. No new distribution surface needed. Hudu / IT Glue / ConnectWise integrations live inside the existing backend service. Auth flows through the existing OAuth/API-key model per integration.
## Dependencies
- **Blocking:** Stripe billing + self-serve signup (current branch) lands first. GTM motion has no path otherwise.
- **Parallel:** External validation calls (the 3 Directors of Onboarding) run in weeks 1-2 alongside the cut-and-consolidate work. If 0/3 light up, this design pauses for a thesis revision.
- **Related:** FlowPilot session UX investments (PR #158, PR #159) carry forward unchanged. Branching tree backend (`tree_type` column) stays in DB.
## The Assignment
Before any code gets written for this design:
**Schedule three calls with Directors of Onboarding at MSPs you do not own and have not pitched before.** Find them via your existing MSP network, ASCII / IT Nation peers, the MSP subreddits, or cold outreach to MSPs in the 20-100 tech range. Do not use vendor friends — they will be polite, not honest.
Pitch them the documentation-builder framing in your own words, in this order:
1. Open with the pain: "Walk me through your last new-client onboarding. Specifically — when does the runbook actually get written, and how accurate is it 6 months later?"
2. Listen. Do not pitch yet. Take notes on the words they use.
3. Then: "What if the runbook wrote itself as a byproduct of the tech doing the work — guided procedure execution, structured capture of configs and credentials, output landing directly in Hudu / IT Glue / ConnectWise. Would that be valuable to you, or am I solving a problem you don't have?"
4. Watch their face / listen to their tone. The signal you want is "yes, that's exactly what I need" in their own words. The signal you want to fear is "interesting, send me more info."
5. Ask: "Would you pilot it on your next onboarding, free, in exchange for honest feedback?"
If 0/3 say yes to pilot, the thesis needs revision before code. If 1/3, build but flag the risk. If 2-3/3, build with confidence.
Bring your own design doc (this one) to the calls. Show it. Let them critique it. Their language is more valuable than yours.
## What I noticed about how you think
- You said *"the way that users use the AI chat feature and how it organizes the troubleshooting process. The best part is how it documents the process from start to finish. This is the way troubleshooting will be done in the future."* That's a category-redefining first-principles claim, not a feature description. Most founders pitch features. You pitched a thesis. That's rare.
- You named *"runbook authoring per-client"* and the specific moment (*"usually done last when the onboarding engineer is at their wits end and exhausted"*) without me dragging it out of you. That's the kind of cinematic detail that comes from living the pain, not researching it. You run the MSP. Andrea works for you. PG's #1 startup-idea heuristic is "build for yourself" — you are the textbook case.
- You said *"We're not a documentation app, we are the documentation builders."* Hold onto that line. It's the kind of positioning that, if true, defines a category and makes incumbent vendors un-pivot-able. Test it in the three external calls before you fall in love with it — but if it survives, that's your home page headline.
- When I challenged your wedge as too broad, you didn't budge. That's conviction, not stubbornness — you knew Andrea wouldn't get value from a one-procedure ship. Worth flagging because most founders cave on scope challenges. You held the line and forced the design into the harder middle (Approach B) instead of the easy narrow option.

View File

@@ -21,22 +21,4 @@ ANTHROPIC_API_KEY=
VOYAGE_API_KEY=
# ConnectWise PSA Integration
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
# Stripe
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
# Self-serve cutover
# SELF_SERVE_ENABLED is the master switch for the public self-serve signup
# flow (pricing page, invite-code-optional registration). Default is false
# until Phase O cutover.
# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the
# global flag for specific users — used for prod test-mode validation
# before the public flip. Empty by default.
SELF_SERVE_ENABLED=false
INTERNAL_TESTER_EMAILS=
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>

View File

@@ -5,12 +5,6 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
libpango1.0-dev \
libcairo2-dev \
libgdk-pixbuf-2.0-dev \
libffi-dev \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./
@@ -18,4 +12,4 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
EXPOSE 8000
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]

View File

@@ -29,37 +29,13 @@ from app.models.session_branch import SessionBranch # noqa: F401
from app.models.fork_point import ForkPoint # noqa: F401
from app.models.session_handoff import SessionHandoff # noqa: F401
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
from app.core.config import settings
def _alembic_sync_url() -> str:
"""Return a psycopg2-compatible sync URL for Alembic.
Priority order:
1. DATABASE_URL_SYNC — in Railway this is set as a reference variable
(${{pgvector.DATABASE_URL}}) that resolves to the correct postgres
superuser credentials for the current environment (production, PR preview,
etc.). This always works even on fresh databases before any custom roles
have been created, because it uses the postgres superuser.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS) converted to a sync
driver — fallback for local dev where DATABASE_URL_SYNC may not be set.
"""
if settings.DATABASE_URL_SYNC:
return settings.DATABASE_URL_SYNC
admin_url = settings.ADMIN_DATABASE_URL
if admin_url and "+asyncpg" in admin_url:
return admin_url.replace("postgresql+asyncpg://", "postgresql://")
return settings.DATABASE_URL_SYNC
# this is the Alembic Config object
config = context.config
# Override sqlalchemy.url with the sync version for migrations
config.set_main_option("sqlalchemy.url", _alembic_sync_url())
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
# Interpret the config file for Python logging.
if config.config_file_name is not None:
@@ -110,7 +86,7 @@ def run_migrations_online() -> None:
from sqlalchemy import create_engine
connectable = create_engine(
_alembic_sync_url(),
settings.DATABASE_URL_SYNC,
poolclass=pool.NullPool,
)

View File

@@ -1,59 +0,0 @@
"""Enable RLS on Phase 3 tables.
Tables covered:
- step_ratings (account_id NOT NULL since migration 7167e9374b0c)
- step_usage_log (account_id NOT NULL since migration 7167e9374b0c)
- target_lists (account_id NOT NULL since migration 2c6aabd89bc6)
- session_shares (account_id NOT NULL since session_share model)
- audit_logs (account_id NOT NULL since migration 2a9056eddd90)
- tree_shares (account_id NOT NULL since migration a05e1a1bea7c)
All use a standard intra-tenant isolation policy.
Token-based access to session_shares and tree_shares goes through
endpoints that use get_admin_db (BYPASSRLS), so a strict tenant
policy here is correct.
Revision ID: 04f013768235
Revises: a05e1a1bea7c
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = '04f013768235'
down_revision: Union[str, None] = 'a05e1a1bea7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_CURRENT_ACCOUNT = (
"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000')::uuid"
)
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
_PHASE3_TABLES = [
"step_ratings",
"step_usage_log",
"target_lists",
"session_shares",
"audit_logs",
"tree_shares",
]
def upgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
def downgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,31 @@
"""add triage fields to ai_sessions for cockpit harness
Revision ID: 071
Revises: 070
Create Date: 2026-04-01
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "071"
down_revision = "070"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ai_sessions", sa.Column("client_name", sa.String(255), nullable=True))
op.add_column("ai_sessions", sa.Column("asset_name", sa.String(255), nullable=True))
op.add_column("ai_sessions", sa.Column("issue_category", sa.String(100), nullable=True))
op.add_column("ai_sessions", sa.Column("triage_hypothesis", sa.Text(), nullable=True))
op.add_column("ai_sessions", sa.Column("evidence_items", JSONB(), nullable=True))
def downgrade() -> None:
op.drop_column("ai_sessions", "evidence_items")
op.drop_column("ai_sessions", "triage_hypothesis")
op.drop_column("ai_sessions", "issue_category")
op.drop_column("ai_sessions", "asset_name")
op.drop_column("ai_sessions", "client_name")

View File

@@ -0,0 +1,61 @@
"""Seed flowpilot_cockpit feature flag with plan defaults.
Revision ID: 072
Revises: 071
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = "072"
down_revision = "071"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Insert the feature flag
op.execute(
sa.text(
"INSERT INTO feature_flags (id, flag_key, display_name, description) "
"VALUES (gen_random_uuid(), 'flowpilot_cockpit', 'FlowPilot Cockpit', "
"'Access to the FlowPilot Cockpit triage view') "
"ON CONFLICT (flag_key) DO NOTHING"
)
)
# Set plan defaults: disabled for free, enabled for pro and team
op.execute(
sa.text(
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
"SELECT gen_random_uuid(), 'free', id, false FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
"ON CONFLICT (plan, flag_id) DO NOTHING"
)
)
op.execute(
sa.text(
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
"SELECT gen_random_uuid(), 'pro', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
"ON CONFLICT (plan, flag_id) DO NOTHING"
)
)
op.execute(
sa.text(
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
"SELECT gen_random_uuid(), 'team', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
"ON CONFLICT (plan, flag_id) DO NOTHING"
)
)
def downgrade() -> None:
op.execute(
sa.text(
"DELETE FROM plan_feature_defaults WHERE flag_id IN "
"(SELECT id FROM feature_flags WHERE flag_key = 'flowpilot_cockpit')"
)
)
op.execute(
sa.text("DELETE FROM feature_flags WHERE flag_key = 'flowpilot_cockpit'")
)

View File

@@ -1,8 +1,8 @@
"""Add account-scoped device_types table with platform seed data.
"""Add device_types table with system seed data.
Revision ID: 073
Revises: b3c7e9f2a1d8
Create Date: 2026-04-12
Revises: 072
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
@@ -11,18 +11,10 @@ import uuid
revision = "073"
down_revision = "b3c7e9f2a1d8"
down_revision = "072"
branch_labels = None
depends_on = None
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
SYSTEM_DEVICE_TYPES = [
("router", "Router", "network", 0),
("switch", "Switch", "network", 1),
@@ -63,13 +55,16 @@ def upgrade() -> None:
sa.Column("label", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
op.execute(
"ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team "
"UNIQUE NULLS NOT DISTINCT (slug, team_id)"
)
op.create_index("idx_device_types_team", "device_types", ["team_id"])
device_types_table = sa.table(
"device_types",
@@ -78,7 +73,7 @@ def upgrade() -> None:
sa.column("label", sa.String),
sa.column("category", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("account_id", UUID(as_uuid=True)),
sa.column("team_id", UUID(as_uuid=True)),
sa.column("sort_order", sa.Integer),
)
@@ -89,44 +84,12 @@ def upgrade() -> None:
"label": label,
"category": category,
"is_system": True,
"account_id": uuid.UUID(_PLATFORM_UUID),
"team_id": None,
"sort_order": sort_order,
}
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
])
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY device_types_select ON device_types
FOR SELECT
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
op.execute(f"""
CREATE POLICY device_types_insert ON device_types
FOR INSERT
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_update ON device_types
FOR UPDATE
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_delete ON device_types
FOR DELETE
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
op.drop_table("device_types")

View File

@@ -2,7 +2,7 @@
Revision ID: 074
Revises: 073
Create Date: 2026-04-12
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
@@ -14,19 +14,12 @@ down_revision = "073"
branch_labels = None
depends_on = None
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
op.create_table(
"network_diagrams",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("client_name", sa.String(255), nullable=True),
sa.Column("asset_name", sa.String(255), nullable=True),
@@ -40,18 +33,9 @@ def upgrade() -> None:
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON network_diagrams
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.create_index("idx_network_diagrams_team", "network_diagrams", ["team_id"])
op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"])
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
op.drop_table("network_diagrams")

View File

@@ -1,102 +0,0 @@
"""create_db_roles
Revision ID: 0b470d9e6cf1
Revises: a9f3b2c1d4e5
Create Date: 2026-04-10 03:58:10.207919
"""
import os
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = '0b470d9e6cf1'
down_revision: Union[str, None] = 'a9f3b2c1d4e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Passwords from env vars. For local dev, defaults are sufficient.
# For production (Railway), set DB_APP_ROLE_PASSWORD and
# DB_ADMIN_ROLE_PASSWORD as environment variables before running migrations.
# Passwords must not contain single quotes.
app_pw = os.environ.get("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
admin_pw = os.environ.get("DB_ADMIN_ROLE_PASSWORD", "admin_secret_change_me")
# Fetch the current database name dynamically — avoids hardcoding
# (the DB is named 'resolutionflow' in dev, potentially different elsewhere).
conn = op.get_bind()
db_name = conn.execute(text("SELECT current_database()")).scalar()
# ── Application role ────────────────────────────────────────────────────
# Subject to RLS. Used by FastAPI at runtime via DATABASE_URL.
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_app') THEN
CREATE ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
ELSE
ALTER ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
END IF;
END $$
""")
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_app")
op.execute("GRANT USAGE ON SCHEMA public TO resolutionflow_app")
op.execute(
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public "
"TO resolutionflow_app"
)
op.execute(
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO resolutionflow_app"
)
# Ensure future tables automatically get the same permissions
op.execute(
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO resolutionflow_app"
)
op.execute(
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
"GRANT USAGE, SELECT ON SEQUENCES TO resolutionflow_app"
)
# ── Admin role ──────────────────────────────────────────────────────────
# BYPASSRLS. Used by Alembic (DATABASE_URL_SYNC) and /admin/* endpoints
# (ADMIN_DATABASE_URL) after Task 11.
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_admin') THEN
CREATE ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
ELSE
ALTER ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
END IF;
END $$
""")
op.execute("GRANT resolutionflow_app TO resolutionflow_admin")
op.execute("ALTER ROLE resolutionflow_admin BYPASSRLS")
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_admin")
def downgrade() -> None:
conn = op.get_bind()
db_name = conn.execute(text("SELECT current_database()")).scalar()
op.execute(
"REVOKE ALL ON ALL TABLES IN SCHEMA public FROM resolutionflow_app"
)
op.execute(
"REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM resolutionflow_app"
)
op.execute(
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_app"
)
op.execute(
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_admin"
)
op.execute("DROP ROLE IF EXISTS resolutionflow_admin")
op.execute("DROP ROLE IF EXISTS resolutionflow_app")

View File

@@ -1,32 +0,0 @@
"""Drop team_id from target_lists.
account_id (NOT NULL) is now the tenant isolation key; team_id is redundant.
All reads/writes use account_id via RLS + application filter.
Revision ID: 172ad76d7d20
Revises: 04f013768235
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '172ad76d7d20'
down_revision: Union[str, None] = '04f013768235'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists', if_exists=True)
op.drop_constraint('target_lists_team_id_fkey', 'target_lists', type_='foreignkey')
op.drop_column('target_lists', 'team_id')
def downgrade() -> None:
op.add_column('target_lists', sa.Column('team_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'target_lists_team_id_fkey', 'target_lists', 'teams',
['team_id'], ['id'], ondelete='CASCADE',
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])

View File

@@ -1,86 +0,0 @@
"""set NOT NULL on all previously-nullable account_id columns
Revision ID: 174f442795b7
Revises: 3a40fe11b427
Create Date: 2026-04-09 00:00:00.000000
All tables in this migration had account_id set to nullable previously.
Task 9 (create_global_content_tables) cleared all NULL rows.
This migration enforces the NOT NULL constraint.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '174f442795b7'
down_revision: Union[str, None] = '3a40fe11b427'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# tree_embeddings: backfill from trees (must happen before SET NOT NULL)
op.execute("""
UPDATE tree_embeddings te
SET account_id = t.account_id
FROM trees t
WHERE te.tree_id = t.id
AND te.account_id IS NULL
""")
# feedback: backfill from users
op.execute("""
UPDATE feedback f
SET account_id = u.account_id
FROM users u
WHERE f.user_id = u.id
AND f.account_id IS NULL
""")
# Verify ALL tables before touching any SET NOT NULL
tables_with_account_id = [
'users', 'trees', 'tree_categories', 'tree_tags',
'step_categories', 'step_library', 'tree_embeddings', 'feedback',
]
for table in tables_with_account_id:
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} NULL account_id rows in {table}. "
"Run Task 9 (create_global_content_tables) first, or "
"manually backfill/delete orphaned rows."
)
# SET NOT NULL on all
for table in tables_with_account_id:
op.alter_column(table, 'account_id', nullable=False)
# Create indexes where they don't already exist
new_indexes = [
('tree_embeddings', 'ix_tree_embeddings_account_id'),
('feedback', 'ix_feedback_account_id'),
]
for table, index_name in new_indexes:
result = op.get_bind().execute(sa.text(
f"SELECT 1 FROM pg_indexes WHERE tablename='{table}' AND indexname='{index_name}'"
))
if not result.fetchone():
op.create_index(index_name, table, ['account_id'])
def downgrade() -> None:
# Revert to nullable
for table in ('users', 'trees', 'tree_categories', 'tree_tags',
'step_categories', 'step_library', 'tree_embeddings', 'feedback'):
op.alter_column(table, 'account_id', nullable=True)
for table, index_name in (
('tree_embeddings', 'ix_tree_embeddings_account_id'),
('feedback', 'ix_feedback_account_id'),
):
try:
op.drop_index(index_name, table_name=table)
except Exception:
pass

View File

@@ -1,51 +0,0 @@
"""Add account_id to audit_logs and backfill via user_id.
Revision ID: 2a9056eddd90
Revises: 70a5dd746e83
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2a9056eddd90'
down_revision: Union[str, None] = '70a5dd746e83'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('audit_logs', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_audit_logs_account_id', 'audit_logs', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the acting user's account
op.execute("""
UPDATE audit_logs al
SET account_id = u.account_id
FROM users u
WHERE al.user_id = u.id
AND u.account_id IS NOT NULL
AND al.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM audit_logs WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} audit_logs rows have NULL account_id after backfill. "
"All audit log entries must have an associated user with an account."
)
op.alter_column('audit_logs', 'account_id', nullable=False)
op.create_index('ix_audit_logs_account_id', 'audit_logs', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_audit_logs_account_id', table_name='audit_logs')
op.drop_constraint('fk_audit_logs_account_id', 'audit_logs', type_='foreignkey')
op.drop_column('audit_logs', 'account_id')

View File

@@ -1,30 +0,0 @@
"""account_invites add revoked_at and email_sent_at
Revision ID: 2aa73d3231c2
Revises: e1af7ab57ceb
Create Date: 2026-05-06 07:28:28.514384
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2aa73d3231c2'
down_revision: Union[str, None] = 'e1af7ab57ceb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"])
def downgrade() -> None:
op.drop_index("ix_account_invites_revoked_at", table_name="account_invites")
op.drop_column("account_invites", "email_sent_at")
op.drop_column("account_invites", "revoked_at")

View File

@@ -1,62 +0,0 @@
"""add account_id to target_lists (keep team_id)
Revision ID: 2c6aabd89bc6
Revises: 78fc200abac1
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2c6aabd89bc6'
down_revision: Union[str, None] = '78fc200abac1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('target_lists', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_target_lists_account_id', 'target_lists', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Primary: team_id → team admin user → account_id
op.execute("""
UPDATE target_lists tl
SET account_id = u.account_id
FROM users u
WHERE u.team_id = tl.team_id
AND u.is_team_admin = TRUE
AND u.account_id IS NOT NULL
AND tl.account_id IS NULL
""")
# Fallback: created_by → users.account_id
op.execute("""
UPDATE target_lists tl
SET account_id = u.account_id
FROM users u
WHERE tl.created_by = u.id
AND u.account_id IS NOT NULL
AND tl.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM target_lists WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} target_lists rows have NULL account_id. "
"No team admin found for these teams. Resolve before re-running."
)
op.alter_column('target_lists', 'account_id', nullable=False)
op.create_index('ix_target_lists_account_id', 'target_lists', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_target_lists_account_id', table_name='target_lists')
op.drop_constraint('fk_target_lists_account_id', 'target_lists', type_='foreignkey')
op.drop_column('target_lists', 'account_id')

View File

@@ -1,175 +0,0 @@
"""create template_trees and platform_steps global content tables
Revision ID: 3a40fe11b427
Revises: 2c6aabd89bc6
Create Date: 2026-04-09 00:00:00.000000
These tables hold platform-owned content that is readable by all
authenticated users. No account_id. No RLS. Ever.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision: str = '3a40fe11b427'
down_revision: Union[str, None] = '2c6aabd89bc6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── Create template_trees ─────────────────────────────────────────────────
op.create_table(
'template_trees',
sa.Column('id', UUID(), primary_key=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(100), nullable=True),
sa.Column('tree_type', sa.String(20), nullable=False),
sa.Column('tree_structure', JSONB(), nullable=False),
sa.Column('tags', JSONB(), nullable=False, server_default='[]'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('source_tree_id', UUID(), sa.ForeignKey('trees.id', ondelete='SET NULL'), nullable=True),
)
op.create_index('ix_template_trees_tree_type', 'template_trees', ['tree_type'])
# ── Create platform_steps ────────────────────────────────────────────────
op.create_table(
'platform_steps',
sa.Column('id', UUID(), primary_key=True),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('step_type', sa.String(50), nullable=False),
sa.Column('content', JSONB(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('source_step_id', UUID(), sa.ForeignKey('step_library.id', ondelete='SET NULL'), nullable=True),
)
op.create_index('ix_platform_steps_step_type', 'platform_steps', ['step_type'])
# ── Copy is_default=TRUE trees → template_trees ─────────────────────────
# Note: trees.tags is a relationship via tree_tags join table — no direct column.
# Aggregate tag names via a correlated subquery.
op.execute("""
INSERT INTO template_trees
(id, name, description, category, tree_type, tree_structure,
tags, is_active, created_at, updated_at, source_tree_id)
SELECT
gen_random_uuid(), t.name, t.description, t.category, t.tree_type,
t.tree_structure,
COALESCE(
(SELECT jsonb_agg(tt.name ORDER BY tt.name)
FROM tree_tag_assignments ta
JOIN tree_tags tt ON tt.id = ta.tag_id
WHERE ta.tree_id = t.id),
'[]'::jsonb
),
t.is_active,
COALESCE(t.created_at, NOW()), COALESCE(t.updated_at, NOW()), t.id
FROM trees t
WHERE t.is_default = TRUE
""")
# ── Copy visibility='public' steps → platform_steps ─────────────────────
op.execute("""
INSERT INTO platform_steps
(id, title, step_type, content, is_active, created_at, updated_at, source_step_id)
SELECT
gen_random_uuid(), title, step_type, content, is_active,
COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id
FROM step_library
WHERE visibility = 'public'
""")
# ── Create platform sentinel account ─────────────────────────────────────
op.execute("""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000001',
'ResolutionFlow Platform',
'PLATFORM',
NOW(),
NOW()
)
ON CONFLICT (id) DO NOTHING
""")
# ── Assign is_default trees to platform account ──────────────────────────
op.execute("""
UPDATE trees
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE is_default = TRUE
AND account_id IS NULL
""")
# ── Assign remaining trees to their author's account ─────────────────────
# Handles trees with no team_id that aren't is_default (e.g. inactive test
# trees, trees created before the team system existed).
op.execute("""
UPDATE trees
SET account_id = u.account_id
FROM users u
WHERE trees.author_id = u.id
AND trees.account_id IS NULL
AND u.account_id IS NOT NULL
""")
# ── Final fallback: any still-NULL trees go to platform account ───────────
# Covers trees whose author has no account (seeded content, system rows).
op.execute("""
UPDATE trees
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
# ── Assign global categories/tags/steps to platform account ─────────────
op.execute("""
UPDATE tree_categories
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE tree_tags
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE step_categories
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE step_library
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
# ── Verify zero NULLs in all 5 tables ───────────────────────────────────
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} NULL account_id rows remain in {table} "
"after platform account assignment. Investigate before re-running."
)
def downgrade() -> None:
platform_id = '00000000-0000-0000-0000-000000000001'
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
op.execute(f"UPDATE {table} SET account_id = NULL WHERE account_id = '{platform_id}'")
op.execute(f"DELETE FROM accounts WHERE id = '{platform_id}'")
op.drop_index('ix_platform_steps_step_type', table_name='platform_steps')
op.drop_index('ix_template_trees_tree_type', table_name='template_trees')
op.drop_table('platform_steps')
op.drop_table('template_trees')

View File

@@ -1,77 +0,0 @@
"""add account_id to AI branching tables
Revision ID: 478c159e5654
Revises: cc214c63aa30
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '478c159e5654'
down_revision: Union[str, None] = 'cc214c63aa30'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
ai_tables = ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps')
# Step 1: ADD COLUMN (nullable)
for table in ai_tables:
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
op.add_column('ai_suggestions', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_ai_suggestions_account_id', 'ai_suggestions', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Step 2: BACKFILL
for table in ai_tables:
op.execute(f"""
UPDATE {table} t
SET account_id = ai.account_id
FROM ai_sessions ai
WHERE t.session_id = ai.id
AND t.account_id IS NULL
""")
op.execute("""
UPDATE ai_suggestions s
SET account_id = u.account_id
FROM users u
WHERE s.user_id = u.id
AND s.account_id IS NULL
""")
# Step 3: VERIFY zero NULLs
for table in ai_tables + ('ai_suggestions',):
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} NULL account_id rows in {table}."
)
# Step 4: SET NOT NULL
for table in ai_tables + ('ai_suggestions',):
op.alter_column(table, 'account_id', nullable=False)
# Step 5: CREATE INDEX
for table in ai_tables + ('ai_suggestions',):
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('session_branches', 'session_handoffs', 'fork_points',
'ai_session_steps', 'ai_suggestions'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,84 +0,0 @@
"""add_starter_rename_team_to_enterprise
Revision ID: 4ce3e594cb87
Revises: c6cbfc534fad
Create Date: 2026-05-07 19:36:27.172082
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
This migration:
1. Defensively migrates any existing subscriptions on plan='team' to
plan='enterprise' (dev has zero such rows; prod is expected to have
none, but the UPDATE is safe and idempotent).
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
and plan_feature_defaults are FK-referenced but currently empty;
the rename works because PostgreSQL allows updating PK values when
no FK rows reference them.
3. Inserts a new plan_limits row for 'starter' between free and pro.
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
the string 'team' for "shared with my account" — that is a separate
domain and is intentionally not touched.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '4ce3e594cb87'
down_revision: Union[str, None] = 'c6cbfc534fad'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("""
INSERT INTO plan_limits (
plan,
max_trees,
max_sessions_per_month,
max_users,
custom_branding,
priority_support,
export_formats,
max_ai_builds_per_month,
max_ai_builds_per_24h,
kb_accelerator_enabled,
kb_max_lifetime_conversions,
kb_batch_max_size,
kb_allowed_formats,
kb_detailed_analysis,
kb_conversational_refinement,
kb_step_library_matching,
kb_history_limit
) VALUES (
'starter',
10,
75,
1,
FALSE,
FALSE,
'["markdown", "text", "html"]'::jsonb,
15,
5,
FALSE,
NULL,
NULL,
'["txt", "paste", "md"]'::jsonb,
FALSE,
FALSE,
FALSE,
NULL
)
ON CONFLICT (plan) DO NOTHING
""")
def downgrade() -> None:
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")

View File

@@ -1,28 +0,0 @@
"""users add role_at_signup and onboarding_step_completed
Revision ID: 58e3caaa6269
Revises: 5bb055a1593e
Create Date: 2026-05-06 07:25:16.780761
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '58e3caaa6269'
down_revision: Union[str, None] = '5bb055a1593e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True))
op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "onboarding_step_completed")
op.drop_column("users", "role_at_signup")

View File

@@ -1,47 +0,0 @@
"""users password_hash nullable
Revision ID: 5bb055a1593e
Revises: b1fad5ddf357
Create Date: 2026-05-06 07:23:21.480252
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5bb055a1593e'
down_revision: Union[str, None] = 'b1fad5ddf357'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(255),
nullable=True,
)
def downgrade() -> None:
# NOTE: downgrade is non-trivial if any OAuth-only users exist.
# This downgrade fails fast in that case rather than corrupting data.
conn = op.get_bind()
null_count = conn.execute(
sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL")
).scalar()
if null_count and null_count > 0:
raise RuntimeError(
f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. "
"Set passwords or delete those rows before downgrading."
)
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(255),
nullable=False,
)

View File

@@ -1,74 +0,0 @@
"""add fix outcome tracking columns to session_suggested_fixes
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
ai_outcome_proposal.
status is the outcome dimension (did the fix work?), orthogonal to the
existing user_decision column (which script-path the engineer took).
Revision ID: 6492ec8d2d5b
Revises: f07010f17b01
Create Date: 2026-04-23 18:32:38.609719
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '6492ec8d2d5b'
down_revision: Union[str, None] = 'f07010f17b01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"session_suggested_fixes",
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")),
)
op.add_column(
"session_suggested_fixes",
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("partial_notes", sa.Text(), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("failure_reason", sa.Text(), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
)
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
op.execute(
"UPDATE session_suggested_fixes "
"SET status = 'dismissed' "
"WHERE user_decision = 'dismissed'"
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')",
)
op.alter_column("session_suggested_fixes", "status", server_default=None)
def downgrade() -> None:
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
op.drop_column("session_suggested_fixes", "failure_reason")
op.drop_column("session_suggested_fixes", "partial_notes")
op.drop_column("session_suggested_fixes", "verified_at")
op.drop_column("session_suggested_fixes", "applied_at")
op.drop_column("session_suggested_fixes", "status")

View File

@@ -1,90 +0,0 @@
"""Enable RLS on Phase 2 session and supporting tables.
10 tables use a standard tenant-only policy.
step_library uses a visibility-aware policy — public steps visible to all tenants.
NOTE: session_messages does not exist in this codebase (removed from plan).
script_generations is the correct table name (not script_template_generations).
sessions and ai_sessions are two separate tables, both in scope.
Prerequisites:
- Phase 1 migration must have run (resolutionflow_app role exists, Phase 1 tables have RLS)
- NOT NULL write-path bugs fixed (P2-A commits b641ac6)
- shares.py cross-tenant session fix deployed (P2-B commit ac2b193)
Revision ID: 70a5dd746e83
Revises: c5f48b9890f9
Create Date: 2026-04-10 06:54:49.431817
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '70a5dd746e83'
down_revision: Union[str, None] = 'c5f48b9890f9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
# Standard tenant-only policy — account_id must match the current tenant.
# When no tenant context is set, COALESCE returns the nil UUID so zero rows
# are visible (fail-closed).
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
# Visibility-aware policy for step_library — public steps (visibility='public')
# must be visible to ALL tenants regardless of account_id. This covers the
# visibility='public' arm of build_step_visibility_filter() in app/core/filters.py.
# The created_by arm (private steps visible to their author) is covered
# transitively: private steps share account_id with their creator, so the
# account_id match handles it. This relies on account_id NOT NULL on step_library.
_STEP_LIBRARY_USING = f"account_id = {_CURRENT_ACCOUNT} OR visibility = 'public'"
# Standard tables: strict tenant isolation, no cross-tenant visibility.
_STANDARD_TABLES = [
"sessions",
"ai_sessions",
"session_branches",
"session_supporting_data",
"session_resolution_outputs",
"session_handoffs",
"script_templates",
"script_generations",
"maintenance_schedules",
"psa_post_log",
]
def upgrade() -> None:
# ── Standard tenant-isolation tables ────────────────────────────────────
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
# ── step_library ────────────────────────────────────────────────────────
# Public steps (visibility='public') must be readable by all tenants so
# the Solutions Library browsing experience works without tenant context.
# Private/team steps remain tenant-scoped.
op.execute("ALTER TABLE step_library ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE step_library FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON step_library
USING ({_STEP_LIBRARY_USING})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES + ["step_library"]:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -1,46 +0,0 @@
"""add account_id to step_ratings and step_usage_log
Revision ID: 7167e9374b0c
Revises: 478c159e5654
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7167e9374b0c'
down_revision: Union[str, None] = '478c159e5654'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for table in ('step_ratings', 'step_usage_log'):
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: from the RATER/LOGGER user's account (not the step's account)
op.execute(f"""
UPDATE {table} t
SET account_id = u.account_id
FROM users u
WHERE t.user_id = u.id
AND t.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
op.alter_column(table, 'account_id', nullable=False)
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('step_ratings', 'step_usage_log'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,70 +0,0 @@
"""add origin discriminator + inline idempotency to script_builder_sessions
Adds:
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
- invariant: pilot_inline rows must have ai_session_id
- partial unique index: one pilot_inline session per (user, pilot session)
Revision ID: 71efd2102f49
Revises: 6492ec8d2d5b
Create Date: 2026-04-24 04:22:10.819809
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '71efd2102f49'
down_revision = '6492ec8d2d5b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"script_builder_sessions",
sa.Column(
"origin",
sa.String(length=20),
nullable=False,
server_default=sa.text("'standalone'"),
),
)
op.create_check_constraint(
"ck_script_builder_sessions_origin",
"script_builder_sessions",
"origin IN ('standalone', 'pilot_inline')",
)
op.create_check_constraint(
"ck_script_builder_sessions_origin_ai_session",
"script_builder_sessions",
"origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
)
op.create_index(
"ux_script_builder_sessions_pilot_inline",
"script_builder_sessions",
["user_id", "ai_session_id"],
unique=True,
postgresql_where=sa.text("origin = 'pilot_inline'"),
)
# Drop the server_default — app code owns the default via model default.
op.alter_column("script_builder_sessions", "origin", server_default=None)
def downgrade() -> None:
op.drop_index(
"ux_script_builder_sessions_pilot_inline",
table_name="script_builder_sessions",
)
op.drop_constraint(
"ck_script_builder_sessions_origin_ai_session",
"script_builder_sessions",
type_="check",
)
op.drop_constraint(
"ck_script_builder_sessions_origin",
"script_builder_sessions",
type_="check",
)
op.drop_column("script_builder_sessions", "origin")

View File

@@ -1,60 +0,0 @@
"""add applied_pending status + pending_reason to session_suggested_fixes
Adds the `applied_pending` non-terminal status (engineer ran the fix but
verification is deferred — waiting on client, async sync, etc) alongside
the existing `applied_partial` status. Mirrors partial_notes with a new
pending_reason column for the "what are you waiting on?" prose.
Revision ID: c0f3a4b7e91d
Revises: 71efd2102f49
Create Date: 2026-04-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c0f3a4b7e91d"
down_revision: Union[str, None] = "71efd2102f49"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"session_suggested_fixes",
sa.Column("pending_reason", sa.Text(), nullable=True),
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'applied_pending', 'dismissed')",
)
def downgrade() -> None:
op.execute(
"UPDATE session_suggested_fixes "
"SET status = 'applied_partial', "
" partial_notes = COALESCE(partial_notes, pending_reason) "
"WHERE status = 'applied_pending'"
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
)
op.drop_column("session_suggested_fixes", "pending_reason")

View File

@@ -1,103 +0,0 @@
"""add account_id to script_builder_sessions, script_templates, script_generations
Revision ID: 78fc200abac1
Revises: 7f136778f5a8
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '78fc200abac1'
down_revision: Union[str, None] = '7f136778f5a8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PLATFORM_ACCOUNT_ID = '00000000-0000-0000-0000-000000000001'
def upgrade() -> None:
# Ensure the platform sentinel account exists before any fallback assignments.
# Migration 3a40fe11b427 also inserts this with ON CONFLICT DO NOTHING — safe.
op.execute(f"""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (
'{PLATFORM_ACCOUNT_ID}',
'ResolutionFlow Platform',
'PLATFORM',
NOW(),
NOW()
)
ON CONFLICT (id) DO NOTHING
""")
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# script_builder_sessions: user_id → users.account_id
op.execute("""
UPDATE script_builder_sessions sbs
SET account_id = u.account_id
FROM users u
WHERE sbs.user_id = u.id
AND sbs.account_id IS NULL
""")
# script_templates: created_by → users.account_id (nullable created_by)
op.execute("""
UPDATE script_templates st
SET account_id = u.account_id
FROM users u
WHERE st.created_by = u.id
AND st.account_id IS NULL
""")
# Fallback: team_id → team admin user
op.execute("""
UPDATE script_templates st
SET account_id = u.account_id
FROM users u
WHERE u.team_id = st.team_id
AND u.is_team_admin = TRUE
AND u.account_id IS NOT NULL
AND st.account_id IS NULL
""")
# Final fallback: platform-seeded templates with NULL team_id AND NULL created_by
# (e.g. the 6 AD templates inserted by migration 057) → platform sentinel account
op.execute(f"""
UPDATE script_templates
SET account_id = '{PLATFORM_ACCOUNT_ID}'
WHERE account_id IS NULL
""")
# script_generations: user_id → users.account_id
op.execute("""
UPDATE script_generations sg
SET account_id = u.account_id
FROM users u
WHERE sg.user_id = u.id
AND sg.account_id IS NULL
""")
# VERIFY
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
op.alter_column(table, 'account_id', nullable=False)
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,62 +0,0 @@
"""add account_id to maintenance_schedules
Revision ID: 7f136778f5a8
Revises: 8aac5b372402
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7f136778f5a8'
down_revision: Union[str, None] = '8aac5b372402'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('maintenance_schedules',
sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_maintenance_schedules_account_id', 'maintenance_schedules', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Primary: tree_id → trees.account_id (only where tree.account_id is NOT NULL)
op.execute("""
UPDATE maintenance_schedules ms
SET account_id = t.account_id
FROM trees t
WHERE ms.tree_id = t.id
AND t.account_id IS NOT NULL
AND ms.account_id IS NULL
""")
# Fallback: created_by → users.account_id (for is_default trees with NULL account_id)
op.execute("""
UPDATE maintenance_schedules ms
SET account_id = u.account_id
FROM users u
WHERE ms.created_by = u.id
AND u.account_id IS NOT NULL
AND ms.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM maintenance_schedules WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} maintenance_schedules rows have NULL account_id. "
"Check if created_by is NULL — those rows need manual resolution."
)
op.alter_column('maintenance_schedules', 'account_id', nullable=False)
op.create_index('ix_maintenance_schedules_account_id', 'maintenance_schedules', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_maintenance_schedules_account_id', table_name='maintenance_schedules')
op.drop_constraint('fk_maintenance_schedules_account_id', 'maintenance_schedules', type_='foreignkey')
op.drop_column('maintenance_schedules', 'account_id')

View File

@@ -1,81 +0,0 @@
"""add account_id to PSA and notification tables
Revision ID: 8aac5b372402
Revises: a1d2a84b9abb
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '8aac5b372402'
down_revision: Union[str, None] = 'a1d2a84b9abb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Step 1: ADD COLUMN
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Step 2: BACKFILL
# psa_post_log: prefer psa_connection → fallback to posted_by user
# Note: cannot reference the updated table (ppl) inside the FROM clause JOIN,
# so use a correlated subquery for psa_connections lookup instead.
op.execute("""
UPDATE psa_post_log ppl
SET account_id = COALESCE(
(SELECT account_id FROM psa_connections WHERE id = ppl.psa_connection_id),
u.account_id
)
FROM users u
WHERE ppl.posted_by = u.id
AND ppl.account_id IS NULL
""")
# psa_member_mappings: via psa_connection
op.execute("""
UPDATE psa_member_mappings pmm
SET account_id = pc.account_id
FROM psa_connections pc
WHERE pmm.psa_connection_id = pc.id
AND pmm.account_id IS NULL
""")
# notification_logs: via notification_config
op.execute("""
UPDATE notification_logs nl
SET account_id = nc.account_id
FROM notification_configs nc
WHERE nl.notification_config_id = nc.id
AND nl.account_id IS NULL
""")
# Step 3: VERIFY
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
# Step 4: SET NOT NULL
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
op.alter_column(table, 'account_id', nullable=False)
# Step 5: CREATE INDEX
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,57 +0,0 @@
"""Add account_id to tree_shares and backfill via tree owner's account.
The share belongs to the tree's tenant, not the actor who created it.
A super admin in account A can share a tree owned by account B; that share
must land in account B so account B's RLS filter sees it.
Revision ID: a05e1a1bea7c
Revises: 2a9056eddd90
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a05e1a1bea7c'
down_revision: Union[str, None] = '2a9056eddd90'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('tree_shares', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_tree_shares_account_id', 'tree_shares', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the tree's account, not the creator's account.
# A share lives in the same tenant as its tree so that the tree owner's
# RLS context covers their own shares regardless of who created them.
op.execute("""
UPDATE tree_shares ts
SET account_id = t.account_id
FROM trees t
WHERE ts.tree_id = t.id
AND t.account_id IS NOT NULL
AND ts.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM tree_shares WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} tree_shares rows have NULL account_id after backfill. "
"All share entries must have a creating user with an account."
)
op.alter_column('tree_shares', 'account_id', nullable=False)
op.create_index('ix_tree_shares_account_id', 'tree_shares', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_tree_shares_account_id', table_name='tree_shares')
op.drop_constraint('fk_tree_shares_account_id', 'tree_shares', type_='foreignkey')
op.drop_column('tree_shares', 'account_id')

View File

@@ -1,45 +0,0 @@
"""add account_id to user personalization tables
Revision ID: a1d2a84b9abb
Revises: 7167e9374b0c
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1d2a84b9abb'
down_revision: Union[str, None] = '7167e9374b0c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for table in ('user_folders', 'user_pinned_trees'):
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
op.execute(f"""
UPDATE {table} t
SET account_id = u.account_id
FROM users u
WHERE t.user_id = u.id
AND t.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
op.alter_column(table, 'account_id', nullable=False)
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('user_folders', 'user_pinned_trees'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,79 +0,0 @@
"""create_internal_tickets
Revision ID: a1e6a018af02
Revises: ff6fe5895ea2
Create Date: 2026-05-28 16:29:32.624317
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1e6a018af02'
down_revision: Union[str, None] = 'ff6fe5895ea2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
def upgrade() -> None:
op.create_table(
'internal_tickets',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('account_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_name', sa.String(120), nullable=True),
sa.Column('customer_contact', sa.String(200), nullable=True),
sa.Column('problem_statement', sa.Text(), nullable=False),
sa.Column('status', sa.String(30), nullable=False, server_default='open'),
sa.Column('flow_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('flow_proposal_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('ai_session_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('assigned_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('resolution_notes', sa.Text(), nullable=True),
sa.Column('psa_promoted_ticket_id', sa.String(64), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['assigned_user_id'], ['users.id'], ondelete='SET NULL'),
sa.CheckConstraint(
"status IN ('open', 'walking', 'resolved', 'escalated')",
name='ck_internal_tickets_status',
),
)
op.create_index('ix_internal_tickets_account_id', 'internal_tickets', ['account_id'])
op.create_index('ix_internal_tickets_status', 'internal_tickets', ['status'])
op.create_index('ix_internal_tickets_assigned_user_id', 'internal_tickets', ['assigned_user_id'])
op.execute("ALTER TABLE internal_tickets ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE internal_tickets FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON internal_tickets
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON internal_tickets")
op.execute("ALTER TABLE internal_tickets DISABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE internal_tickets NO FORCE ROW LEVEL SECURITY")
op.drop_index('ix_internal_tickets_assigned_user_id', 'internal_tickets')
op.drop_index('ix_internal_tickets_status', 'internal_tickets')
op.drop_index('ix_internal_tickets_account_id', 'internal_tickets')
op.drop_table('internal_tickets')

View File

@@ -1,59 +0,0 @@
"""add_l1_columns
Revision ID: a8186f22506d
Revises: b269a1add160
Create Date: 2026-05-28 16:15:40.900535
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8186f22506d'
down_revision: Union[str, None] = 'b269a1add160'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'users',
sa.Column('can_cover_l1', sa.Boolean(), nullable=False, server_default='false'),
)
op.add_column(
'accounts',
sa.Column('l1_seats_purchased', sa.Integer(), nullable=False, server_default='0'),
)
op.add_column(
'subscriptions',
sa.Column('l1_seat_limit', sa.Integer(), nullable=True),
)
op.add_column(
'audit_logs',
sa.Column('acting_as', sa.String(30), nullable=True),
)
# Rotate account_role CHECK constraint to include 'l1_tech'
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
op.create_check_constraint(
'ck_users_account_role_enum',
'users',
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
)
def downgrade() -> None:
# Reverse the constraint rotation first
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
op.create_check_constraint(
'ck_users_account_role_enum',
'users',
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
)
op.drop_column('audit_logs', 'acting_as')
op.drop_column('subscriptions', 'l1_seat_limit')
op.drop_column('accounts', 'l1_seats_purchased')
op.drop_column('users', 'can_cover_l1')

View File

@@ -1,24 +0,0 @@
"""merge Phase 1 tenant isolation chain with main head
Revision ID: a9f3b2c1d4e5
Revises: 070, 174f442795b7
Create Date: 2026-04-09 00:00:00.000000
Merge migration: consolidates the Phase 1 account_id chain (cc214c63aa30 → … → 174f442795b7)
with the main sequential chain (… → 070) into a single head so that
`alembic upgrade head` works without ambiguity.
"""
from typing import Sequence, Union
revision: str = 'a9f3b2c1d4e5'
down_revision: Union[str, tuple] = ('070', '174f442795b7')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -1,39 +0,0 @@
"""add oauth_identities
Revision ID: b1fad5ddf357
Revises: c0f3a4b7e91d
Create Date: 2026-05-06 07:17:11.374555
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'b1fad5ddf357'
down_revision: Union[str, None] = 'c0f3a4b7e91d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"oauth_identities",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("provider_subject", sa.String(255), nullable=False),
sa.Column("provider_email_at_link", sa.String(255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
)
op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities")
op.drop_table("oauth_identities")

View File

@@ -1,72 +0,0 @@
"""add_session_policy_columns_to_accounts
Revision ID: b269a1add160
Revises: 4ce3e594cb87
Create Date: 2026-05-13 19:50:51.343777
Adds per-account session-policy overrides. NULL on either column means
"use the system default from Settings.SESSION_*_MINUTES_DEFAULT." The
CHECK constraint is defense-in-depth for the both-set case; the partial-
override case (one NULL, one set) is validated at the app layer because
the DB cannot see Settings.
See docs/plans/2026-05-13-session-expiration-policy.md for full design.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b269a1add160'
down_revision: Union[str, None] = '4ce3e594cb87'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'accounts',
sa.Column(
'session_idle_minutes',
sa.Integer(),
nullable=True,
comment=(
'Account override for idle session window in minutes. '
'NULL = use Settings.SESSION_IDLE_MINUTES_DEFAULT.'
),
),
)
op.add_column(
'accounts',
sa.Column(
'session_absolute_minutes',
sa.Integer(),
nullable=True,
comment=(
'Account override for absolute session lifetime in minutes. '
'NULL = use Settings.SESSION_ABSOLUTE_MINUTES_DEFAULT.'
),
),
)
op.create_check_constraint(
'session_idle_le_absolute_when_both_set',
'accounts',
'('
'session_idle_minutes IS NULL '
'OR session_absolute_minutes IS NULL '
'OR session_idle_minutes <= session_absolute_minutes'
')',
)
op.execute(
"COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS "
"'Defense in depth: catches idle > absolute when both are overridden. "
"Partial-override case (one NULL, one set) is validated at the app layer "
"against current system defaults, since the DB cannot see Settings.'"
)
def downgrade() -> None:
op.drop_constraint('session_idle_le_absolute_when_both_set', 'accounts', type_='check')
op.drop_column('accounts', 'session_absolute_minutes')
op.drop_column('accounts', 'session_idle_minutes')

View File

@@ -1,97 +0,0 @@
"""create_l1_walk_sessions
Revision ID: b3358ba0e48c
Revises: a1e6a018af02
Create Date: 2026-05-28 16:33:52.120027
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b3358ba0e48c'
down_revision: Union[str, None] = 'a1e6a018af02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
def upgrade() -> None:
op.create_table(
'l1_walk_sessions',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('account_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('acting_as', sa.String(30), nullable=True),
sa.Column('ticket_id', sa.String(64), nullable=False),
sa.Column('ticket_kind', sa.String(10), nullable=False),
sa.Column('session_kind', sa.String(20), nullable=False),
sa.Column('flow_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('flow_proposal_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('current_node_id', sa.String(100), nullable=True),
sa.Column('walked_path', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column('walk_notes', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column('status', sa.String(20), nullable=False, server_default='active'),
sa.Column('resolution_notes', sa.Text(), nullable=True),
sa.Column('helpful', sa.Boolean(), nullable=True),
sa.Column('escalation_reason', sa.Text(), nullable=True),
sa.Column('escalation_reason_category', sa.String(30), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('last_step_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'),
sa.CheckConstraint(
"ticket_kind IN ('psa', 'internal')",
name='ck_l1_walk_sessions_ticket_kind',
),
sa.CheckConstraint(
"session_kind IN ('flow', 'proposal', 'adhoc')",
name='ck_l1_walk_sessions_session_kind',
),
sa.CheckConstraint(
"status IN ('active', 'resolved', 'escalated', 'abandoned')",
name='ck_l1_walk_sessions_status',
),
sa.CheckConstraint(
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
name='ck_l1_walk_sessions_target_consistency',
),
)
op.create_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions', ['account_id'])
op.create_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions', ['created_by_user_id'])
op.create_index('ix_l1_walk_sessions_status', 'l1_walk_sessions', ['status'])
op.create_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions', ['last_step_at'])
op.execute("ALTER TABLE l1_walk_sessions ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE l1_walk_sessions FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON l1_walk_sessions
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON l1_walk_sessions")
op.execute("ALTER TABLE l1_walk_sessions DISABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE l1_walk_sessions NO FORCE ROW LEVEL SECURITY")
op.drop_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_status', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions')
op.drop_table('l1_walk_sessions')

View File

@@ -1,85 +0,0 @@
"""Enable RLS on Phase 4 tables — all remaining tenant-scoped tables.
All tables in this migration already have account_id NOT NULL (enforced by
earlier migrations). This migration adds ENABLE ROW LEVEL SECURITY,
FORCE ROW LEVEL SECURITY, and the appropriate tenant isolation policy to each.
Policy variants used:
- Standard: account_id = current_setting(app.current_account_id)::uuid
- Platform: standard OR account_id = PLATFORM_ACCOUNT_ID
(for global content tables readable by all tenants)
Skipped intentionally:
- accounts — IS the root table; no account_id column
- plan_feature_defaults — platform config; no account_id column
- script_categories — global lookup table; no account_id column
- platform_steps — global content; no account_id column (readable by all)
- template_trees — global content; no account_id column (readable by all)
Revision ID: b3c7e9f2a1d8
Revises: 172ad76d7d20
Create Date: 2026-04-12
"""
from typing import Union
from alembic import op
revision: str = "b3c7e9f2a1d8"
down_revision: Union[str, None] = "172ad76d7d20"
branch_labels = None
depends_on = None
# Standard policy — tenant sees only own rows.
_STANDARD_TABLES = [
"users",
"account_invites",
"account_limit_overrides",
"account_feature_overrides",
"subscriptions",
"ai_chat_sessions",
"ai_conversations",
"ai_session_steps",
"ai_session_embeddings",
"ai_suggestions",
"ai_usage",
"assistant_chats",
"attachments",
"copilot_conversations",
"feedback",
"file_uploads",
"fork_points",
"kb_imports",
"notifications",
"notification_configs",
"notification_logs",
"psa_activity_logs",
"psa_member_mappings",
"script_builder_sessions",
"session_ratings",
"tree_embeddings",
"user_folders",
"user_pinned_trees",
]
_POLICY_EXPR = (
"account_id = COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_POLICY_EXPR})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")

View File

@@ -1,108 +0,0 @@
"""enable_rls_phase1
Revision ID: c5f48b9890f9
Revises: 0b470d9e6cf1
Create Date: 2026-04-10 04:01:13.043321
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c5f48b9890f9'
down_revision: Union[str, None] = '0b470d9e6cf1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
def upgrade() -> None:
# ── trees ───────────────────────────────────────────────────────────────
# Extended policy mirrors can_access_tree() in app/core/permissions.py.
# Tenant sees: own rows, platform rows, any default tree, any public tree,
# any gallery-featured tree.
# is_gallery_featured = TRUE is included because /public/templates is a
# no-auth endpoint — no tenant context is set, so gallery trees must pass
# RLS on their own flag rather than relying on account_id or is_public.
# Private/team trees from other accounts are hidden.
op.execute("ALTER TABLE trees ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE trees FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON trees
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
OR is_default = TRUE
OR is_public = TRUE
OR is_gallery_featured = TRUE
)
""")
# ── tree_tags ────────────────────────────────────────────────────────────
# Own account + platform tags (global tags visible to all tenants).
op.execute("ALTER TABLE tree_tags ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tree_tags FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON tree_tags
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── tree_categories ──────────────────────────────────────────────────────
op.execute("ALTER TABLE tree_categories ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tree_categories FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON tree_categories
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── step_categories ──────────────────────────────────────────────────────
op.execute("ALTER TABLE step_categories ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE step_categories FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON step_categories
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── psa_connections ──────────────────────────────────────────────────────
# Tenant-only — PSA credentials must never cross tenant boundaries.
op.execute("ALTER TABLE psa_connections ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE psa_connections FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON psa_connections
USING (account_id = {_CURRENT_ACCOUNT})
""")
# ── flow_proposals ────────────────────────────────────────────────────────
# Tenant-only.
op.execute("ALTER TABLE flow_proposals ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE flow_proposals FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON flow_proposals
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
for table in ["trees", "tree_tags", "tree_categories", "step_categories",
"psa_connections", "flow_proposals"]:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -1,47 +0,0 @@
"""subscriptions pilot complimentary backfill
This migration converts existing pilot/dev accounts to permanent complimentary
Pro per the self-serve signup spec section 5. Forward-only; downgrade is
prohibited because original status is not preserved.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c6cbfc534fad"
down_revision: Union[str, None] = "c982a3fc4bf1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Set status='complimentary' and plan='pro' for all existing accounts that
don't have a canceled or past_due subscription. Pilot users transition to
permanent complimentary Pro per spec section 5.
Forward-only — does not preserve original status values."""
conn = op.get_bind()
# Update existing rows
conn.execute(sa.text("""
UPDATE subscriptions
SET status = 'complimentary', plan = 'pro',
current_period_end = NULL, current_period_start = NULL,
updated_at = now()
WHERE status NOT IN ('canceled', 'past_due')
"""))
# Backfill: any account without a Subscription row gets one
conn.execute(sa.text("""
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
"""))
def downgrade() -> None:
raise RuntimeError(
"Cannot downgrade: original subscription state is not preserved. "
"Restore from backup if needed."
)

View File

@@ -1,45 +0,0 @@
"""add stripe_events
Revision ID: c982a3fc4bf1
Revises: f7da3f93b519
Create Date: 2026-05-06 07:32:08.027633
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = 'c982a3fc4bf1'
down_revision: Union[str, None] = 'f7da3f93b519'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"stripe_events",
sa.Column("id", sa.String(length=255), primary_key=True, nullable=False),
sa.Column("event_type", sa.String(length=100), nullable=False),
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"payload_excerpt",
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.create_index("ix_stripe_events_event_type", "stripe_events", ["event_type"])
def downgrade() -> None:
op.drop_index("ix_stripe_events_event_type", table_name="stripe_events")
op.drop_table("stripe_events")

View File

@@ -1,95 +0,0 @@
"""add account_id to core session tables
Revision ID: cc214c63aa30
Revises: b8d2f4a6c091
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cc214c63aa30'
down_revision: Union[str, None] = '064'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = ('067',)
def upgrade() -> None:
# ── Step 1: ADD COLUMN (nullable) ────────────────────────────────────────
for table in ('sessions', 'attachments', 'session_supporting_data',
'session_resolution_outputs'):
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
f'fk_{table}_account_id',
table, 'accounts',
['account_id'], ['id'],
ondelete='CASCADE',
)
# ── Step 2: BACKFILL ─────────────────────────────────────────────────────
# sessions: direct join to users
op.execute("""
UPDATE sessions s
SET account_id = u.account_id
FROM users u
WHERE s.user_id = u.id
AND s.account_id IS NULL
""")
# attachments: chain through sessions (now backfilled above)
op.execute("""
UPDATE attachments a
SET account_id = s.account_id
FROM sessions s
WHERE a.session_id = s.id
AND a.account_id IS NULL
""")
# session_supporting_data: same chain
op.execute("""
UPDATE session_supporting_data sd
SET account_id = s.account_id
FROM sessions s
WHERE sd.session_id = s.id
AND sd.account_id IS NULL
""")
# session_resolution_outputs: FK is to ai_sessions, not sessions
op.execute("""
UPDATE session_resolution_outputs sro
SET account_id = ai.account_id
FROM ai_sessions ai
WHERE sro.session_id = ai.id
AND sro.account_id IS NULL
""")
# ── Step 3: VERIFY zero NULLs — raises if any remain ────────────────────
for table in ('sessions', 'attachments', 'session_supporting_data',
'session_resolution_outputs'):
result = op.get_bind().execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} NULL account_id rows remain in {table}. "
f"Fix the backfill before re-running."
)
# ── Step 4: SET NOT NULL ─────────────────────────────────────────────────
for table in ('sessions', 'attachments', 'session_supporting_data',
'session_resolution_outputs'):
op.alter_column(table, 'account_id', nullable=False)
# ── Step 5: CREATE INDEX ─────────────────────────────────────────────────
for table in ('sessions', 'attachments', 'session_supporting_data',
'session_resolution_outputs'):
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
def downgrade() -> None:
for table in ('sessions', 'attachments', 'session_supporting_data',
'session_resolution_outputs'):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
op.drop_column(table, 'account_id')

View File

@@ -1,28 +0,0 @@
"""accounts add wizard columns
Revision ID: e1af7ab57ceb
Revises: 58e3caaa6269
Create Date: 2026-05-06 07:27:15.755518
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1af7ab57ceb'
down_revision: Union[str, None] = '58e3caaa6269'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True))
op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column("accounts", "primary_psa")
op.drop_column("accounts", "team_size_bucket")

View File

@@ -1,404 +0,0 @@
"""FlowPilot migration Phase 1 — schema for the unified session surface.
Revision ID: f07010f17b01
Revises: 074
Create Date: 2026-04-17
Creates the backing store for the FlowPilot unified session surface:
- `session_facts` — "What we know" facts, keyed to a session, with a polymorphic
`source_ref` pointing at a task-lane item inside `ai_sessions.pending_task_lane`
(no DB-level FK; integrity enforced at the service layer per the design doc).
- `session_suggested_fixes` — AI-proposed resolution paths. Only one active
(`superseded_at IS NULL`) per session at a time.
- `draft_templates` — scripts pending post-resolve templatization
(Option 2 in the three-option dialog).
- `account_settings` — new per-account key/value settings table with a JSONB
`preferences` grab-bag. Rows are created lazily on first write.
- Column additions to `ai_sessions` — resolution/escalation markdown + external IDs,
plus `state_version` (incremented by any write that invalidates the resolution
note preview cache).
- Column additions to `script_templates` — provenance fields for templates
promoted from draft_templates.
All four new tenant-scoped tables have RLS enabled + forced with a
`tenant_isolation` policy matching the repo pattern (USING + WITH CHECK on
`account_id = app.current_account_id`). Downgrade is reversible: drops in the
inverse order of creation.
Chained from `074` (add_network_diagrams_table) per the single-head state of
production; the other local heads on feat/flowpilot-migration are branch
artifacts not present in production.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "f07010f17b01"
down_revision = "074"
branch_labels = None
depends_on = None
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
# ── ai_sessions: resolution / escalation columns + state_version ───────
op.add_column(
"ai_sessions",
sa.Column("resolution_note_markdown", sa.Text(), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column("resolution_note_posted_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column("resolution_note_external_id", sa.String(128), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column("escalation_package_markdown", sa.Text(), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column("escalation_package_posted_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column("escalation_package_external_id", sa.String(128), nullable=True),
)
op.add_column(
"ai_sessions",
sa.Column(
"state_version",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
)
# ── script_templates: provenance for post-resolve promotion ────────────
op.add_column(
"script_templates",
sa.Column(
"source_session_id",
UUID(as_uuid=True),
sa.ForeignKey("ai_sessions.id"),
nullable=True,
),
)
op.add_column(
"script_templates",
sa.Column(
"source_user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=True,
),
)
op.add_column(
"script_templates",
sa.Column("source_ticket_ref", sa.String(64), nullable=True),
)
# ── session_facts ──────────────────────────────────────────────────────
op.create_table(
"session_facts",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"session_id",
UUID(as_uuid=True),
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"account_id",
UUID(as_uuid=True),
sa.ForeignKey("accounts.id"),
nullable=False,
),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("source_type", sa.String(32), nullable=False),
# `source_ref` is a polymorphic pointer to a task-lane item inside
# ai_sessions.pending_task_lane JSON, NOT a FK to any table.
# Integrity enforced at the service layer per Section 4.2 of the
# migration design doc.
sa.Column("source_ref", UUID(as_uuid=True), nullable=True),
sa.Column("source_summary", sa.Text(), nullable=True),
sa.Column(
"created_by",
UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
name="ck_session_facts_source_type",
),
)
# Active-facts-per-session; partial index excludes soft-deleted rows.
op.create_index(
"idx_session_facts_session",
"session_facts",
["session_id"],
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_index(
"idx_session_facts_account",
"session_facts",
["account_id"],
)
op.execute("ALTER TABLE session_facts ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE session_facts FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON session_facts
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
# ── session_suggested_fixes ────────────────────────────────────────────
op.create_table(
"session_suggested_fixes",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"session_id",
UUID(as_uuid=True),
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"account_id",
UUID(as_uuid=True),
sa.ForeignKey("accounts.id"),
nullable=False,
),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("confidence_pct", sa.Integer(), nullable=False),
sa.Column(
"script_template_id",
UUID(as_uuid=True),
sa.ForeignKey("script_templates.id"),
nullable=True,
),
sa.Column("ai_drafted_script", sa.Text(), nullable=True),
sa.Column("ai_drafted_parameters", JSONB(), nullable=True),
sa.Column("user_decision", sa.String(32), nullable=True),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"confidence_pct BETWEEN 0 AND 100",
name="ck_session_suggested_fixes_confidence_pct",
),
sa.CheckConstraint(
"user_decision IS NULL OR user_decision IN ("
"'one_off', 'draft_template', 'build_template', 'dismissed')",
name="ck_session_suggested_fixes_user_decision",
),
)
# Only-one-active-per-session is enforced by service-layer supersession;
# this partial index serves the "find active fix" query.
op.create_index(
"idx_session_suggested_fixes_session_active",
"session_suggested_fixes",
["session_id"],
postgresql_where=sa.text("superseded_at IS NULL"),
)
op.execute("ALTER TABLE session_suggested_fixes ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE session_suggested_fixes FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON session_suggested_fixes
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
# ── draft_templates ────────────────────────────────────────────────────
op.create_table(
"draft_templates",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"account_id",
UUID(as_uuid=True),
sa.ForeignKey("accounts.id"),
nullable=False,
),
sa.Column(
"source_session_id",
UUID(as_uuid=True),
sa.ForeignKey("ai_sessions.id"),
nullable=False,
),
sa.Column(
"source_user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("script_body", sa.Text(), nullable=False),
sa.Column("proposed_parameters", JSONB(), nullable=False),
sa.Column("proposed_name", sa.String(200), nullable=True),
sa.Column(
"proposed_category_id",
UUID(as_uuid=True),
sa.ForeignKey("script_categories.id"),
nullable=True,
),
sa.Column(
"status",
sa.String(32),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"promoted_template_id",
UUID(as_uuid=True),
sa.ForeignKey("script_templates.id"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_draft_templates_status",
),
)
# Supports the Script Library "N scripts ready to review" badge.
op.create_index(
"idx_draft_templates_account_pending",
"draft_templates",
["account_id"],
postgresql_where=sa.text("status = 'pending'"),
)
op.execute("ALTER TABLE draft_templates ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE draft_templates FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON draft_templates
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
# ── account_settings ───────────────────────────────────────────────────
# One row per account, created lazily on first write. The `preferences`
# JSONB is a grab-bag for simple settings (e.g. templatize_prompt_enabled).
# Settings graduate to typed columns via future migrations when they meet
# the promotion criteria in Section 4.6 of the design doc (hot path /
# validation / joins).
op.create_table(
"account_settings",
sa.Column(
"account_id",
UUID(as_uuid=True),
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"preferences",
JSONB(),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.execute("ALTER TABLE account_settings ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE account_settings FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON account_settings
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
# Drop in reverse order so FK dependencies unwind cleanly.
op.execute("DROP POLICY IF EXISTS tenant_isolation ON account_settings")
op.execute("ALTER TABLE account_settings DISABLE ROW LEVEL SECURITY")
op.drop_table("account_settings")
op.execute("DROP POLICY IF EXISTS tenant_isolation ON draft_templates")
op.execute("ALTER TABLE draft_templates DISABLE ROW LEVEL SECURITY")
op.drop_index("idx_draft_templates_account_pending", table_name="draft_templates")
op.drop_table("draft_templates")
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_suggested_fixes")
op.execute("ALTER TABLE session_suggested_fixes DISABLE ROW LEVEL SECURITY")
op.drop_index(
"idx_session_suggested_fixes_session_active",
table_name="session_suggested_fixes",
)
op.drop_table("session_suggested_fixes")
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_facts")
op.execute("ALTER TABLE session_facts DISABLE ROW LEVEL SECURITY")
op.drop_index("idx_session_facts_account", table_name="session_facts")
op.drop_index("idx_session_facts_session", table_name="session_facts")
op.drop_table("session_facts")
op.drop_column("script_templates", "source_ticket_ref")
op.drop_column("script_templates", "source_user_id")
op.drop_column("script_templates", "source_session_id")
op.drop_column("ai_sessions", "state_version")
op.drop_column("ai_sessions", "escalation_package_external_id")
op.drop_column("ai_sessions", "escalation_package_posted_at")
op.drop_column("ai_sessions", "escalation_package_markdown")
op.drop_column("ai_sessions", "resolution_note_external_id")
op.drop_column("ai_sessions", "resolution_note_posted_at")
op.drop_column("ai_sessions", "resolution_note_markdown")

View File

@@ -1,41 +0,0 @@
"""add plan_billing
Revision ID: f236a91224d0
Revises: 2aa73d3231c2
Create Date: 2026-05-06 07:30:06.807887
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f236a91224d0'
down_revision: Union[str, None] = '2aa73d3231c2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plan_billing",
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("monthly_price_cents", sa.Integer(), nullable=True),
sa.Column("annual_price_cents", sa.Integer(), nullable=True),
sa.Column("stripe_product_id", sa.String(255), nullable=True),
sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True),
sa.Column("stripe_annual_price_id", sa.String(255), nullable=True),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("plan_billing")

View File

@@ -1,57 +0,0 @@
"""add sales_leads
Revision ID: f7da3f93b519
Revises: f236a91224d0
Create Date: 2026-05-06 07:31:39.533305
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'f7da3f93b519'
down_revision: Union[str, None] = 'f236a91224d0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"sales_leads",
sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("company", sa.String(length=255), nullable=False),
sa.Column("team_size", sa.String(length=20), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("posthog_distinct_id", sa.String(length=255), nullable=True),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'new'"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_sales_leads_email", "sales_leads", ["email"])
def downgrade() -> None:
op.drop_index("ix_sales_leads_email", table_name="sales_leads")
op.drop_table("sales_leads")

View File

@@ -1,52 +0,0 @@
"""extend_flow_proposals_l1
Revision ID: ff6fe5895ea2
Revises: a8186f22506d
Create Date: 2026-05-28 16:26:06.932886
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ff6fe5895ea2'
down_revision: Union[str, None] = 'a8186f22506d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('flow_proposals', sa.Column('source', sa.String(30), nullable=True))
op.add_column('flow_proposals', sa.Column('linked_ticket_id', sa.String(64), nullable=True))
op.add_column('flow_proposals', sa.Column('linked_ticket_kind', sa.String(10), nullable=True))
op.add_column(
'flow_proposals',
sa.Column('validated_by_outcome', sa.Boolean(), nullable=False, server_default='false'),
)
# Backfill existing rows then enforce NOT NULL on source
op.execute("UPDATE flow_proposals SET source = 'manual_draft' WHERE source IS NULL")
op.alter_column('flow_proposals', 'source', nullable=False)
op.create_check_constraint(
'ck_flow_proposals_source',
'flow_proposals',
"source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')",
)
op.create_check_constraint(
'ck_flow_proposals_linked_ticket_kind',
'flow_proposals',
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
)
def downgrade() -> None:
op.drop_constraint('ck_flow_proposals_linked_ticket_kind', 'flow_proposals', type_='check')
op.drop_constraint('ck_flow_proposals_source', 'flow_proposals', type_='check')
op.drop_column('flow_proposals', 'validated_by_outcome')
op.drop_column('flow_proposals', 'linked_ticket_kind')
op.drop_column('flow_proposals', 'linked_ticket_id')
op.drop_column('flow_proposals', 'source')

View File

@@ -7,17 +7,9 @@ from sqlalchemy import select
import sentry_sdk
from app.core.database import get_db
from jose import JWTError
from app.core.security import (
IdleTokenExpired,
decode_refresh_token_strict,
decode_token,
)
from app.core.security import decode_token
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.core.tenant_context import set_current_account_id, clear_current_account_id
from app.core.admin_database import get_admin_db # noqa: F401 — re-exported for use in endpoints
# Routes that are allowed even when must_change_password is True
_PASSWORD_CHANGE_ALLOWLIST = {
@@ -30,14 +22,10 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
"""Get current authenticated user from JWT token.
Must use get_admin_db (BYPASSRLS): this dep runs before require_tenant_context
sets app.current_account_id, so the users table RLS would block the lookup.
"""
"""Get current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
@@ -70,72 +58,15 @@ async def get_current_user(
return user
async def get_current_user_optional(
request: Request,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> Optional[User]:
"""Best-effort current user for endpoints that work both anonymous and authed.
Returns None on missing/invalid/expired token instead of raising. Used by
surfaces like /config/public that anonymous clients can hit but where an
authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS
allowlist override).
"""
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
return None
token = auth_header.split(None, 1)[1].strip()
if not token:
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
try:
user_uuid = UUID(user_id)
except ValueError:
return None
result = await db.execute(select(User).where(User.id == user_uuid))
return result.scalar_one_or_none()
async def get_refresh_token_payload(
token: Annotated[str, Depends(oauth2_scheme)]
) -> dict:
"""Extract and validate a refresh token from the Authorization header.
Returns one of three outcomes via HTTP 401 `detail`:
- `session_expired_idle` — JWT signature valid but `exp` past
- `invalid_refresh_token` — any other decode failure, or `type != "refresh"`
- (200 path) — returns the decoded payload
The frontend uses these to choose between the "your session ended for
security" banner and a plain logout redirect. See
docs/plans/2026-05-13-session-expiration-policy.md §4.10.
"""
try:
payload = decode_refresh_token_strict(token)
except IdleTokenExpired:
"""Extract and validate a refresh token from the Authorization header."""
payload = decode_token(token)
if payload is None or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired_idle",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
headers={"WWW-Authenticate": "Bearer"},
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
@@ -144,15 +75,10 @@ async def get_refresh_token_payload(
async def get_current_active_user(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Ensure user is active (not disabled). Enforces must_change_password —
blocks all routes except allowlist.
Trial expiry enforcement now happens via require_active_subscription in
individual routers, NOT here. This dep no longer mutates Subscription
state.
"""
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
Enforces must_change_password — blocks all routes except allowlist."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -170,6 +96,26 @@ async def get_current_active_user(
# Set Sentry user context for error attribution
sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email})
# Lightweight trial expiry check
if current_user.account_id:
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
subscription = result.scalar_one_or_none()
if (
subscription
and subscription.status == "trialing"
and subscription.current_period_end
and subscription.current_period_end < datetime.now(timezone.utc)
):
subscription.plan = "free"
subscription.status = "active"
subscription.current_period_end = None
subscription.current_period_start = None
await db.commit()
return current_user
@@ -199,53 +145,6 @@ async def require_engineer_or_admin(
)
async def require_l1(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""L1 tech exact-match (with super_admin bypass for support)."""
if current_user.is_super_admin:
return current_user
if current_user.account_role != "l1_tech":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="L1 tech role required",
)
return current_user
async def require_l1_or_coverage(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""L1 endpoints: l1_tech, owners, super_admin, or engineers with can_cover_l1=True."""
if current_user.is_super_admin:
return current_user
role = current_user.account_role
if role == "l1_tech":
return current_user
if role == "owner":
return current_user
if role == "engineer" and current_user.can_cover_l1:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="L1 access requires l1_tech role or engineer coverage flag",
)
async def require_l1_or_above(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Any tier from l1_tech upward (l1_tech, engineer, owner, super_admin)."""
if current_user.is_super_admin:
return current_user
if current_user.account_role in ("l1_tech", "engineer", "owner"):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="L1 or above required",
)
async def require_team_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
@@ -291,158 +190,3 @@ async def get_plan_limits_for_user(
"""Get plan limits for the current user's account."""
from app.core.subscriptions import get_user_plan_limits
return await get_user_plan_limits(current_user.account_id, db)
async def require_tenant_context(
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Set per-request tenant context for RLS.
Raises 403 if the authenticated user has no account_id — never falls back
to PLATFORM_ACCOUNT_ID (that would grant platform-scope access to a
malformed account).
Sets the ContextVar that the SQLAlchemy transaction-begin listener reads to
issue set_config('app.current_account_id', …, true) on every transaction.
Applied to every user-facing router. NOT applied to /admin/* routers or
public endpoints (auth, shared, webhooks).
"""
if current_user.account_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account required",
)
token = set_current_account_id(current_user.account_id)
try:
yield
finally:
clear_current_account_id(token)
async def require_admin_db(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
) -> AsyncSession:
"""Return a BYPASSRLS admin DB session after verifying super_admin role.
Use on /admin/* endpoints that query RLS-protected tables. Replaces
Depends(get_db) on the db parameter of those endpoints.
The current_user dep is still declared separately on the endpoint if
the user object is needed in the handler.
"""
return db
_SUBSCRIPTION_GUARD_ALLOWLIST = {
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password/change",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
"/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
"/api/v1/users/me/onboarding-dismiss-rest",
}
async def require_active_subscription(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Returns the Subscription row when the account has access; raises 402
when locked. Mounted on routers requiring Pro entitlement.
'Locked' = (trialing AND current_period_end < now()) OR
(canceled OR incomplete OR no subscription).
Active states: active, complimentary, trialing-with-time-remaining, past_due.
"""
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
return None
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(
status_code=402,
detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"},
)
now = datetime.now(timezone.utc)
is_live = (
sub.status in ("active", "complimentary", "past_due")
or (
sub.status == "trialing"
and sub.current_period_end is not None
and sub.current_period_end > now
)
)
if not is_live:
raise HTTPException(
status_code=402,
detail={
"error": "subscription_inactive",
"status": sub.status,
"plan": sub.plan,
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
"upgrade_url": "/account/billing/select-plan",
},
)
return sub
_EMAIL_VERIFICATION_ALLOWLIST = {
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
"/api/v1/auth/password/change",
"/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
"/api/v1/users/me/onboarding-dismiss-rest",
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
}
VERIFICATION_GRACE_DAYS = 7
async def require_verified_email_after_grace(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Enforces 'this user has verified email OR is still in 7-day grace.'
OAuth signups bypass cleanly because /auth/{google,microsoft}/callback
sets users.email_verified_at = now() (provider-attested)."""
from datetime import datetime, timezone, timedelta
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
return
if current_user.email_verified_at is not None:
return
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
if datetime.now(timezone.utc) < grace_ends:
return
raise HTTPException(
status_code=403,
detail={
"error": "email_not_verified",
"grace_ended_at": grace_ends.isoformat(),
"resend_url": "/api/v1/auth/email/send-verification",
},
)

View File

@@ -1,54 +0,0 @@
"""Public endpoint for resolving an account invite code into display info.
Mounted as a public route (no tenant context, no auth) — used by the
/accept-invite page on the frontend so an invitee can see what account they
are about to join before they sign up. Uses the BYPASSRLS admin session
factory because account_invites is account-scoped under Phase 4 RLS but the
caller has no tenant identity yet.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.admin_database import get_admin_db
from app.models.account_invite import AccountInvite
from app.schemas.oauth import InviteLookupResponse
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
async def lookup_invite(
code: str,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> InviteLookupResponse:
"""Return minimal display data for a valid (unused, unexpired, not revoked)
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
invalid state — the AcceptInvitePage shows a single "ask the inviter to
resend" message regardless of which condition failed (anti-enumeration)."""
result = await db.execute(
select(AccountInvite)
.where(AccountInvite.code == code)
.options(
joinedload(AccountInvite.account),
joinedload(AccountInvite.invited_by),
)
)
invite = result.scalar_one_or_none()
if invite is None or not invite.is_valid:
raise HTTPException(
status_code=404,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
return InviteLookupResponse(
account_name=invite.account.name,
inviter_name=invite.invited_by.name,
invited_email=invite.email,
role=invite.role,
)

View File

@@ -1,214 +0,0 @@
"""Account session-policy endpoints — owner-only.
GET /accounts/me/security — read the policy + system bounds.
PATCH /accounts/me/security — set or clear the per-account override.
POST /accounts/me/security/revoke-sessions lands in the next commit.
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11.
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_account_owner
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import resolve_session_policy
from app.models.account import Account
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.schemas.account_security import (
ActiveUser,
RevokeSessionsRequest,
RevokeSessionsResponse,
SessionPolicyResponse,
SessionPolicyUpdateRequest,
)
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
def _policy_response(
account: Account, active_users: list[ActiveUser]
) -> SessionPolicyResponse:
eff_idle, eff_abs = resolve_session_policy(account)
return SessionPolicyResponse(
idle_minutes=account.session_idle_minutes,
absolute_minutes=account.session_absolute_minutes,
effective_idle_minutes=eff_idle,
effective_absolute_minutes=eff_abs,
idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN,
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
active_users=active_users,
)
async def _load_account(db: AsyncSession, account_id) -> Account:
return (
await db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
"""Return distinct users in this account who currently hold an
un-revoked refresh token. See plan §4.7."""
from app.models.refresh_token import RefreshToken
stmt = (
select(User.id, User.name, User.email, User.last_login)
.join(RefreshToken, RefreshToken.user_id == User.id)
.where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
.distinct()
.order_by(User.last_login.desc().nulls_last())
)
rows = (await db.execute(stmt)).all()
return [
ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
for row in rows
]
@router.get("", response_model=SessionPolicyResponse)
async def get_session_policy(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
active_users = await _load_active_users(db, current_user.account_id)
return _policy_response(account, active_users)
@router.patch("", response_model=SessionPolicyResponse)
async def update_session_policy(
body: SessionPolicyUpdateRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
# Snapshot effective values BEFORE change, for audit.
old_idle = account.session_idle_minutes
old_abs = account.session_absolute_minutes
effective_old_idle, effective_old_abs = resolve_session_policy(account)
new_idle = body.idle_minutes
new_abs = body.absolute_minutes
# Per-field bound checks. NULL clears the override and is always valid.
if new_idle is not None and not (
settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} "
f"and {settings.SESSION_IDLE_MINUTES_MAX}"
),
)
if new_abs is not None and not (
settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} "
f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}"
),
)
# Effective-value invariant: idle must not exceed absolute after defaults.
# The DB CHECK only catches the both-set case; this catches the partial-
# override case where (e.g.) idle=43200 with absolute=NULL would yield an
# effective idle larger than the system default absolute.
effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT
effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
if effective_new_idle > effective_new_abs:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Effective idle ({effective_new_idle}min) cannot exceed effective "
f"absolute ({effective_new_abs}min)"
),
)
account.session_idle_minutes = new_idle
account.session_absolute_minutes = new_abs
await log_audit(
db,
user_id=current_user.id,
account_id=account.id,
action="account.session_policy_update",
resource_type="account",
resource_id=account.id,
details={
"old": {"idle_minutes": old_idle, "absolute_minutes": old_abs},
"new": {"idle_minutes": new_idle, "absolute_minutes": new_abs},
"effective_old": {
"idle_minutes": effective_old_idle,
"absolute_minutes": effective_old_abs,
},
"effective_new": {
"idle_minutes": effective_new_idle,
"absolute_minutes": effective_new_abs,
},
},
)
await db.commit()
await db.refresh(account)
active_users = await _load_active_users(db, account.id)
return _policy_response(account, active_users)
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
async def revoke_sessions(
body: RevokeSessionsRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Bulk-revoke refresh tokens for users in the caller's account.
`scope="all"` revokes every active session in the account, including
the caller's own. `scope="others"` preserves the caller's sessions.
The caller's access token is NOT revoked (we don't track access JTIs);
it dies on its 5-minute timer. For `scope="all"`, the frontend is
expected to log the caller out locally after the response.
See docs/plans/2026-05-13-session-expiration-policy.md §4.11.
"""
# Subquery: refresh-token rows belonging to users in this account.
user_ids_subq = select(User.id).where(User.account_id == current_user.account_id)
stmt = (
sa_update(RefreshToken)
.where(
RefreshToken.user_id.in_(user_ids_subq),
RefreshToken.revoked_at.is_(None),
)
.values(revoked_at=datetime.now(timezone.utc))
.returning(RefreshToken.id)
)
if body.scope == "others":
stmt = stmt.where(RefreshToken.user_id != current_user.id)
result = await db.execute(stmt)
revoked_count = len(result.all())
await log_audit(
db,
user_id=current_user.id,
account_id=current_user.account_id,
action="account.sessions_revoked_bulk",
resource_type="account",
resource_id=current_user.account_id,
details={"scope": body.scope, "revoked_count": revoked_count},
)
await db.commit()
return RevokeSessionsResponse(revoked_count=revoked_count)

View File

@@ -9,66 +9,23 @@ from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit
from app.models.refresh_token import RefreshToken
from app.core.email import EmailService
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.account_settings import AccountSettings
from app.models.subscription import Subscription
from app.models.user import User
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
from app.schemas.user import UserResponse, AccountRoleUpdate, CoverageUpdate
from app.schemas.user import UserResponse, AccountRoleUpdate
from app.core.security import verify_password
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
from app.services.seat_enforcement import check_seat_available, get_seat_usage
from app.schemas.seat_enforcement import SeatUsage
_SEAT_CHECKED_ROLES = frozenset({"engineer", "l1_tech"})
from app.api.deps import get_current_active_user, require_account_owner
router = APIRouter(prefix="/accounts", tags=["accounts"])
async def _load_account(db: AsyncSession, account_id: UUID) -> Account:
"""Load an Account by id; raises 404 if missing."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
return account
async def _enforce_seat_limit(db: AsyncSession, account_id: UUID, role: str) -> None:
"""Raise HTTP 402 if the account has no capacity for the given role.
Only fires for seat-counted roles (engineer, l1_tech).
Accounts without a subscription (free / pre-billing) are not blocked.
Grandfathering: if current > limit, existing users keep access; this
helper only blocks new additions.
"""
if role not in _SEAT_CHECKED_ROLES:
return
sub = await get_account_subscription(account_id, db)
if sub is None:
return # no subscription → no enforcement
account = await _load_account(db, account_id)
seat_result = await check_seat_available(account, sub, role, db)
if not seat_result.available:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "seat_limit_exceeded",
"role": seat_result.role,
"current": seat_result.current,
"limit": seat_result.limit,
"upgrade_url": "/account/billing",
},
)
@router.get("/me", response_model=AccountResponse)
async def get_my_account(
db: Annotated[AsyncSession, Depends(get_db)],
@@ -129,41 +86,6 @@ async def get_my_members(
return result.scalars().all()
@router.get("/me/seats", response_model=SeatUsage)
async def get_my_account_seat_usage(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)],
):
"""Returns engineer + l1_tech seat-usage counts. Accessible to engineer+.
Powers the SeatCounterWidget on admin/users and account/users surfaces.
"""
account = await _load_account(db, current_user.account_id)
sub = await get_account_subscription(current_user.account_id, db)
if sub is None:
# No subscription → treat as unlimited; return live counts with no limit
from sqlalchemy import func
engineer_count = (await db.execute(
select(func.count(User.id))
.where(User.account_id == account.id)
.where(User.account_role == "engineer")
.where(User.is_active.is_(True))
)).scalar_one()
l1_count = (await db.execute(
select(func.count(User.id))
.where(User.account_id == account.id)
.where(User.account_role == "l1_tech")
.where(User.is_active.is_(True))
)).scalar_one()
from app.schemas.seat_enforcement import SeatCheckResult
return SeatUsage(
engineer=SeatCheckResult(available=True, current=engineer_count, limit=None, role="engineer"),
l1_tech=SeatCheckResult(available=True, current=l1_count, limit=None, role="l1_tech"),
)
engineer, l1_tech = await get_seat_usage(account, sub, db)
return SeatUsage(engineer=engineer, l1_tech=l1_tech)
@router.patch("/me", response_model=AccountResponse)
async def update_my_account(
data: AccountUpdate,
@@ -217,58 +139,16 @@ async def update_member_role(
detail="Cannot change your own role"
)
# Seat enforcement: check capacity before promoting to a seat-counted role.
# Demotions (engineer/l1_tech → viewer) and lateral moves skip the check.
if data.account_role != user.account_role:
await _enforce_seat_limit(db, current_user.account_id, data.account_role)
user.account_role = data.account_role
await db.commit()
await db.refresh(user)
return user
@router.patch("/me/members/{user_id}/coverage", response_model=UserResponse)
async def update_member_coverage(
user_id: UUID,
data: CoverageUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)],
):
"""Toggle the `can_cover_l1` flag on an engineer in your account.
Owner-only. Returns 404 if target user not in your account. Returns 422
if target user's role is not 'engineer' (coverage flag only applies to
engineers — owners/super_admins already see L1 surface; viewers/l1_techs
don't need this flag).
"""
result = await db.execute(
select(User).where(
User.id == user_id,
User.account_id == current_user.account_id,
)
)
target = result.scalar_one_or_none()
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in your account",
)
if target.account_role != "engineer":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="can_cover_l1 only applies to engineers",
)
target.can_cover_l1 = data.can_cover_l1
await db.commit()
await db.refresh(target)
return target
@router.post("/me/transfer-ownership", response_model=AccountResponse)
async def transfer_ownership(
data: TransferOwnershipRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Transfer account ownership to another member (owner only)."""
@@ -378,10 +258,7 @@ async def create_invite(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Create an invite to join this account (owner only). Sends invite email."""
# Seat enforcement: block invite if the target role is at capacity.
await _enforce_seat_limit(db, current_user.account_id, data.role)
"""Create an invite to join this account (owner only)."""
code = secrets.token_urlsafe(16)
expires_at = None
@@ -397,115 +274,11 @@ async def create_invite(
expires_at=expires_at,
)
db.add(invite)
await db.flush()
# Lookup account name for email
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
# Send invite email — non-blocking on failure (function returns False on error)
email_sent = await EmailService.send_account_invite_email(
to_email=invite.email,
code=code,
account_name=account.name,
role=invite.role,
)
if email_sent:
invite.email_sent_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(invite)
return invite
@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED)
async def create_invites_bulk(
payload: AccountInviteBulkCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Create multiple invites in one call (wizard step 3 supports up to N).
Per-row failures are returned in `failed`; successes in `created`."""
# Lookup account once for email rendering
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
created: list[AccountInvite] = []
failed: list[dict] = []
for invite_data in payload.invites:
try:
# Seat enforcement per invite row — 402 bubbles as an HTTPException
# which is caught below and recorded in `failed`.
await _enforce_seat_limit(db, current_user.account_id, invite_data.role)
code = secrets.token_urlsafe(16)
expires_at = None
if invite_data.expires_in_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days)
invite = AccountInvite(
account_id=current_user.account_id,
invited_by_id=current_user.id,
email=invite_data.email,
code=code,
role=invite_data.role,
expires_at=expires_at,
)
db.add(invite)
await db.flush()
email_sent = await EmailService.send_account_invite_email(
to_email=invite.email,
code=code,
account_name=account.name,
role=invite.role,
)
if email_sent:
invite.email_sent_at = datetime.now(timezone.utc)
created.append(invite)
except HTTPException as exc:
failed.append({"email": invite_data.email, "error": exc.detail})
except Exception as e:
failed.append({"email": invite_data.email, "error": str(e)})
await db.commit()
for inv in created:
await db.refresh(inv)
return AccountInviteBulkResponse(created=created, failed=failed)
@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_invite(
invite_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Soft-revoke an invitation by setting revoked_at. Idempotent on already-
revoked invites; rejects already-accepted invites."""
result = await db.execute(
select(AccountInvite).where(
AccountInvite.id == invite_id,
AccountInvite.account_id == current_user.account_id,
)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if invite.is_revoked:
return None # idempotent
if invite.is_used:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite")
invite.revoked_at = datetime.now(timezone.utc)
await db.commit()
return None
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
async def resend_invite(
invite_id: UUID,
@@ -604,7 +377,7 @@ async def list_invites(
@router.post("/me/leave")
async def leave_account(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Leave the current account (non-owners only). Creates a personal account."""
@@ -650,7 +423,7 @@ class DeleteAccountRequest(BaseModel):
@router.delete("/me")
async def delete_account(
data: DeleteAccountRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Delete the current account and soft-delete the user (owner only, no other members)."""
@@ -785,65 +558,3 @@ async def get_sso_status(
sso_enabled=account.sso_enabled,
sso_provider=account.sso_provider,
)
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
#
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
# write. Any engineer-role user can read + update preferences because the
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
# are team-level toggles rather than account-owner-gated admin settings.
class AccountPreferencesResponse(BaseModel):
preferences: dict
class AccountPreferencesUpdate(BaseModel):
"""Merge-style update — each key in `preferences` overwrites that key in
the stored JSONB, other keys are preserved. Omit the body entirely to
no-op.
"""
preferences: dict
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
async def get_my_preferences(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Return the current account's preferences JSONB (empty dict if no row)."""
result = await db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == current_user.account_id
)
)
prefs = result.scalar_one_or_none() or {}
return AccountPreferencesResponse(preferences=prefs)
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
async def update_my_preferences(
data: AccountPreferencesUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Upsert preference keys. Existing keys not present in the payload are kept.
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
from the post-resolve "Don't ask me again for this team" checkbox sets
just that key without clobbering any other preferences.
"""
for key, value in data.preferences.items():
await AccountSettings.set_setting(db, current_user.account_id, key, value)
await db.commit()
# Return the merged state so the client doesn't need a second GET.
result = await db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == current_user.account_id
)
)
prefs = result.scalar_one_or_none() or {}
return AccountPreferencesResponse(preferences=prefs)

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload, aliased
from app.core.admin_database import get_admin_db
from app.core.database import get_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
@@ -57,7 +57,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
page: Optional[int] = Query(None, ge=1),
size: Optional[int] = Query(None, ge=1, le=100),
@@ -153,7 +153,7 @@ async def list_users(
@router.get("/accounts", response_model=AdminAccountListResponse)
async def list_accounts(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1),
size: int = Query(12, ge=1, le=100),
@@ -427,23 +427,14 @@ async def _get_account_detail_payload(
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_account(
data: AdminAccountCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new account without requiring an initial user."""
owner_id = None
if data.owner_email:
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
owner_id = owner.id
display_code = await _generate_unique_display_code(db)
new_account = Account(
name=data.name.strip(),
display_code=display_code,
owner_id=owner_id,
)
db.add(new_account)
await db.flush()
@@ -457,7 +448,7 @@ async def create_account(
await log_audit(
db, current_user.id, "account.create_admin", "account", new_account.id,
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email},
{"name": new_account.name, "plan": data.plan},
)
await db.commit()
return await _get_account_detail_payload(new_account.id, db)
@@ -466,7 +457,7 @@ async def create_account(
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def get_account_detail(
account_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = Query(False),
):
@@ -478,7 +469,7 @@ async def get_account_detail(
async def update_account(
account_id: UUID,
data: AdminAccountUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update account settings from the admin panel."""
@@ -500,7 +491,7 @@ async def update_account(
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new user with a temporary password (super admin only).
@@ -625,7 +616,7 @@ async def create_user(
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Get enriched user details (super admin only)."""
@@ -743,7 +734,7 @@ async def get_user(
async def update_user_role(
user_id: UUID,
role_data: RoleUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change user role (super admin only)."""
@@ -775,7 +766,7 @@ async def update_user_role(
async def update_account_role(
user_id: UUID,
data: AccountRoleUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change a user's account role (super admin only)."""
@@ -801,7 +792,7 @@ async def update_account_role(
async def update_super_admin_status(
user_id: UUID,
data: dict,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Promote or demote a user to/from super admin (super admin only)."""
@@ -840,7 +831,7 @@ async def update_super_admin_status(
@router.put("/users/{user_id}/deactivate", response_model=UserResponse)
async def deactivate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Deactivate a user account (super admin only)."""
@@ -869,7 +860,7 @@ async def deactivate_user(
@router.put("/users/{user_id}/activate", response_model=UserResponse)
async def activate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Reactivate a user account (super admin only)."""
@@ -893,7 +884,7 @@ async def activate_user(
async def move_user_account(
user_id: UUID,
data: MoveUserAccount,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Move a user to a different account (super admin only)."""
@@ -968,11 +959,11 @@ async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple
async def update_user_plan(
user_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "starter", "enterprise"):
if data.plan not in ("free", "pro", "team"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
user, subscription = await _get_user_subscription(user_id, db)
old_plan = subscription.plan
@@ -987,11 +978,11 @@ async def update_user_plan(
async def update_account_plan(
account_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change an account subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "starter", "enterprise"):
if data.plan not in ("free", "pro", "team"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
account, subscription = await _get_account_subscription(account_id, db)
old_plan = subscription.plan
@@ -1012,7 +1003,7 @@ async def update_account_plan(
async def extend_user_trial(
user_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for a user's subscription (super admin only)."""
@@ -1042,7 +1033,7 @@ async def extend_user_trial(
async def extend_account_trial(
account_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for an account subscription (super admin only)."""
@@ -1079,7 +1070,7 @@ async def extend_account_trial(
async def admin_reset_password(
user_id: UUID,
data: AdminPasswordReset,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Admin-triggered password reset (super admin only).
@@ -1150,7 +1141,7 @@ async def admin_reset_password(
@router.put("/users/{user_id}/archive", response_model=UserResponse)
async def archive_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive (soft delete) a user (super admin only)."""
@@ -1185,7 +1176,7 @@ async def archive_user(
@router.put("/users/{user_id}/restore", response_model=UserResponse)
async def restore_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Restore an archived user (super admin only)."""
@@ -1210,7 +1201,7 @@ async def restore_user(
@router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse)
async def hard_delete_check(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
@@ -1283,7 +1274,7 @@ async def hard_delete_check(
@router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT)
async def hard_delete_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a user (super admin only). User must be archived first."""
@@ -1343,7 +1334,7 @@ async def hard_delete_user(
@router.post("/invites", status_code=status.HTTP_201_CREATED)
async def admin_create_invite(
data: dict,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Quick-invite a user to an account (super admin only).

View File

@@ -4,26 +4,25 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.admin_database import get_admin_db
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.category import TreeCategory
from app.models.tree import Tree
from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse
from app.api.deps import require_admin
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/admin/categories", tags=["admin-categories"])
@router.get("/global", response_model=list[GlobalCategoryResponse])
async def list_global_categories(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all global categories (account_id IS NULL)."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID).order_by(TreeCategory.name)
select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name)
)
categories = result.scalars().all()
@@ -46,36 +45,36 @@ async def list_global_categories(
@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_global_category(
data: GlobalCategoryCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a global category."""
# Check slug uniqueness for global categories
existing = await db.execute(
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists")
category = TreeCategory(name=data.name, slug=data.slug, account_id=PLATFORM_ACCOUNT_ID)
category = TreeCategory(name=data.name, slug=data.slug, account_id=None)
db.add(category)
await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name})
await db.commit()
await db.refresh(category)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=PLATFORM_ACCOUNT_ID, tree_count=0)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0)
@router.put("/global/{category_id}", response_model=GlobalCategoryResponse)
async def update_global_category(
category_id: UUID,
data: GlobalCategoryUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:
@@ -87,7 +86,7 @@ async def update_global_category(
# Check slug uniqueness
existing = await db.execute(
select(TreeCategory).where(
TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID, TreeCategory.id != category_id
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
)
)
if existing.scalar_one_or_none():
@@ -104,19 +103,19 @@ async def update_global_category(
return GlobalCategoryResponse(
id=category.id, name=category.name, slug=category.slug,
account_id=PLATFORM_ACCOUNT_ID, tree_count=tree_count,
account_id=None, tree_count=tree_count,
)
@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_global_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete (archive) a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.admin_database import get_admin_db
from app.core.database import get_db
from app.models.user import User
from app.models.subscription import Subscription
from app.models.tree import Tree
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"])
@router.get("/metrics", response_model=DashboardMetrics)
async def get_dashboard_metrics(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get platform overview metrics."""
@@ -28,7 +28,7 @@ async def get_dashboard_metrics(
) or 0
paid_accounts = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.plan.in_(["pro", "starter", "enterprise"])
Subscription.plan.in_(["pro", "team"])
)
) or 0
total_trees = await db.scalar(
@@ -45,7 +45,7 @@ async def get_dashboard_metrics(
@router.get("/activity", response_model=list[ActivityEntry])
async def get_dashboard_activity(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get recent audit log entries for activity feed."""

View File

@@ -12,7 +12,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.admin_database import get_admin_db
from app.core.database import get_db
from app.models.script_template import ScriptTemplate
from app.models.tree import Tree
from app.models.user import User
@@ -66,7 +66,7 @@ def _script_summary(script: ScriptTemplate) -> dict:
@router.get("/featured")
async def list_featured(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all featured flows and scripts (super admin only)."""
@@ -92,7 +92,7 @@ async def list_featured(
@router.get("/items")
async def list_all_items(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List ALL flows and scripts with their gallery status (super admin only)."""
@@ -119,7 +119,7 @@ async def list_all_items(
async def toggle_flow_featured(
flow_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a flow (super admin only)."""
@@ -138,7 +138,7 @@ async def toggle_flow_featured(
async def update_flow_sort_order(
flow_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a flow (super admin only)."""
@@ -157,7 +157,7 @@ async def update_flow_sort_order(
async def toggle_script_featured(
script_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a script (super admin only)."""
@@ -176,7 +176,7 @@ async def toggle_script_featured(
async def update_script_sort_order(
script_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a script (super admin only)."""

View File

@@ -8,101 +8,34 @@ from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.models.plan_billing import PlanBilling
from app.models.account import Account
from app.models.account_limit_override import AccountLimitOverride
from app.models.subscription import Subscription
from app.schemas.admin import (
PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
PlanLimitResponse, PlanLimitUpdate,
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
)
from app.api.deps import require_admin
from app.services.billing import BillingService
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits).
_PLAN_BILLING_FIELDS = (
"display_name",
"description",
"monthly_price_cents",
"annual_price_cents",
"stripe_product_id",
"stripe_monthly_price_id",
"stripe_annual_price_id",
"is_public",
"is_archived",
"sort_order",
)
# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model.
# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit
# null for any of them would otherwise trigger a NOT NULL violation at commit.
_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({
"display_name",
"is_public",
"is_archived",
"sort_order",
})
def _merge_plan_with_billing(
plan: PlanLimits, billing: PlanBilling | None
) -> PlanLimitWithBillingResponse:
"""Build a merged response. Billing fields are None when no plan_billing row
exists for the plan."""
payload = {
"plan": plan.plan,
"max_trees": plan.max_trees,
"max_sessions_per_month": plan.max_sessions_per_month,
"max_users": plan.max_users,
"custom_branding": plan.custom_branding,
"priority_support": plan.priority_support,
"export_formats": plan.export_formats or [],
}
if billing is not None:
payload.update({
"display_name": billing.display_name,
"description": billing.description,
"monthly_price_cents": billing.monthly_price_cents,
"annual_price_cents": billing.annual_price_cents,
"stripe_product_id": billing.stripe_product_id,
"stripe_monthly_price_id": billing.stripe_monthly_price_id,
"stripe_annual_price_id": billing.stripe_annual_price_id,
"is_public": billing.is_public,
"is_archived": billing.is_archived,
"sort_order": billing.sort_order,
})
return PlanLimitWithBillingResponse(**payload)
@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse])
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
async def list_plan_limits(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all plan limit configurations, merged with plan_billing fields
where present. Plans without a plan_billing row return None for the
billing fields."""
rows = (await db.execute(
select(PlanLimits, PlanBilling)
.outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan)
)).all()
return [_merge_plan_with_billing(pl, pb) for pl, pb in rows]
"""List all plan limit configurations."""
result = await db.execute(select(PlanLimits))
return result.scalars().all()
@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse)
@router.put("/plan-limits", response_model=PlanLimitResponse)
async def update_plan_limits(
data: PlanLimitUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan's limits and (if any plan_billing field is included)
upsert the matching plan_billing row in the same transaction. After
commit, invalidates the in-process billing cache for accounts on this
plan (currently a no-op — see BillingService.invalidate_billing_cache).
"""
"""Update a plan's limits."""
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
plan = result.scalar_one_or_none()
if not plan:
@@ -115,50 +48,10 @@ async def update_plan_limits(
plan.priority_support = data.priority_support
plan.export_formats = data.export_formats
# Did the request include any plan_billing field? (Pydantic gives us
# `model_fields_set` to distinguish "user passed null" from "field omitted".)
billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS)
billing: PlanBilling | None = None
if billing_fields_set:
billing = (await db.execute(
select(PlanBilling).where(PlanBilling.plan == data.plan)
)).scalar_one_or_none()
if billing is None:
# Create. display_name is required on the model — derive from the
# plan name when the caller didn't supply one (e.g. "pro" → "Pro").
display_name = data.display_name or data.plan.capitalize()
billing = PlanBilling(plan=data.plan, display_name=display_name)
db.add(billing)
# Apply only the fields the caller actually included. Allows partial
# updates without clobbering existing values.
for field in billing_fields_set:
value = getattr(data, field)
if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS:
# Don't NULL out a NOT NULL column on update.
continue
setattr(billing, field, value)
await log_audit(
db, current_user.id, "plan_limits.update", "plan_limits",
details={"plan": data.plan, "updated_billing": bool(billing_fields_set)},
)
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
await db.commit()
await db.refresh(plan)
if billing is not None:
await db.refresh(billing)
# Invalidate any in-process billing cache for accounts on this plan.
# TODO: invalidate app.state.billing_cache when added.
account_ids = [
row[0] for row in (await db.execute(
select(Subscription.account_id).where(Subscription.plan == data.plan)
)).all()
]
await BillingService.invalidate_billing_cache(account_ids)
return _merge_plan_with_billing(plan, billing)
return plan
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])

View File

@@ -15,7 +15,7 @@ from datetime import datetime
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import or_, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -49,6 +49,8 @@ from app.schemas.ai_session import (
ChatMessageRequest,
ChatMessageResponse,
SaveTaskLaneRequest,
TriagePatchRequest,
TriagePatchResponse,
)
from app.services import flowpilot_engine
from app.services import unified_chat_service
@@ -120,6 +122,11 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
pending_task_lane=session.pending_task_lane,
is_branching=getattr(session, 'is_branching', False),
active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None,
client_name=getattr(session, 'client_name', None),
asset_name=getattr(session, 'asset_name', None),
issue_category=getattr(session, 'issue_category', None),
triage_hypothesis=getattr(session, 'triage_hypothesis', None),
evidence_items=getattr(session, 'evidence_items', None),
)
@@ -301,7 +308,7 @@ async def send_chat_message(
message = f"{message}\n\n[Attached document content]\n{doc_context}"
try:
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message(
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data = await unified_chat_service.send_chat_message(
session_id=session_id,
user_id=user_id,
account_id=account_id,
@@ -346,6 +353,7 @@ async def send_chat_message(
fork=fork_metadata,
actions=actions_data,
questions=questions_data,
triage_update=triage_update_data,
)
@@ -442,7 +450,12 @@ async def resolve_session(
try:
from app.services.resolution_output_generator import ResolutionOutputGenerator
gen = ResolutionOutputGenerator(db)
await gen.generate_all(session_id)
await gen.generate_all(
session_id,
root_cause=data.root_cause,
steps_taken=data.steps_taken,
recommendations=data.recommendations,
)
except Exception:
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
@@ -452,13 +465,6 @@ async def resolve_session(
# ── Escalate ──
#
# Thin shim over HandoffManager. The legacy `flowpilot_engine.escalate_session`
# is no longer the source of truth — every escalation now creates a
# SessionHandoff row, fans out via the SSE bus, dispatches AppNotification +
# external channels via notify(), and emails per-user. EscalateModal and the
# /handoff endpoint both funnel through here / through HandoffManager so the
# senior-pickup magic-moment screen works regardless of entry point.
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
@@ -466,62 +472,25 @@ async def escalate_session(
request: Request,
session_id: UUID,
data: EscalateSessionRequest,
background_tasks: BackgroundTasks,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Escalate a FlowPilot session — unified through HandoffManager."""
from app.services.handoff_manager import HandoffManager, enrich_escalation_async
# Owner-only — matches the original constraint on flowpilot_engine.escalate_session.
session_result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = session_result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
manager = HandoffManager(db)
"""Escalate a FlowPilot session to another engineer."""
try:
handoff = await manager.create_handoff(
result = await flowpilot_engine.escalate_session(
session_id=session_id,
intent="escalate",
engineer_notes=data.escalation_reason,
request=data,
user_id=current_user.id,
priority="normal",
target_user_id=data.escalated_to_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
documentation, psa_result = await manager.finalize_escalation(
handoff, session, current_user.id
)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
await manager.dispatch_escalation_notifications(handoff)
# AI enrichment (Sonnet assessment + enhanced escalation_package) runs
# in the background so the escalating engineer doesn't wait on
# 15-25s of model latency. Result lands on the handoff row when ready;
# the senior's magic-moment screen reads it at pickup time.
background_tasks.add_task(
enrich_escalation_async, handoff.id, current_user.id
)
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
**psa_result,
)
return result
# ── Pause ──
@@ -563,15 +532,11 @@ async def save_task_lane(
_: None = Depends(require_engineer_or_admin),
):
"""Save the current task lane state including user's in-progress responses."""
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
session = await db.get(AISession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
payload = {
"questions": [q.model_dump() for q in body.questions],
@@ -588,6 +553,122 @@ async def save_task_lane(
await db.commit()
# ── Triage Metadata ──
@router.patch("/{session_id}/triage", response_model=TriagePatchResponse)
@limiter.limit("30/minute")
async def update_triage(
request: Request,
session_id: UUID,
body: TriagePatchRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Update triage metadata on a session (incident header fields)."""
session = await db.get(AISession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
patch_data = body.model_dump(exclude_unset=True)
for field, value in patch_data.items():
setattr(session, field, value)
await db.commit()
await db.refresh(session)
return TriagePatchResponse(
id=session.id,
client_name=session.client_name,
asset_name=session.asset_name,
issue_category=session.issue_category,
triage_hypothesis=session.triage_hypothesis,
evidence_items=session.evidence_items,
)
# ── Handoff Draft ──
@router.post("/{session_id}/handoff-draft")
@limiter.limit("10/minute")
async def handoff_draft(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Stream a structured handoff draft for the conclude modal."""
from fastapi.responses import StreamingResponse
from app.services.assistant_chat_service import _call_ai
session = await db.get(AISession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
# Build context from session data
context_parts = [
f"Problem: {session.problem_summary or 'Unknown'}",
f"Domain: {session.problem_domain or 'Unknown'}",
f"Client: {session.client_name or 'Unknown'}",
f"Asset: {session.asset_name or 'Unknown'}",
f"Hypothesis: {session.triage_hypothesis or 'None'}",
]
if session.evidence_items:
context_parts.append("\nEvidence collected:")
for item in session.evidence_items:
status_icon = {"confirmed": "", "ruled_out": "", "pending": "?"}.get(item.get("status", ""), "?")
context_parts.append(f" {status_icon} {item.get('text', '')}")
# Include task lane steps if available
if session.pending_task_lane:
actions = session.pending_task_lane.get("actions", [])
if actions:
context_parts.append("\nSteps taken:")
for a in actions:
context_parts.append(f" - {a.get('label', '')}")
# Include last 20 conversation messages
msgs = session.conversation_messages or []
if msgs:
context_parts.append("\nRecent conversation:")
for msg in msgs[-20:]:
role = msg.get("role", "unknown")
content = msg.get("content", "")[:300]
context_parts.append(f" [{role}]: {content}")
context = "\n".join(context_parts)
prompt = (
"Generate a structured handoff summary for this troubleshooting session.\n"
"Return ONLY valid JSON with exactly these four fields:\n"
'{"root_cause": "...", "resolution": "...", "steps_taken": ["step1", "step2"], "recommendations": "..."}\n\n'
f"Session context:\n{context}"
)
async def generate():
try:
content, _, _ = await _call_ai(
system_base="You are a concise technical documentation assistant for MSP teams. Return only JSON.",
rag_context="",
history=[],
new_message=prompt,
max_tokens=1024,
)
yield f"data: {content}\n\n"
except Exception as e:
logger.exception(f"Handoff draft generation failed for session {session_id}")
import json
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
# ── Resume ──
@router.post("/{session_id}/resume", status_code=204)
@@ -688,8 +769,7 @@ async def get_escalation_queue(
select(AISession)
.where(
scope_filter,
AISession.status.in_(("requesting_escalation", "escalated")),
AISession.user_id != current_user.id,
AISession.status == "requesting_escalation",
)
.order_by(AISession.created_at.desc())
)
@@ -811,13 +891,13 @@ async def search_sessions(
limit: int = Query(5, ge=1, le=20),
):
"""Search AI sessions by content using full-text search. Used by Command Palette."""
# Sessions are user-scoped. The list endpoint uses user_id only;
# search must be consistent. Cross-user access requires explicit
# escalation or session sharing — not ambient account membership.
result = await db.execute(
select(AISession)
.where(
AISession.user_id == current_user.id,
or_(
AISession.user_id == current_user.id,
AISession.account_id == current_user.account_id,
),
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
)
.params(q=q)
@@ -883,25 +963,13 @@ async def list_sessions(
date_to: Optional[datetime] = Query(None),
q: Optional[str] = Query(None, min_length=2, max_length=200),
):
"""List the current user's AI sessions (owned or picked up).
"Picked up" includes both the legacy escalation_package.picked_up_by
marker (set by flowpilot_engine.pickup_session) AND the new
escalated_to_id field (set by HandoffManager.claim_session for the
unified handoff/escalate path). Without the escalated_to_id branch
the senior tech wouldn't see a session they just claimed in their
chat sidebar — the picked-up session lands as the active chat with
no entry in the list, which is what the user reported as "4 versions
of the session" (their unrelated owned sessions show up while the
claimed one is invisible).
"""
"""List the current user's AI sessions (owned or picked up)."""
user_id_str = str(current_user.id)
query = (
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.escalated_to_id == current_user.id,
AISession.escalation_package["picked_up_by"].as_string() == user_id_str,
)
)
@@ -958,22 +1026,11 @@ async def get_session(
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
# Allow access if user is owner, escalation target, or picked-up handler.
# Sessions in transit (requesting_escalation / escalated) are also
# readable by any account member — the whole point of escalation is that
# other techs can see the context before claiming. Tenant boundary is
# enforced by RLS on the underlying query, so account-scope is the right
# ceiling for in-transit reads.
# Allow access if user is owner, escalation target, or picked-up handler
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
is_in_transit = session.status in ("requesting_escalation", "escalated")
if (
session.user_id != current_user.id
and session.escalated_to_id != current_user.id
and not is_handler
and not is_in_transit
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
return _build_session_detail(session)
@@ -989,18 +1046,6 @@ async def get_documentation(
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get auto-generated documentation for a session."""
# Verify session ownership — owner only. Documentation endpoints require direct
# ownership; escalated_to_id / picked_up_by handlers use get_session (read-only).
# This is consistent with stream_documentation which has the same owner-only check.
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Session not found")
try:
return await flowpilot_engine.get_session_documentation(
session_id=session_id,
@@ -1026,14 +1071,13 @@ async def stream_documentation(
# Verify session ownership
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
select(AISession).where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
async def event_generator():
try:
@@ -1128,19 +1172,6 @@ async def retry_psa_push_endpoint(
"""Manually retry a failed PSA documentation push."""
from app.models.psa_post_log import PsaPostLog
# Verify the session belongs to the current user
session_result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not session_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Find the latest failed push log for this session
result = await db.execute(
select(PsaPostLog)

View File

@@ -43,7 +43,6 @@ async def create_suggestion(
suggestion = AISuggestion(
tree_id=data.tree_id,
user_id=current_user.id,
account_id=current_user.account_id,
session_id=data.session_id,
action_type=data.action_type,
target_node_id=data.target_node_id,

View File

@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.core.filters import tenant_filter
from app.models import User, Session, Tree, SessionRating
from app.schemas.analytics import (
TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse,
@@ -291,13 +290,8 @@ async def get_flow_analytics(
current_user: User = Depends(get_current_active_user),
):
"""Analytics for a specific flow."""
# Verify tree exists and belongs to the requesting user's account.
result = await db.execute(
select(Tree).where(
Tree.id == tree_id,
tenant_filter(Tree, current_user.account_id),
)
)
# Verify tree exists
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Flow not found")

View File

@@ -1,6 +1,6 @@
import logging
import secrets
import string
import uuid
from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from app.core.config import settings
from app.core.settings_manager import SettingsManager
from app.core.admin_database import get_admin_db
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.core.security import (
verify_password,
@@ -20,7 +20,6 @@ from app.core.security import (
create_email_verification_token,
decode_token,
hash_token,
resolve_session_policy,
)
from app.models.user import User
from app.models.invite_code import InviteCode
@@ -28,6 +27,7 @@ from app.models.refresh_token import RefreshToken
from app.models.account import Account
from app.models.subscription import Subscription
from app.models.account_invite import AccountInvite
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from app.schemas.user import UserCreate, UserResponse, UserLogin, UserUpdate
from app.schemas.token import Token
from app.schemas.auth_password import (
@@ -43,21 +43,11 @@ from app.core.email import EmailService
from app.api.deps import get_current_active_user, get_refresh_token_payload
from app.core.audit import log_audit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database.
Module-public so OAuth callback endpoints (and any future token-issuing
surface) can register the JTI in the ``refresh_tokens`` table the same
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
will reject the token as "revoked" because no row exists.
Caller is responsible for committing the session.
"""
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database."""
payload = decode_token(refresh_token_str)
if payload and payload.get("jti"):
token_record = RefreshToken(
@@ -68,136 +58,18 @@ async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id)
db.add(token_record)
async def _mint_session_tokens(user: User, db: AsyncSession) -> Token:
"""Mint a fresh refresh+access pair for a new login.
Snapshots the account's current session policy into the refresh JWT
(auth_time/idle_max/abs_max) and registers the JTI in refresh_tokens.
Caller is responsible for committing the session. Use this for every
NEW login (password, OAuth, etc.) — for /auth/refresh use
_refresh_session_tokens instead, which carries claims forward.
See docs/plans/2026-05-13-session-expiration-policy.md §4.6.
"""
account = (
await db.execute(select(Account).where(Account.id == user.account_id))
).scalar_one()
idle_minutes, abs_minutes = resolve_session_policy(account)
idle_max_seconds = idle_minutes * 60
abs_max_seconds = abs_minutes * 60
now = datetime.now(timezone.utc)
auth_time_unix = int(now.timestamp())
refresh_token_str = create_refresh_token(
user_id=str(user.id),
auth_time=auth_time_unix,
idle_max_seconds=idle_max_seconds,
abs_max_seconds=abs_max_seconds,
)
access_token = create_access_token(data={"sub": str(user.id)})
await store_refresh_token(db, refresh_token_str, user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
absolute_expires_at=datetime.fromtimestamp(
auth_time_unix + abs_max_seconds, tz=timezone.utc
),
)
async def _resolve_refresh_claims(
payload: dict, user: User, db: AsyncSession
) -> tuple[int, int, int]:
"""Return (auth_time, idle_max_seconds, abs_max_seconds) for a refresh.
Grandfathers legacy tokens issued before the session-policy PR: tokens
missing any of auth_time/idle_max/abs_max get treated as if just minted
under the account's current policy. One free rotation under the new
rules — see plan §5.1. Callers that have the claims use them as-is.
"""
auth_time = payload.get("auth_time")
idle_max_seconds = payload.get("idle_max")
abs_max_seconds = payload.get("abs_max")
if auth_time is None or idle_max_seconds is None or abs_max_seconds is None:
account = (
await db.execute(select(Account).where(Account.id == user.account_id))
).scalar_one()
idle_minutes, abs_minutes = resolve_session_policy(account)
auth_time = int(datetime.now(timezone.utc).timestamp())
idle_max_seconds = idle_minutes * 60
abs_max_seconds = abs_minutes * 60
return auth_time, idle_max_seconds, abs_max_seconds
async def _mint_with_claims(
user: User,
auth_time: int,
idle_max_seconds: int,
abs_max_seconds: int,
db: AsyncSession,
) -> Token:
"""Mint a refresh+access pair carrying explicit session-policy claims.
Used by /auth/refresh after the grandfather + absolute-cap checks
have already produced the effective claim values. Caller commits.
"""
now = datetime.now(timezone.utc)
refresh_token_str = create_refresh_token(
user_id=str(user.id),
auth_time=auth_time,
idle_max_seconds=idle_max_seconds,
abs_max_seconds=abs_max_seconds,
)
access_token = create_access_token(data={"sub": str(user.id)})
await store_refresh_token(db, refresh_token_str, user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
absolute_expires_at=datetime.fromtimestamp(
auth_time + abs_max_seconds, tz=timezone.utc
),
)
def _generate_display_code() -> str:
"""Generate a random 8-character alphanumeric display code."""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(8))
async def _reject_if_oauth_only(db: AsyncSession, user) -> None:
"""If the user has no password_hash, raise 400 with a list of linked
providers so the client can redirect them to the right OAuth flow."""
if user is None or user.password_hash is not None:
return
from app.models.oauth_identity import OAuthIdentity
result = await db.execute(
select(OAuthIdentity.provider).where(OAuthIdentity.user_id == user.id)
)
providers = [row for row in result.scalars().all()]
raise HTTPException(
status_code=400,
detail={"error": "use_oauth_provider", "providers": providers},
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("3/minute")
async def register(
request: Request,
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Register a new user.
@@ -238,24 +110,10 @@ async def register(
detail="Account invite code has expired"
)
if account_invite_record.email.lower() != user_data.email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "invite_email_mismatch"},
)
# Validate platform invite code (skip if account invite was provided)
invite_code_record = None
if not account_invite_record:
# When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed
# entirely — public self-serve signup is the whole point. The
# invite_code field stays in the schema for backward compatibility
# and so paid/trial-bearing codes still apply when supplied.
if (
settings.REQUIRE_INVITE_CODE
and not settings.is_self_serve_active_for(user_data.email)
and not user_data.invite_code
):
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
@@ -289,33 +147,6 @@ async def register(
detail="Invite code has expired"
)
# Seat enforcement: re-check at accept time (race-condition guard).
# Fires only when an account invite is being accepted and the target role
# is seat-counted (engineer, l1_tech). Accounts without a subscription
# (free / pre-billing) are not blocked.
if account_invite_record and account_invite_record.role in ("engineer", "l1_tech"):
from app.core.subscriptions import get_account_subscription
from app.services.seat_enforcement import check_seat_available
from app.models.account import Account as _Account
sub = await get_account_subscription(account_invite_record.account_id, db)
if sub is not None:
acct_result = await db.execute(
select(_Account).where(_Account.id == account_invite_record.account_id)
)
acct = acct_result.scalar_one()
seat_result = await check_seat_available(acct, sub, account_invite_record.role, db)
if not seat_result.available:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "seat_limit_exceeded",
"role": seat_result.role,
"current": seat_result.current,
"limit": seat_result.limit,
"upgrade_url": "/account/billing",
},
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
existing_user = result.scalar_one_or_none()
@@ -366,30 +197,26 @@ async def register(
# Now set account owner and create subscription
new_account.owner_id = new_user.id
# Apply plan/trial from invite code if present
sub_plan = "free"
sub_status = "active"
period_start = None
period_end = None
if invite_code_record and invite_code_record.assigned_plan:
# Plan/trial driven by platform invite code (existing pilot flow)
sub_plan = invite_code_record.assigned_plan
sub_status = "active"
period_start = None
period_end = None
if invite_code_record.trial_duration_days:
sub_status = "trialing"
period_start = datetime.now(timezone.utc)
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
db.add(Subscription(
account_id=new_account.id,
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
))
else:
# New self-serve shop — start the standard Pro trial.
# start_trial commits internally; flush our pending User/Account changes
# first so the FK is satisfied.
await db.flush()
from app.services.billing import BillingService
await BillingService.start_trial(db, new_account.id)
new_subscription = Subscription(
account_id=new_account.id,
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
)
db.add(new_subscription)
# Mark platform invite code as used
if invite_code_record:
@@ -399,34 +226,6 @@ async def register(
await db.commit()
await db.refresh(new_user)
# Auto-send verification email for newly-registered users.
# Skip silently if verification already done (shouldn't happen for fresh
# users, but defensive).
if new_user.email_verified_at is None:
verification_enabled = await SettingsManager.get(
"email_verification_enabled", db, default=True
)
if verification_enabled:
try:
raw_token = create_email_verification_token(str(new_user.id))
payload = decode_token(raw_token)
if payload and payload.get("jti"):
token_record = EmailVerificationToken(
token_hash=hash_token(payload["jti"]),
user_id=new_user.id,
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
)
db.add(token_record)
await db.commit()
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
await EmailService.send_email_verification_email(
to_email=new_user.email,
verification_url=verification_url,
)
except Exception as e:
logger.warning("verification email send failed for %s: %s", new_user.email, e)
return new_user
@@ -435,14 +234,13 @@ async def register(
async def login(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Login and get access token."""
# Find user by email
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
await _reject_if_oauth_only(db, user)
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -453,9 +251,20 @@ async def login(
# Update last login
user.last_login = datetime.now(timezone.utc)
token = await _mint_session_tokens(user, db)
# Create tokens
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return token
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
)
@router.post("/login/json", response_model=Token)
@@ -463,13 +272,12 @@ async def login(
async def login_json(
request: Request,
credentials: UserLogin,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Login with JSON body (alternative to form data)."""
result = await db.execute(select(User).where(User.email == credentials.email))
user = result.scalar_one_or_none()
await _reject_if_oauth_only(db, user)
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -478,9 +286,19 @@ async def login_json(
user.last_login = datetime.now(timezone.utc)
token = await _mint_session_tokens(user, db)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return token
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
)
@router.post("/refresh", response_model=Token)
@@ -488,41 +306,15 @@ async def login_json(
async def refresh_token(
request: Request,
payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Refresh access token, enforcing both idle and absolute session windows.
Algorithm (see plan §4.5):
1. Decode refresh JWT (the dep already rejects idle-expired tokens with
session_expired_idle).
2. Load the user. If missing or inactive, 401 invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max (grandfather legacy
tokens that pre-date this PR).
4. Atomically revoke the JTI regardless of outcome — so an absolute-
expired token cannot be replayed; the second attempt finds it
already revoked and gets invalid_refresh_token instead.
5. If the atomic UPDATE matched zero rows, 401 invalid_refresh_token.
6. If now >= auth_time + abs_max, 401 session_expired_absolute.
7. Otherwise mint new tokens carrying the claims forward.
"""
"""Refresh access token using refresh token (rotation: old token is revoked)."""
user_id = payload.get("sub")
jti = payload.get("jti")
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
)
auth_time, idle_max_seconds, abs_max_seconds = await _resolve_refresh_claims(
payload, user, db
)
# Atomically revoke the old refresh token first — this consumes the
# token regardless of whether the absolute check passes, so an absolute-
# expired token cannot be replayed.
# Atomically revoke the old refresh token (token rotation).
# Using a conditional UPDATE prevents the race where two concurrent
# refresh requests both read revoked_at=NULL and both succeed.
if jti:
token_hash = hash_token(jti)
result = await db.execute(
@@ -535,31 +327,35 @@ async def refresh_token(
.returning(RefreshToken.id, RefreshToken.user_id)
)
revoked_row = result.fetchone()
if not revoked_row:
# Either the token doesn't exist or was already revoked/used
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
detail="Refresh token has been revoked"
)
# Absolute-window check. Boundary is `>=`, not `>` — a deadline equal to
# now is expired. The token row has already been revoked above, so the
# client cannot retry this token even though we're raising after the
# consume.
now_unix = int(datetime.now(timezone.utc).timestamp())
if now_unix >= auth_time + abs_max_seconds:
# Commit the revoke so the consumed-on-failure invariant survives
# any subsequent rollback in the request lifecycle.
await db.commit()
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired_absolute",
detail="User not found"
)
token = await _mint_with_claims(
user, auth_time, idle_max_seconds, abs_max_seconds, db
)
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store new refresh token
await _store_refresh_token(db, new_refresh_token_str, user.id)
await db.commit()
return token
return Token(
access_token=access_token,
refresh_token=new_refresh_token_str,
token_type="bearer"
)
@router.get("/me", response_model=UserResponse)
@@ -574,7 +370,7 @@ async def get_me(
async def update_me(
data: UserUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Update current user's profile (name, email)."""
update_fields = data.model_fields_set - {"current_password"}
@@ -621,7 +417,7 @@ async def update_me(
@router.post("/logout")
async def logout(
payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Logout user by revoking the refresh token."""
jti = payload.get("jti")
@@ -644,10 +440,9 @@ async def change_password(
request: Request,
data: ChangePasswordRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Change the current user's password."""
await _reject_if_oauth_only(db, current_user)
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -685,13 +480,13 @@ async def change_password(
async def forgot_password(
request: Request,
data: ForgotPasswordRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Request a password reset email. Always returns success (anti-enumeration)."""
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if user and user.password_hash is not None:
if user:
# Create reset token JWT
raw_token = create_password_reset_token(str(user.id))
payload = decode_token(raw_token)
@@ -720,7 +515,7 @@ async def forgot_password(
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
async def verify_reset_token(
data: VerifyResetTokenRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Verify a password reset token is valid."""
payload = decode_token(data.token)
@@ -751,7 +546,7 @@ async def verify_reset_token(
async def reset_password(
request: Request,
data: ResetPasswordRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Reset password using a valid reset token."""
payload = decode_token(data.token)
@@ -818,7 +613,7 @@ async def reset_password(
@router.get("/email/verification-status")
async def get_verification_status(
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Check if email verification is enabled on the platform."""
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -830,7 +625,7 @@ async def get_verification_status(
async def send_verification_email(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Send an email verification link to the current user."""
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -869,7 +664,7 @@ async def send_verification_email(
@router.post("/email/verify")
async def verify_email(
data: dict,
db: Annotated[AsyncSession, Depends(get_admin_db)]
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Verify an email using a token. Public endpoint."""
token = data.get("token")
@@ -925,3 +720,59 @@ async def verify_email(
await db.commit()
return {"message": "Email verified successfully"}
@router.get("/me/feature-flags", response_model=dict[str, bool])
async def get_my_feature_flags(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict[str, bool]:
"""Resolve feature flags for the current user's account and plan."""
plan = "free"
if current_user.account_id:
sub_result = await db.execute(
select(Subscription).where(
Subscription.account_id == current_user.account_id,
Subscription.status.in_(["active", "trialing"]),
)
)
sub = sub_result.scalar_one_or_none()
if sub:
plan = sub.plan
flags_result = await db.execute(select(FeatureFlag))
flags = flags_result.scalars().all()
if not flags:
return {}
flag_ids = [f.id for f in flags]
defaults_result = await db.execute(
select(PlanFeatureDefault).where(
PlanFeatureDefault.flag_id.in_(flag_ids),
PlanFeatureDefault.plan == plan,
)
)
plan_defaults = {d.flag_id: d.enabled for d in defaults_result.scalars().all()}
overrides: dict[uuid.UUID, bool] = {}
if current_user.account_id:
overrides_result = await db.execute(
select(AccountFeatureOverride).where(
AccountFeatureOverride.flag_id.in_(flag_ids),
AccountFeatureOverride.account_id == current_user.account_id,
)
)
overrides = {o.flag_id: o.enabled for o in overrides_result.scalars().all()}
resolved = {}
for flag in flags:
if flag.id in overrides:
resolved[flag.flag_key] = overrides[flag.id]
elif flag.id in plan_defaults:
resolved[flag.flag_key] = plan_defaults[flag.id]
else:
resolved[flag.flag_key] = False
return resolved

View File

@@ -1,44 +1,31 @@
"""Legacy beta signup endpoint — redirects to /register?from=beta.
Phase 2 (self-serve signup) makes the public register flow the canonical
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
preserve any external links that still hit it, but now responds with a
307 Temporary Redirect to `/register?from=beta` so the user lands in the
real signup flow. The `?from=beta` marker lets the frontend tag the
signup origin for analytics.
Note: there is no `beta_signup` database table — the original endpoint
only fired a notification email. There is therefore no waitlist to email
and no migration to run when retiring the endpoint.
"""
"""Public beta signup endpoint — no auth required."""
import logging
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
from app.core.config import settings
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
from app.core.email import EmailService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/beta-signup", tags=["beta"])
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
# be absolute — a relative URL would resolve against the API origin
# (api.resolutionflow.com), which has no /register page.
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
class BetaSignupRequest(BaseModel):
email: EmailStr
@router.post("", include_in_schema=False)
async def beta_signup_redirect() -> RedirectResponse:
"""Redirect legacy beta-signup POST to the public register page.
class BetaSignupResponse(BaseModel):
success: bool
message: str
Returns 307 so any client following the redirect preserves the HTTP
method; the frontend treats `/register?from=beta` as the canonical
entry point and reads the `from` query param for analytics.
"""
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
return RedirectResponse(
url=f"{frontend_url}/register?from=beta",
status_code=307,
@router.post("", response_model=BetaSignupResponse)
async def beta_signup(data: BetaSignupRequest):
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
sent = await EmailService.send_beta_signup_notification(data.email)
if not sent:
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
return BetaSignupResponse(
success=True,
message="Thanks! We'll be in touch with beta access details.",
)

View File

@@ -1,76 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.models.account import Account
from app.models.user import User
from app.schemas.billing import (
BillingPortalSessionResponse,
BillingStateResponse,
CheckoutSessionCreate,
CheckoutSessionResponse,
)
from app.services.billing import BillingService
router = APIRouter(prefix="/billing", tags=["billing"])
@router.post("/checkout-session", response_model=CheckoutSessionResponse)
async def create_checkout_session(
payload: CheckoutSessionCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> CheckoutSessionResponse:
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
url = await BillingService.create_checkout_session(
db=db,
account=account,
plan=payload.plan,
seats=payload.seats,
billing_interval=payload.billing_interval,
success_url=f"{settings.FRONTEND_URL}/account/billing?success=1",
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
)
return CheckoutSessionResponse(url=url)
@router.get("/state", response_model=BillingStateResponse)
async def get_billing_state(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> BillingStateResponse:
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
state = await BillingService.get_billing_state(db, account)
return BillingStateResponse(**state)
@router.get("/portal-session", response_model=BillingPortalSessionResponse)
async def get_billing_portal_session(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> BillingPortalSessionResponse:
"""Return a Stripe-hosted Customer Portal URL for the account so the user
can update card / cancel. Allowlisted from the subscription + email-verify
guards (a canceled or unverified-past-grace user must still be able to
update billing)."""
if not settings.stripe_enabled:
raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"})
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
try:
url = await BillingService.open_customer_portal(account)
except ValueError:
raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"})
return BillingPortalSessionResponse(url=url)

View File

@@ -12,8 +12,6 @@ from app.models.user import User
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_category, can_create_category
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.core.filters import tenant_filter
router = APIRouter(prefix="/categories", tags=["categories"])
@@ -49,13 +47,13 @@ async def list_categories(
elif current_user.account_id:
query = query.where(
or_(
TreeCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
TreeCategory.account_id.is_(None), # Global
TreeCategory.account_id == current_user.account_id # User's account
)
)
else:
# User has no account, only show global categories
query = query.where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
query = query.where(TreeCategory.account_id.is_(None))
query = query.order_by(TreeCategory.display_order, TreeCategory.name)
@@ -110,12 +108,10 @@ async def get_category(
detail="You don't have access to this category"
)
# Get tree count — scoped to the requesting account so cross-account
# trees in shared categories are not counted.
# Get tree count
count_query = select(func.count(Tree.id)).where(
Tree.category_id == category.id,
Tree.is_active == True,
tenant_filter(Tree, current_user.account_id),
Tree.is_active == True
)
count_result = await db.execute(count_query)
tree_count = count_result.scalar() or 0
@@ -177,7 +173,7 @@ async def create_category(
name=category_data.name,
slug=slug,
description=category_data.description,
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
account_id=category_data.account_id,
display_order=max_order + 1,
created_by=current_user.id
)

View File

@@ -1,50 +0,0 @@
"""Public runtime configuration endpoint.
GET /api/v1/config/public
Returns the small set of runtime flags the frontend needs at app load
to decide whether to render the self-serve signup flow and which OAuth
buttons to show. No authentication required.
The response model lives in `app.schemas.config` so it can be reused by
frontend codegen and other call sites if needed.
"""
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user_optional
from app.core.config import settings
from app.models.user import User
from app.schemas.config import PublicConfigResponse
router = APIRouter(prefix="/config", tags=["config"])
@router.get("/public", response_model=PublicConfigResponse)
async def get_public_config(
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
) -> PublicConfigResponse:
"""Return public-safe runtime config.
`oauth_providers` reflects which OAuth client IDs are configured server
side; the frontend uses it to render only buttons that will actually
succeed. `self_serve_enabled` is the master switch for the new public
self-serve signup flow; an authenticated caller whose email is on the
INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag
is off, so internal validation in prod test mode can exercise the full
surface before the public flip.
"""
providers: list[str] = []
if settings.GOOGLE_CLIENT_ID:
providers.append("google")
if settings.MS_CLIENT_ID:
providers.append("microsoft")
user_email = current_user.email if current_user else None
return PublicConfigResponse(
self_serve_enabled=settings.is_self_serve_active_for(user_email),
oauth_providers=providers,
)

View File

@@ -15,7 +15,6 @@ from app.schemas.device_type import (
DeviceTypeUpdate,
DeviceTypeResponse,
)
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/device-types", tags=["device-types"])
@@ -29,8 +28,8 @@ async def list_device_types(
select(DeviceType)
.where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == current_user.account_id,
DeviceType.is_system.is_(True),
DeviceType.team_id == current_user.team_id,
)
)
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
@@ -49,16 +48,16 @@ async def create_device_type(
existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == current_user.account_id,
DeviceType.team_id == current_user.team_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team")
system_existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.is_system.is_(True),
)
)
if system_existing.scalar_one_or_none():
@@ -69,7 +68,7 @@ async def create_device_type(
label=data.label,
category=data.category,
is_system=False,
account_id=current_user.account_id,
team_id=current_user.team_id,
sort_order=data.sort_order,
)
db.add(device_type)
@@ -90,7 +89,7 @@ async def update_device_type(
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot modify system device types")
if device_type.account_id != current_user.account_id:
if device_type.team_id != current_user.team_id:
raise HTTPException(status_code=404, detail="Device type not found")
update_data = data.model_dump(exclude_unset=True)
@@ -113,7 +112,7 @@ async def delete_device_type(
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot delete system device types")
if device_type.account_id != current_user.account_id:
if device_type.team_id != current_user.team_id:
raise HTTPException(status_code=404, detail="Device type not found")
await db.delete(device_type)

View File

@@ -1,221 +0,0 @@
"""Draft template endpoints — Phase 6 post-resolve templatization flow.
Engineers who picked "Run now, templatize after resolve" on the three-option
dialog (Phase 5) generate a `draft_templates` row at decision time. After
the session resolves, the TemplatizePrompt component lets them either:
- Accept → promotes the draft to a real `script_templates` row
- Reject → marks the draft rejected, no library entry created
The Script Library sidebar uses the list endpoint to surface a
"X drafts ready to review" badge for the account.
See FLOWPILOT-MIGRATION.md Section 5.3.
"""
import logging
import re
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.models.ai_session import AISession
from app.models.draft_template import DraftTemplate
from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.user import User
from app.schemas.draft_template import (
DraftTemplateAcceptRequest,
DraftTemplateAcceptResponse,
DraftTemplateListResponse,
DraftTemplateRejectResponse,
DraftTemplateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/draft-templates", tags=["draft-templates"])
def _slugify(name: str) -> str:
"""Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII."""
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# ── List ─────────────────────────────────────────────────────────────────
@router.get("", response_model=DraftTemplateListResponse)
async def list_drafts(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
pending_only: bool = True,
) -> DraftTemplateListResponse:
"""List drafts for the current user's account.
Defaults to pending-only — that's what the Script Library badge counts
and what the post-resolve TemplatizePrompt iterates over. Pass
`pending_only=false` to include accepted/rejected for an audit view.
"""
stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc())
if pending_only:
stmt = stmt.where(DraftTemplate.status == "pending")
result = await db.execute(stmt)
drafts = list(result.scalars().all())
return DraftTemplateListResponse(
drafts=[DraftTemplateResponse.model_validate(d) for d in drafts]
)
# ── Get one ──────────────────────────────────────────────────────────────
@router.get("/{draft_id}", response_model=DraftTemplateResponse)
async def get_draft(
draft_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateResponse:
draft = await _load_draft_or_404(db, draft_id)
return DraftTemplateResponse.model_validate(draft)
# ── Accept ───────────────────────────────────────────────────────────────
@router.post(
"/{draft_id}/accept",
response_model=DraftTemplateAcceptResponse,
status_code=201,
)
async def accept_draft(
draft_id: UUID,
body: DraftTemplateAcceptRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateAcceptResponse:
"""Promote a draft to a real `script_templates` row.
Provenance fields (`source_session_id`, `source_user_id`,
`source_ticket_ref`) are copied so the Script Library can render the
"generated from CW #X · resolved by Y · used N times" chip.
On success: draft.status='accepted', draft.promoted_template_id set,
draft.resolved_at set. The new template is owned by the engineer's team
(matches scripts.create_template's behavior).
Returns 409 if the draft is already accepted/rejected.
"""
draft = await _load_draft_or_404(db, draft_id)
if draft.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Draft is already {draft.status}",
)
# Validate the category exists and belongs to (or is global for) this account.
cat_result = await db.execute(
select(ScriptCategory).where(
ScriptCategory.id == body.category_id,
ScriptCategory.is_active == True, # noqa: E712
)
)
if cat_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="category_id does not reference an active script category",
)
# Look up source-session ticket ref for the provenance chip. RLS makes
# cross-account ai_session lookup impossible — the draft must belong to
# the same account as the requesting user.
source_session = (
await db.execute(
select(AISession).where(AISession.id == draft.source_session_id)
)
).scalar_one_or_none()
source_ticket_ref = (
f"CW #{source_session.psa_ticket_id}"
if source_session and source_session.psa_ticket_id
else None
)
slug = _slugify(body.name)
template = ScriptTemplate(
category_id=body.category_id,
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=body.name,
slug=slug,
description=body.description,
script_body=body.edited_body or draft.script_body,
parameters_schema=body.parameters_schema,
# FlowPilot provenance — drives the Script Library chip.
source_session_id=draft.source_session_id,
source_user_id=draft.source_user_id,
source_ticket_ref=source_ticket_ref,
)
db.add(template)
await db.flush() # populate template.id
draft.status = "accepted"
draft.promoted_template_id = template.id
draft.resolved_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(template)
return DraftTemplateAcceptResponse(
draft_id=draft.id,
promoted_template_id=template.id,
template_slug=template.slug,
)
# ── Reject ───────────────────────────────────────────────────────────────
@router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse)
async def reject_draft(
draft_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateRejectResponse:
"""Mark a draft rejected.
No template is created. The row stays for audit (so a team admin can see
the engineer reviewed and explicitly declined). Returns 409 on a draft
that's already accepted/rejected.
"""
draft = await _load_draft_or_404(db, draft_id)
if draft.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Draft is already {draft.status}",
)
draft.status = "rejected"
draft.resolved_at = datetime.now(timezone.utc)
await db.commit()
return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected")
# ── Helpers ─────────────────────────────────────────────────────────────
async def _load_draft_or_404(
db: AsyncSession, draft_id: UUID
) -> DraftTemplate:
"""RLS-scoped draft load. 404 covers missing + cross-tenant."""
result = await db.execute(
select(DraftTemplate).where(DraftTemplate.id == draft_id)
)
draft = result.scalar_one_or_none()
if draft is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Draft template not found",
)
return draft

View File

@@ -3,10 +3,8 @@
Endpoints:
GET /analytics/flowpilot?period=30d — Main dashboard data
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
GET /analytics/flowpilot/escalations?period=30d — Escalation handoff metrics
"""
import logging
import statistics
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
@@ -15,17 +13,10 @@ from sqlalchemy import select, func, case, cast, Date, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import (
get_current_active_user,
get_db,
require_engineer_or_admin,
require_team_admin,
)
from app.api.deps import get_current_active_user, get_db, require_team_admin
from app.models.user import User
from app.models.tree import Tree
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.models.session_handoff import SessionHandoff
from app.models.flow_proposal import FlowProposal
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_post_log import PsaPostLog
@@ -45,7 +36,6 @@ from app.schemas.flowpilot_analytics import (
EnhancedPsaMetrics,
PsaFunnel,
PsaDailyTrend,
EscalationMetrics,
)
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
@@ -737,104 +727,3 @@ async def get_enhanced_psa_metrics(
push_funnel=push_funnel,
daily_trend=daily_trend,
)
# ─── Escalation Mode metrics (wedge stat for /escalations queue + analytics page)
#
# Pulls all (handoff.claimed_at, first_step_after_claim.created_at) pairs in the
# window and aggregates avg/median/p95 of the delta in Python. Pilot scale
# (~1k rows max per account per month) makes this cheaper and clearer than
# Postgres percentile_cont gymnastics.
#
# IMPORTANT: this is the in-product metric only. The "minutes recovered"
# sales claim requires manual baseline measurement (see The Assignment in
# docs/plans/2026-04-27-escalation-mode-wedge-design.md).
@router.get("/escalations", response_model=EscalationMetrics)
@limiter.limit("30/minute")
async def get_escalation_metrics(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
) -> EscalationMetrics:
"""Time-to-first-action after escalation claim, account-scoped.
Returns:
n_handoffs_claimed: handoffs in window that were claimed by a senior.
n_handoffs_with_action: subset where the senior took at least one
action (an ai_session_step row created after claimed_at).
avg/median/p95_seconds_to_first_action: aggregates of
(first_step.created_at - claimed_at) in seconds.
Excludes handoffs where claimed_at IS NULL (never claimed) and handoffs
where no ai_session_step was created after the claim. Both are
counted — n_handoffs_claimed includes "no action yet" handoffs so the
conversion rate is visible.
"""
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
)
account_id = current_user.account_id
period_start = _get_period_start(period)
# First-action timestamp per handoff via correlated scalar subquery.
first_action_subq = (
select(func.min(AISessionStep.created_at))
.where(
AISessionStep.session_id == SessionHandoff.session_id,
AISessionStep.created_at > SessionHandoff.claimed_at,
)
.correlate(SessionHandoff)
.scalar_subquery()
)
rows = (
await db.execute(
select(
SessionHandoff.claimed_at,
first_action_subq.label("first_action_at"),
).where(
SessionHandoff.account_id == account_id,
SessionHandoff.claimed_at.isnot(None),
SessionHandoff.claimed_at >= period_start,
)
)
).all()
n_handoffs_claimed = len(rows)
deltas: list[float] = []
for claimed_at, first_action_at in rows:
if first_action_at is None:
continue
delta_s = (first_action_at - claimed_at).total_seconds()
# Floor at zero — clock drift between rows could in theory yield a
# tiny negative if a step's created_at races claimed_at. Surface as
# 0s rather than absurd negative deltas.
if delta_s < 0:
delta_s = 0.0
deltas.append(delta_s)
n_handoffs_with_action = len(deltas)
if n_handoffs_with_action == 0:
return EscalationMetrics(
period=period,
n_handoffs_claimed=n_handoffs_claimed,
n_handoffs_with_action=0,
)
sorted_deltas = sorted(deltas)
p95_idx = max(0, int(round(0.95 * (n_handoffs_with_action - 1))))
return EscalationMetrics(
period=period,
n_handoffs_claimed=n_handoffs_claimed,
n_handoffs_with_action=n_handoffs_with_action,
avg_seconds_to_first_action=round(statistics.fmean(deltas), 2),
median_seconds_to_first_action=round(statistics.median(deltas), 2),
p95_seconds_to_first_action=round(sorted_deltas[p95_idx], 2),
)

View File

@@ -194,7 +194,6 @@ async def create_folder(
new_folder = UserFolder(
user_id=current_user.id,
account_id=current_user.account_id,
name=folder_data.name,
color=folder_data.color,
icon=folder_data.icon,

View File

@@ -1,7 +1,6 @@
"""PSA integration endpoints — connection CRUD and test."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
@@ -12,8 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
logger = logging.getLogger(__name__)
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
from app.core.database import get_db
from app.models.psa_connection import PsaConnection
@@ -30,20 +27,8 @@ from app.schemas.psa_connection import (
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
PSABoardResponse,
)
from app.core.config import settings
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
TicketCreatePayloadSchema,
PSAPrioritySchema,
TicketListResponseSchema,
AiParseRequestSchema,
AiParseResponseSchema,
)
import app.services.ticket_service as ticket_svc
from app.services.psa.encryption import (
decrypt_credentials,
encrypt_credentials,
@@ -360,335 +345,43 @@ async def update_flowpilot_settings(
# ── ticket / status / company endpoints ──────────────────────────
@router.get("/boards", response_model=list[PSABoardResponse])
async def list_boards(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA service boards."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
except PSAError as e:
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
logger.warning("list_boards failed: %s", e)
return []
@router.get("/tickets/search", response_model=TicketListResponseSchema)
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
status_name: str | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
priority: str | None = None,
company_id: int | None = None,
page: int = 1,
page_size: int = 25,
):
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
"""Search ConnectWise tickets."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
member_identifier: str | None = None
if assigned_to_me:
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
conn = conn_result.scalar_one_or_none()
if conn:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if not mapping:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
try:
_provider = await get_provider_for_account(current_user.account_id, db)
cw_members = await _provider.list_members()
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
if matched:
member_identifier = matched.identifier
else:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
except PSAError:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
parsed_board_ids: list[int] = []
if board_ids:
try:
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
try:
provider = await get_provider_for_account(current_user.account_id, db)
result = await provider.search_tickets(
query,
board_id=board_id,
status_id=status_id,
status_name=status_name,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,
board_ids=parsed_board_ids,
company_id=company_id,
page=page,
page_size=page_size,
tickets = await provider.search_tickets(
query, board_id=board_id, status_id=status_id, include_closed=include_closed
)
items = [
return [
PSATicketSearchResult(
id=t.id,
summary=t.summary,
company_name=t.company_name,
company_id=t.company_id,
board_name=t.board_name,
board_id=t.board_id,
status_name=t.status_name,
status_id=t.status_id,
priority_name=t.priority_name,
priority_id=t.priority_id,
closed=t.closed,
)
for t in result.items
for t in tickets
]
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
async def create_ticket(
data: TicketCreatePayloadSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA ticket."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
from app.services.psa.types import TicketCreatePayload
try:
return await ticket_svc.create_ticket(
current_user.account_id,
TicketCreatePayload(**data.model_dump()),
db,
)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
async def ai_parse_ticket(
data: AiParseRequestSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Parse natural language into a ticket pre-fill payload using Claude."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
import anthropic
import json
# Fetch boards + members for context (both cached)
boards = []
members = []
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
members = await provider.list_members()
except PSAError:
pass
boards_list = [{"id": b.id, "name": b.name} for b in boards]
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
system_prompt = """You are a ticket triage assistant for an MSP help desk.
Extract structured ticket information from the engineer's natural language description.
Return ONLY valid JSON matching this exact schema — no other text:
{
"summary": "short one-line ticket title or null",
"board_id": "integer matching one of the provided boards or null",
"priority_name": "one of: Critical, High, Medium, Low, or null",
"description": "expanded description or null",
"assignee_identifier": "member identifier string from the provided members list or null",
"warnings": ["list of strings explaining what could not be resolved"]
}"""
user_msg = f"""Available boards: {json.dumps(boards_list)}
Available members: {json.dumps(members_list[:50])}
Engineer's description: {data.prompt}"""
missing_fields: list[str] = []
warnings: list[str] = []
response_data = AiParseResponseSchema()
try:
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
max_retries=1,
)
msg = await client.messages.create(
model=settings.get_model_for_action("default"),
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
)
raw = msg.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
import re
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw.strip())
parsed = json.loads(raw)
response_data.summary = parsed.get("summary")
response_data.description = parsed.get("description")
warnings = parsed.get("warnings", [])
# Resolve board_id
if parsed.get("board_id"):
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
if board_match:
response_data.board_id = board_match.id
else:
missing_fields.append("board_id")
warnings.append(f"Board ID {parsed['board_id']} not found")
else:
missing_fields.append("board_id")
# Resolve assignee
if parsed.get("assignee_identifier"):
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
if member:
response_data.assigned_member_id = int(member.id)
else:
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
# Priority/status/company always need manual selection
missing_fields.extend(["status_id", "priority_id", "company_id"])
except Exception as e:
logger.warning("AI parse failed: %s", e)
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
warnings = ["AI parsing failed — please fill in manually"]
response_data.missing_fields = missing_fields
response_data.warnings = warnings
return response_data
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
async def update_ticket_status_endpoint(
ticket_id: int,
status_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a ticket's status."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
async def list_ticket_resources(
ticket_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
except PSAError as e:
# Resources are optional display data — degrade gracefully rather than surfacing a toast
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
return []
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
async def add_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
async def remove_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/priorities", response_model=list[PSAPrioritySchema])
async def list_priorities(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA priority levels for ticket creation form."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
raw = await provider.list_priorities()
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
except PSAError as e:
logger.warning("list_priorities failed: %s", e)
return []
@router.get("/tickets/{ticket_id}/context")
async def get_ticket_context(
ticket_id: int,
@@ -790,30 +483,7 @@ async def get_ticket_statuses(
except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e:
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
return []
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
async def get_board_statuses(
board_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get available statuses for a service board directly (no ticket lookup required)."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
statuses = await provider.get_ticket_statuses(board_id)
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
except PSAError as e:
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
return []
raise HTTPException(status_code=502, detail=str(e))
# ── member mapping endpoints ─────────────────────────────────────────
@@ -821,7 +491,7 @@ async def get_board_statuses(
@router.get("/members", response_model=list[PsaMemberResponse])
async def list_members(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List CW members (from CW API)."""
@@ -839,9 +509,7 @@ async def list_members(
for m in members
]
except PSAError as e:
# Members are optional display data — degrade gracefully
logger.warning("list_members failed: %s", e)
return []
raise HTTPException(status_code=502, detail=str(e))
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -849,37 +517,31 @@ async def get_member_mappings(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get all account users with their PSA member mappings (unmapped users included)."""
"""Get all member mappings for the account."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
return []
# Fetch all active account users
users_result = await db.execute(
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
)
users = users_result.scalars().all()
# Fetch all existing mappings keyed by user_id for O(1) lookup
mappings_result = await db.execute(
result = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
mapping_by_user: dict[str, PsaMemberMapping] = {
str(m.user_id): m for m in mappings_result.scalars().all()
}
mappings = result.scalars().all()
return [
PsaMemberMappingResponse(
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
user_id=str(user.id),
user_email=user.email,
user_name=user.name,
external_member_id=m.external_member_id if m else None,
external_member_name=m.external_member_name if m else None,
matched_by=m.matched_by if m else None,
)
for user in users
]
response = []
for m in mappings:
user_result = await db.execute(select(User).where(User.id == m.user_id))
user = user_result.scalar_one_or_none()
if user:
response.append(PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=user.email,
user_name=user.name,
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by=m.matched_by,
))
return response
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -902,7 +564,6 @@ async def save_member_mappings(
for m in mappings:
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=UUID(m.user_id),
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
@@ -963,7 +624,6 @@ async def auto_match_members(
if not existing.scalar_one_or_none():
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=user.id,
external_member_id=cw_member.id,
external_member_name=cw_member.name,

View File

@@ -1,277 +0,0 @@
"""L1 Workspace endpoints (Phase 1).
PSA-merge queue support + AI build path are deferred to Phase 2.
"""
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status as http_status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_l1_or_coverage
from app.models.l1_walk_session import L1WalkSession
from app.models.user import User
from app.schemas.l1 import (
EscalateRequest,
EscalateWithoutWalkRequest,
IntakeRequest,
IntakeResponse,
NotesRequest,
QueueRow,
ResolveRequest,
StepRequest,
WalkSessionResponse,
)
from app.services import internal_ticket_service, l1_session_service
router = APIRouter(prefix="/l1", tags=["l1"])
def _to_response(session: L1WalkSession) -> WalkSessionResponse:
return WalkSessionResponse(
id=session.id,
session_kind=session.session_kind,
flow_id=session.flow_id,
flow_proposal_id=session.flow_proposal_id,
current_node_id=session.current_node_id,
walked_path=session.walked_path or [],
walk_notes=session.walk_notes or [],
status=session.status,
started_at=session.started_at,
last_step_at=session.last_step_at,
resolved_at=session.resolved_at,
)
async def _get_session_or_404(
db: AsyncSession, session_id: UUID, user: User
) -> L1WalkSession:
"""Fetch a session by id, scoped to the caller's account.
Phase 1 policy (per spec §7.9): sessions are account-scoped, not
user-scoped. Any L1 or coverage engineer in the same account can
step/note/resolve/escalate any session — supports team coverage
(e.g., L1 hands off mid-shift; coverage engineer takes over a call).
For a stricter "creator-only" policy, add
``created_by_user_id == user.id`` here.
"""
session = await db.get(L1WalkSession, session_id)
if session is None or session.account_id != user.account_id:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
return session
@router.post("/intake", response_model=IntakeResponse)
async def intake(
payload: IntakeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""L1 intake: creates an internal ticket and starts a walk session.
Phase 1: internal-ticket only (PSA support follows in Phase 2 escalation polish).
If `flow_id` is provided, starts a flow session; otherwise an adhoc session.
"""
ticket = await internal_ticket_service.create_ticket(
db,
account_id=user.account_id,
created_by_user_id=user.id,
problem_statement=payload.problem_statement,
customer_name=payload.customer_name,
customer_contact=payload.customer_contact,
)
if payload.flow_id is not None:
session = await l1_session_service.start_flow_session(
db,
account_id=user.account_id,
user=user,
flow_id=payload.flow_id,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
else:
session = await l1_session_service.start_adhoc_session(
db,
account_id=user.account_id,
user=user,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
await db.commit()
return IntakeResponse(
session_id=session.id,
session_kind=session.session_kind,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
@router.get("/queue", response_model=list[QueueRow])
async def queue(
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
status_filter: Optional[str] = None,
limit: int = 50,
):
"""Phase 1 queue: internal tickets only. PSA-fed rows in Phase 2."""
tickets = await internal_ticket_service.list_tickets_for_account(
db,
account_id=user.account_id,
status=status_filter,
limit=limit,
)
return [
QueueRow(
ticket_id=str(t.id),
ticket_kind="internal",
problem_statement=t.problem_statement,
customer_name=t.customer_name,
status=t.status,
created_at=t.created_at,
)
for t in tickets
]
@router.get("/sessions/active", response_model=list[WalkSessionResponse])
async def list_active_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""The caller's currently-active sessions (for the dashboard 'Resume in progress' widget)."""
stmt = (
select(L1WalkSession)
.where(L1WalkSession.created_by_user_id == user.id)
.where(L1WalkSession.status == "active")
.order_by(L1WalkSession.last_step_at.desc())
.limit(20)
)
result = await db.execute(stmt)
return [_to_response(s) for s in result.scalars()]
@router.get("/sessions/{session_id}", response_model=WalkSessionResponse)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
session = await _get_session_or_404(db, session_id, user)
return _to_response(session)
@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse)
async def post_step(
session_id: UUID,
payload: StepRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.record_step(
db,
session_id=session_id,
node_id=payload.node_id,
question=payload.question,
answer=payload.answer,
note=payload.note,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse)
async def post_notes(
session_id: UUID,
payload: NotesRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.update_notes(
db,
session_id=session_id,
notes=payload.notes,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse)
async def post_resolve(
session_id: UUID,
payload: ResolveRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.resolve(
db,
session_id=session_id,
helpful=payload.helpful,
resolution_notes=payload.resolution_notes,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse)
async def post_escalate(
session_id: UUID,
payload: EscalateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.escalate(
db,
session_id=session_id,
reason=payload.reason or "",
reason_category=payload.reason_category,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/escalate-without-walk", response_model=WalkSessionResponse)
async def post_escalate_without_walk(
payload: EscalateWithoutWalkRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
ticket = await internal_ticket_service.create_ticket(
db,
account_id=user.account_id,
created_by_user_id=user.id,
problem_statement=payload.problem_statement,
customer_name=payload.customer_name,
customer_contact=payload.customer_contact,
)
session = await l1_session_service.escalate_without_walk(
db,
account_id=user.account_id,
user=user,
ticket_id=str(ticket.id),
ticket_kind="internal",
reason_category=payload.reason_category,
reason=payload.reason,
)
await db.commit()
return _to_response(session)

View File

@@ -29,8 +29,8 @@ def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
return cron.get_next(datetime).astimezone(timezone.utc)
async def _get_tree_or_404(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it. Raises 404 if not found or access denied."""
async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
@@ -38,7 +38,7 @@ async def _get_tree_or_404(tree_id: UUID, current_user: User, db: AsyncSession)
# Super admins can access any tree; regular users must be on the same team
if not getattr(current_user, 'is_super_admin', False):
if tree.team_id != current_user.team_id:
raise HTTPException(status_code=404, detail="Tree not found")
raise HTTPException(status_code=403, detail="Access denied")
return tree
@@ -51,7 +51,7 @@ async def create_schedule(
):
"""Create a cron schedule for a maintenance flow. One per flow."""
# Verify user's team owns the tree
tree = await _get_tree_or_404(data.tree_id, current_user, db)
tree = await _get_tree_or_403(data.tree_id, current_user, db)
if tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Schedules are only supported for maintenance flows")
@@ -69,7 +69,6 @@ async def create_schedule(
schedule = MaintenanceSchedule(
tree_id=data.tree_id,
account_id=current_user.account_id,
created_by=current_user.id,
cron_expression=data.cron_expression,
timezone=data.timezone,
@@ -95,7 +94,7 @@ async def get_schedule_for_tree(
):
"""Get the schedule for a specific maintenance flow."""
# Verify user's team owns the tree before returning schedule data
await _get_tree_or_404(tree_id, current_user, db)
await _get_tree_or_403(tree_id, current_user, db)
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
@@ -123,7 +122,7 @@ async def update_schedule(
raise HTTPException(status_code=404, detail="Schedule not found")
# Verify user's team owns the tree this schedule belongs to
await _get_tree_or_404(schedule.tree_id, current_user, db)
await _get_tree_or_403(schedule.tree_id, current_user, db)
update_fields = data.model_fields_set
was_active = schedule.is_active

View File

@@ -1,12 +1,10 @@
"""Network diagrams API endpoints."""
import base64
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +13,6 @@ from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.schemas.network_diagram import (
NetworkDiagramCreate,
NetworkDiagramUpdate,
@@ -29,7 +26,7 @@ from app.schemas.network_diagram import (
DiagramNode,
DiagramEdge,
)
from app.services import network_diagram_ai_service, storage_service
from app.services import network_diagram_ai_service
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
_SLUG_CATEGORY: dict[str, str] = {
@@ -52,11 +49,11 @@ router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
async def _get_diagram_or_404(
diagram_id: UUID,
account_id: UUID,
team_id: UUID,
db: AsyncSession,
) -> NetworkDiagram:
diagram = await db.get(NetworkDiagram, diagram_id)
if not diagram or diagram.account_id != account_id or diagram.is_archived:
if not diagram or diagram.team_id != team_id or diagram.is_archived:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
@@ -85,19 +82,15 @@ def _diagram_to_list_item(
description=diagram.description,
node_count=len(nodes),
category_counts=category_counts,
thumbnail_url=diagram.thumbnail_url,
created_by=diagram.created_by,
created_at=diagram.created_at,
updated_at=diagram.updated_at,
)
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
async def _get_available_slugs(team_id: UUID, db: AsyncSession) -> set[str]:
stmt = select(DeviceType.slug).where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == account_id,
)
or_(DeviceType.is_system.is_(True), DeviceType.team_id == team_id)
)
result = await db.execute(stmt)
return {row[0] for row in result.all()}
@@ -111,7 +104,7 @@ async def list_client_names(
stmt = (
select(NetworkDiagram.client_name)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.team_id == current_user.team_id,
NetworkDiagram.is_archived.is_(False),
NetworkDiagram.client_name.isnot(None),
NetworkDiagram.client_name != "",
@@ -133,7 +126,7 @@ async def list_diagrams(
stmt = (
select(NetworkDiagram)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.team_id == current_user.team_id,
NetworkDiagram.is_archived.is_(False),
)
.order_by(NetworkDiagram.updated_at.desc())
@@ -155,7 +148,7 @@ async def list_diagrams(
# Single query for custom device types so category_counts is accurate
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
DeviceType.is_system.is_(False),
DeviceType.account_id == current_user.account_id,
DeviceType.team_id == current_user.team_id,
)
dt_result = await db.execute(dt_stmt)
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
@@ -171,8 +164,13 @@ async def create_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
if current_user.team_id is None:
raise HTTPException(
status_code=422,
detail="Network Diagrams require a team account. Assign your account to a team first.",
)
diagram = NetworkDiagram(
account_id=current_user.account_id,
team_id=current_user.team_id,
name=data.name,
client_name=data.client_name,
asset_name=data.asset_name,
@@ -193,7 +191,7 @@ async def get_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
return _diagram_to_response(diagram)
@@ -204,7 +202,7 @@ async def update_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
update_data = data.model_dump(exclude_unset=True)
if "nodes" in update_data and update_data["nodes"] is not None:
@@ -227,7 +225,7 @@ async def archive_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
diagram.is_archived = True
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
@@ -239,9 +237,9 @@ async def duplicate_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
source = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
copy = NetworkDiagram(
account_id=current_user.account_id,
team_id=current_user.team_id,
name=f"Copy of {source.name}",
client_name=source.client_name,
asset_name=source.asset_name,
@@ -262,7 +260,7 @@ async def export_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
return DiagramExportResponse(
@@ -282,7 +280,7 @@ async def import_diagram(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
available_slugs = await _get_available_slugs(current_user.account_id, db)
available_slugs = await _get_available_slugs(current_user.team_id, db)
warnings: list[str] = []
for node in data.nodes:
@@ -290,7 +288,7 @@ async def import_diagram(
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
diagram = NetworkDiagram(
account_id=current_user.account_id,
team_id=current_user.team_id,
name=data.name,
client_name=data.client_name,
description=data.description,
@@ -308,41 +306,13 @@ async def import_diagram(
)
class ThumbnailUploadRequest(BaseModel):
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
@router.post("/{diagram_id}/thumbnail", status_code=204)
async def upload_thumbnail(
diagram_id: UUID,
body: ThumbnailUploadRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
try:
header, encoded = body.data_url.split(",", 1)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid data URL format")
image_bytes = base64.b64decode(encoded)
storage_key = await storage_service.upload_file(
file_data=image_bytes,
filename=f"thumbnail-{diagram_id}.png",
content_type="image/png",
account_id=str(current_user.account_id),
)
presigned_url = storage_service.get_presigned_url(storage_key)
diagram.thumbnail_url = presigned_url
await db.commit()
@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
data: AIGenerateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
available_slugs_set = await _get_available_slugs(current_user.team_id, db)
available_slugs = list(available_slugs_set)
existing_node_ids: list[str] | None = None

View File

@@ -1,247 +0,0 @@
import secrets
import string
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.endpoints.auth import _mint_session_tokens
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.oauth_identity import OAuthIdentity
from app.models.user import User
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
from app.services.billing import BillingService
from app.services.oauth_providers import (
google_exchange_code,
microsoft_exchange_code,
OAuthProfile,
)
router = APIRouter(prefix="/auth", tags=["auth-oauth"])
def _generate_display_code(length: int = 8) -> str:
"""Match the helper used by /auth/register — A-Z + 0-9, length 8."""
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
async def _sign_in_or_register(
db: AsyncSession,
provider: str,
profile: OAuthProfile,
*,
account_invite_code: str | None = None,
invited_email: str | None = None,
) -> tuple[User, bool]:
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
When ``account_invite_code`` is supplied (from the /accept-invite flow),
a brand-new user is created inside the invited account instead of getting
a personal account + Pro trial. Mismatch between the OAuth profile email
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
contract that mirrors the email+password register path.
"""
identity = (
await db.execute(
select(OAuthIdentity).where(
OAuthIdentity.provider == provider,
OAuthIdentity.provider_subject == profile.provider_subject,
)
)
).scalar_one_or_none()
if identity:
user = (
await db.execute(select(User).where(User.id == identity.user_id))
).scalar_one()
return user, False
user = (
await db.execute(select(User).where(User.email == profile.email))
).scalar_one_or_none()
is_new_user = user is None
# If the user arrived via an invite link but already has a ResolutionFlow
# account (e.g., previously signed up with email+password), silently
# linking the OAuth identity to that existing account would bypass the
# invite — they'd stay in their personal account and the invite would
# never be consumed. Fail loud instead so they can sign in and accept the
# invite from the dashboard. The "invited user wants to transfer accounts"
# case is a v2 concern.
if account_invite_code and not is_new_user:
raise HTTPException(
status_code=400,
detail={
"error": "email_already_registered_use_login",
"message": (
"An account already exists for this email. Please sign in "
"instead, then accept the invite from your dashboard."
),
},
)
invite_record: AccountInvite | None = None
if is_new_user and account_invite_code:
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
# consume the same invite code.
invite_record = (
await db.execute(
select(AccountInvite)
.where(AccountInvite.code == account_invite_code)
.with_for_update()
)
).scalar_one_or_none()
if invite_record is None or not invite_record.is_valid:
raise HTTPException(
status_code=400,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
# Verify the OAuth profile email matches what was invited. We compare
# against the invite row directly (source of truth), but also accept
# the client-supplied invited_email as a defensive equality check.
if invite_record.email.lower() != profile.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if invited_email and invited_email.lower() != invite_record.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if is_new_user:
if invite_record is not None:
# Seat enforcement: re-check at OAuth accept time (race-condition guard).
if invite_record.role in ("engineer", "l1_tech"):
from app.core.subscriptions import get_account_subscription
from app.services.seat_enforcement import check_seat_available
sub = await get_account_subscription(invite_record.account_id, db)
if sub is not None:
acct_result = await db.execute(
select(Account).where(Account.id == invite_record.account_id)
)
acct = acct_result.scalar_one()
seat_result = await check_seat_available(acct, sub, invite_record.role, db)
if not seat_result.available:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "seat_limit_exceeded",
"role": seat_result.role,
"current": seat_result.current,
"limit": seat_result.limit,
"upgrade_url": "/account/billing",
},
)
# Join the invited account directly — no personal account, no
# trial creation.
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=invite_record.account_id,
account_role=invite_record.role,
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
invite_record.accepted_by_id = user.id
invite_record.used_at = datetime.now(timezone.utc)
await db.flush()
else:
account = Account(
name=f"{profile.name}'s Account",
display_code=_generate_display_code(),
)
db.add(account)
await db.flush()
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=account.id,
account_role="owner",
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
account.owner_id = user.id
await db.flush()
# start_trial commits internally; flushed account/user above.
await BillingService.start_trial(db, account.id)
db.add(
OAuthIdentity(
user_id=user.id,
provider=provider,
provider_subject=profile.provider_subject,
provider_email_at_link=profile.email,
)
)
await db.commit()
await db.refresh(user)
return user, is_new_user
@router.post("/google/callback", response_model=OAuthCallbackResponse)
async def google_callback(
payload: OAuthCallbackPayload,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> OAuthCallbackResponse:
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=503, detail="Google sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
profile = await google_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(
db,
"google",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
token = await _mint_session_tokens(user, db)
await db.commit()
return OAuthCallbackResponse(
access_token=token.access_token,
refresh_token=token.refresh_token,
is_new_user=is_new,
idle_expires_at=token.idle_expires_at,
absolute_expires_at=token.absolute_expires_at,
)
@router.post("/microsoft/callback", response_model=OAuthCallbackResponse)
async def microsoft_callback(
payload: OAuthCallbackPayload,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> OAuthCallbackResponse:
if not settings.MS_CLIENT_ID:
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
profile = await microsoft_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(
db,
"microsoft",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
token = await _mint_session_tokens(user, db)
await db.commit()
return OAuthCallbackResponse(
access_token=token.access_token,
refresh_token=token.refresh_token,
is_new_user=is_new,
idle_expires_at=token.idle_expires_at,
absolute_expires_at=token.absolute_expires_at,
)

View File

@@ -2,24 +2,18 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.account import Account
from app.models.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection
from app.models.session import Session
from app.models.tree import Tree
from app.models.user import User
from app.schemas.onboarding import (
OnboardingStatus,
OnboardingStepRequest,
OnboardingStepResponse,
)
from app.schemas.onboarding import OnboardingStatus
router = APIRouter(prefix="/users", tags=["onboarding"])
@@ -90,10 +84,6 @@ async def get_onboarding_status(
)
connected_psa = (psa_q.scalar() or 0) > 0
# New (Phase 2 — Task 41)
email_verified = current_user.email_verified_at is not None
shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1
return OnboardingStatus(
created_flow=created_flow,
ran_session=ran_session,
@@ -103,14 +93,12 @@ async def get_onboarding_status(
connected_psa=connected_psa,
is_team_user=is_team_user,
dismissed=current_user.onboarding_dismissed,
email_verified=email_verified,
shop_setup_done=shop_setup_done,
)
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
async def dismiss_onboarding(
db: Annotated[AsyncSession, Depends(get_admin_db)],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStatus:
"""Dismiss the onboarding checklist for the current user."""
@@ -120,98 +108,3 @@ async def dismiss_onboarding(
# Return updated status (reuse the GET logic)
return await get_onboarding_status(db=db, current_user=current_user)
# ---------------------------------------------------------------------------
# Welcome wizard endpoints (Phase 2)
#
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
# can run before email verification and during trial.
# ---------------------------------------------------------------------------
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
async def patch_onboarding_step(
body: OnboardingStepRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStepResponse:
"""Persist welcome-wizard progress for the current user.
Contract:
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
users.role_at_signup, then sets users.onboarding_step_completed=1.
- step=2 + complete writes accounts.primary_psa, then sets
users.onboarding_step_completed=2.
- step=3 + complete just sets users.onboarding_step_completed=3
(invites are POSTed separately).
- action="skip" ignores `data` entirely and only advances the step.
- The new step must be >= current onboarding_step_completed (None=>0);
otherwise 400. Idempotent re-PATCH of the same step succeeds.
"""
current_step = current_user.onboarding_step_completed or 0
if body.step < current_step:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "step_cannot_decrease",
"current_step": current_step,
"requested_step": body.step,
},
)
if body.action == "complete" and body.data is not None and body.step in (1, 2):
# Load the user's account for field writes. Step 3 has no data writes.
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one_or_none()
if account is None:
# Should never happen — user is required to have an account_id.
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="account_not_found",
)
if body.step == 1:
data = body.data
if data.company_name is not None:
account.name = data.company_name
if data.team_size_bucket is not None:
account.team_size_bucket = data.team_size_bucket
if data.role_at_signup is not None:
current_user.role_at_signup = data.role_at_signup
elif body.step == 2:
data = body.data
if data.primary_psa is not None:
account.primary_psa = data.primary_psa
current_user.onboarding_step_completed = body.step
await db.commit()
await db.refresh(current_user)
return OnboardingStepResponse(
onboarding_step_completed=current_user.onboarding_step_completed,
onboarding_dismissed=current_user.onboarding_dismissed,
)
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
async def dismiss_onboarding_rest(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStepResponse:
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
Returns the same shape as the step PATCH so the frontend can update its
local store from a single response.
"""
current_user.onboarding_dismissed = True
await db.commit()
await db.refresh(current_user)
return OnboardingStepResponse(
onboarding_step_completed=current_user.onboarding_step_completed,
onboarding_dismissed=current_user.onboarding_dismissed,
)

View File

@@ -1,58 +0,0 @@
"""Public plans endpoint — no auth required.
GET /api/v1/plans/public
Returns the public-safe view of `plan_billing` joined with
`plan_limits.max_users` (exposed as `max_seats`), filtered to
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
archived/internal). This endpoint exists to power the marketing /pricing page
without exposing the rest of the admin-only billing surface.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import get_admin_db
from app.models.plan_billing import PlanBilling
from app.models.plan_limits import PlanLimits
from app.schemas.billing import PublicPlanResponse
router = APIRouter(prefix="/plans", tags=["plans"])
@router.get("/public", response_model=list[PublicPlanResponse])
async def list_public_plans(
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> list[PublicPlanResponse]:
"""List public, non-archived plans for the marketing /pricing page.
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
of the global plan catalog (same pattern as `/config/public`).
"""
stmt = (
select(PlanBilling, PlanLimits.max_users)
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
.where(PlanBilling.is_public.is_(True))
.where(PlanBilling.is_archived.is_(False))
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
)
rows = (await db.execute(stmt)).all()
return [
PublicPlanResponse(
plan=billing.plan,
display_name=billing.display_name,
description=billing.description,
monthly_price_cents=billing.monthly_price_cents,
annual_price_cents=billing.annual_price_cents,
max_seats=max_users,
sort_order=billing.sort_order,
is_public=billing.is_public,
)
for billing, max_users in rows
]

Some files were not shown because too many files have changed in this diff Show More