Compare commits
61 Commits
feat/cockp
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6e22ceb3 | ||
|
|
8292e6ec65 | ||
|
|
20bd428d83 | ||
|
|
b9da0e7107 | ||
|
|
8f044849d4 | ||
|
|
14304be383 | ||
|
|
a5c5eb6cc3 | ||
|
|
c4f919f3a5 | ||
|
|
8de6ee7aa4 | ||
|
|
83ad2e0661 | ||
|
|
ce4056c6b9 | ||
|
|
9d60b9a244 | ||
|
|
df9ecf2d29 | ||
|
|
b0e5f12897 | ||
|
|
b4f8694f6b | ||
|
|
6f1becf21f | ||
|
|
acbfb3fb37 | ||
|
|
a394a1d464 | ||
|
|
d2ebc4f182 | ||
|
|
8bcf08ae06 | ||
|
|
85575839f2 | ||
|
|
478205c208 | ||
|
|
0f33feb6d6 | ||
|
|
034b858fc9 | ||
|
|
b937cb41e4 | ||
|
|
0d475c71ed | ||
|
|
417fa562ce | ||
|
|
42937b24a4 | ||
|
|
b4b8c67d3b | ||
|
|
d24da77604 | ||
|
|
857e782d14 | ||
|
|
086c4580f1 | ||
|
|
0d69474128 | ||
|
|
b5fdb488b3 | ||
|
|
de5ecf4fb2 | ||
|
|
2779a41b94 | ||
|
|
4666c4f6d2 | ||
|
|
2837c6e4cf | ||
|
|
b3dba57bc5 | ||
|
|
29a9573d6e | ||
|
|
56775eca04 | ||
|
|
82bb7967d8 | ||
|
|
a7dff9e143 | ||
|
|
ba0680ce06 | ||
|
|
290f2be2fd | ||
|
|
e8e12cc7e5 | ||
|
|
bf45322c46 | ||
|
|
f45b045943 | ||
|
|
cef853d7ea | ||
|
|
87cf874199 | ||
|
|
2b53315cc9 | ||
|
|
1811889ed9 | ||
|
|
990f04489f | ||
|
|
ba815d3ee5 | ||
|
|
8bd395a0c7 | ||
|
|
7198c165b2 | ||
|
|
58fe3574bf | ||
|
|
63a84be921 | ||
|
|
75971d8b97 | ||
|
|
7998dd237d | ||
|
|
f4143e52a1 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -47,6 +47,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -233,3 +233,8 @@ package.json
|
||||
package-lock.json
|
||||
.worktrees/
|
||||
.gstack/
|
||||
.gitnexus
|
||||
|
||||
# graphify knowledge graph outputs
|
||||
graphify-out/
|
||||
.graphify_python
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -8,19 +8,42 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- 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 1** — PostgreSQL Row-Level Security (RLS) enforcement across all core tables (trees, tags, categories, psa_connections, flow_proposals) with database role separation (`resolutionflow_app` for user operations, `resolutionflow_admin` with BYPASSRLS for admin endpoints), admin database engine isolation, tenant context via `ContextVar` with automatic transaction-scoped enforcement, `account_id` column backfill on 35+ tables (sessions, AI branching, PSA, notifications, scripts, targets, folders), global content separation via platform account, fresh-DB migration order fixes
|
||||
- **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
|
||||
- **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
|
||||
- **Admin database routing** — privileged operations (analytics, user management) now bypass RLS via dedicated admin engine
|
||||
|
||||
### Fixed
|
||||
- **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.
|
||||
- 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
|
||||
- Non-default, no-team trees now properly handled in global content migration
|
||||
|
||||
---
|
||||
|
||||
|
||||
156
CLAUDE.md
156
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
> **Last Updated:** March 27, 2026
|
||||
> **Last Updated:** April 6, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
| Context | Name Used |
|
||||
|---------|-----------|
|
||||
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
|
||||
| Repository / directory / database | `patherly` (internal name) |
|
||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
||||
|
||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
||||
@@ -44,7 +45,7 @@
|
||||
- **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
|
||||
- **Database:** PostgreSQL with Docker, 101 migrations
|
||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
||||
|
||||
### What's In Progress
|
||||
@@ -96,7 +97,7 @@ patherly/
|
||||
│ │ ├── 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+)
|
||||
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
|
||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
||||
│ └── tests/ # pytest integration tests
|
||||
├── frontend/
|
||||
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
|
||||
## Development Commands
|
||||
|
||||
```powershell
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker start resolutionflow_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate # Linux/Mac
|
||||
@@ -203,21 +204,19 @@ npm run dev
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# First time only: create test database
|
||||
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_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.
|
||||
alembic revision --autogenerate -m "Description"
|
||||
# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
|
||||
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
|
||||
|
||||
# Access PostgreSQL
|
||||
docker exec -it patherly_postgres psql -U postgres -d patherly
|
||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
|
||||
# Seed data
|
||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
@@ -292,7 +291,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**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"`.
|
||||
**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/.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.
|
||||
|
||||
@@ -332,7 +331,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**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.
|
||||
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -344,6 +343,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**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.
|
||||
|
||||
*(Lessons 89–91 were retracted.)*
|
||||
|
||||
**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.
|
||||
|
||||
@@ -353,7 +353,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**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).
|
||||
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). 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. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -390,16 +390,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**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`)
|
||||
- **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 fully specified (v6).
|
||||
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
|
||||
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
|
||||
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
||||
- **Inputs:** `bg-input` (`#252830`) 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, not a text color.
|
||||
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
|
||||
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
|
||||
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
|
||||
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
|
||||
|
||||
---
|
||||
|
||||
@@ -518,3 +518,105 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
||||
| 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 |
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
|
||||
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
|
||||
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
102
backend/alembic/versions/0b470d9e6cf1_create_db_roles.py
Normal file
102
backend/alembic/versions/0b470d9e6cf1_create_db_roles.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
108
backend/alembic/versions/c5f48b9890f9_enable_rls_phase1.py
Normal file
108
backend/alembic/versions/c5f48b9890f9_enable_rls_phase1.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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')
|
||||
@@ -10,6 +10,8 @@ from app.core.database import get_db
|
||||
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 = {
|
||||
@@ -190,3 +192,44 @@ 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
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
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 get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
|
||||
@@ -37,7 +37,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
@@ -74,7 +74,7 @@ def _generate_display_code() -> str:
|
||||
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: AdminUserCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new user with a temporary password (super admin only).
|
||||
@@ -199,7 +199,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Get enriched user details (super admin only)."""
|
||||
@@ -317,7 +317,7 @@ async def get_user(
|
||||
async def update_user_role(
|
||||
user_id: UUID,
|
||||
role_data: RoleUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Change user role (super admin only)."""
|
||||
@@ -349,7 +349,7 @@ async def update_user_role(
|
||||
async def update_account_role(
|
||||
user_id: UUID,
|
||||
data: AccountRoleUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Change a user's account role (super admin only)."""
|
||||
@@ -375,7 +375,7 @@ async def update_account_role(
|
||||
async def update_super_admin_status(
|
||||
user_id: UUID,
|
||||
data: dict,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Promote or demote a user to/from super admin (super admin only)."""
|
||||
@@ -414,7 +414,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Deactivate a user account (super admin only)."""
|
||||
@@ -443,7 +443,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Reactivate a user account (super admin only)."""
|
||||
@@ -467,7 +467,7 @@ async def activate_user(
|
||||
async def move_user_account(
|
||||
user_id: UUID,
|
||||
data: MoveUserAccount,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Move a user to a different account (super admin only)."""
|
||||
@@ -520,7 +520,7 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
||||
async def update_user_plan(
|
||||
user_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change a user's subscription plan (super admin only)."""
|
||||
@@ -539,7 +539,7 @@ async def update_user_plan(
|
||||
async def extend_user_trial(
|
||||
user_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for a user's subscription (super admin only)."""
|
||||
@@ -569,7 +569,7 @@ async def extend_user_trial(
|
||||
async def admin_reset_password(
|
||||
user_id: UUID,
|
||||
data: AdminPasswordReset,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Admin-triggered password reset (super admin only).
|
||||
@@ -640,7 +640,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Archive (soft delete) a user (super admin only)."""
|
||||
@@ -675,7 +675,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Restore an archived user (super admin only)."""
|
||||
@@ -700,7 +700,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
|
||||
@@ -773,7 +773,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Permanently delete a user (super admin only). User must be archived first."""
|
||||
@@ -833,7 +833,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Quick-invite a user to an account (super admin only).
|
||||
|
||||
@@ -4,25 +4,26 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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.is_(None)).order_by(TreeCategory.name)
|
||||
select(TreeCategory).where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID).order_by(TreeCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
@@ -45,36 +46,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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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.is_(None))
|
||||
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
)
|
||||
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=None)
|
||||
category = TreeCategory(name=data.name, slug=data.slug, account_id=PLATFORM_ACCOUNT_ID)
|
||||
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=None, tree_count=0)
|
||||
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=PLATFORM_ACCOUNT_ID, 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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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.is_(None))
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
@@ -86,7 +87,7 @@ async def update_global_category(
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(
|
||||
select(TreeCategory).where(
|
||||
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
|
||||
TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID, TreeCategory.id != category_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
@@ -103,19 +104,19 @@ async def update_global_category(
|
||||
|
||||
return GlobalCategoryResponse(
|
||||
id=category.id, name=category.name, slug=category.slug,
|
||||
account_id=None, tree_count=tree_count,
|
||||
account_id=PLATFORM_ACCOUNT_ID, 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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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.is_(None))
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Get platform overview metrics."""
|
||||
@@ -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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Get recent audit log entries for activity feed."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_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_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update gallery_sort_order on a script (super admin only)."""
|
||||
|
||||
@@ -519,11 +519,15 @@ async def save_task_lane(
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Save the current task lane state including user's in-progress responses."""
|
||||
session = await db.get(AISession, session_id)
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.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 your session")
|
||||
|
||||
payload = {
|
||||
"questions": [q.model_dump() for q in body.questions],
|
||||
@@ -762,13 +766,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(
|
||||
or_(
|
||||
AISession.user_id == current_user.id,
|
||||
AISession.account_id == current_user.account_id,
|
||||
),
|
||||
AISession.user_id == current_user.id,
|
||||
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
|
||||
)
|
||||
.params(q=q)
|
||||
@@ -901,7 +905,7 @@ async def get_session(
|
||||
pkg = session.escalation_package or {}
|
||||
is_handler = pkg.get("picked_up_by") == str(current_user.id)
|
||||
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")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
return _build_session_detail(session)
|
||||
|
||||
@@ -917,6 +921,18 @@ 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,
|
||||
@@ -942,13 +958,14 @@ async def stream_documentation(
|
||||
|
||||
# Verify session ownership
|
||||
result = await db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.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:
|
||||
@@ -1043,6 +1060,19 @@ 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -290,8 +291,13 @@ async def get_flow_analytics(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Analytics for a specific flow."""
|
||||
# Verify tree exists
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
# 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),
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
@@ -12,6 +12,8 @@ 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"])
|
||||
|
||||
@@ -47,13 +49,13 @@ async def list_categories(
|
||||
elif current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeCategory.account_id.is_(None), # Global
|
||||
TreeCategory.account_id == PLATFORM_ACCOUNT_ID, # 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.is_(None))
|
||||
query = query.where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
|
||||
query = query.order_by(TreeCategory.display_order, TreeCategory.name)
|
||||
|
||||
@@ -108,10 +110,12 @@ async def get_category(
|
||||
detail="You don't have access to this category"
|
||||
)
|
||||
|
||||
# Get tree count
|
||||
# Get tree count — scoped to the requesting account so cross-account
|
||||
# trees in shared categories are not counted.
|
||||
count_query = select(func.count(Tree.id)).where(
|
||||
Tree.category_id == category.id,
|
||||
Tree.is_active == True
|
||||
Tree.is_active == True,
|
||||
tenant_filter(Tree, current_user.account_id),
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
tree_count = count_result.scalar() or 0
|
||||
@@ -173,7 +177,7 @@ async def create_category(
|
||||
name=category_data.name,
|
||||
slug=slug,
|
||||
description=category_data.description,
|
||||
account_id=category_data.account_id,
|
||||
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
display_order=max_order + 1,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
@@ -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_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
|
||||
"""Fetch tree and verify the current user's team owns it."""
|
||||
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."""
|
||||
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_403(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=403, detail="Access denied")
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
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_403(data.tree_id, current_user, db)
|
||||
tree = await _get_tree_or_404(data.tree_id, current_user, db)
|
||||
if tree.tree_type != "maintenance":
|
||||
raise HTTPException(status_code=400, detail="Schedules are only supported for maintenance flows")
|
||||
|
||||
@@ -94,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_403(tree_id, current_user, db)
|
||||
await _get_tree_or_404(tree_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
|
||||
@@ -122,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_403(schedule.tree_id, current_user, db)
|
||||
await _get_tree_or_404(schedule.tree_id, current_user, db)
|
||||
|
||||
update_fields = data.model_fields_set
|
||||
was_active = schedule.is_active
|
||||
|
||||
@@ -197,6 +197,7 @@ async def create_template(
|
||||
template = ScriptTemplate(
|
||||
category_id=data.category_id,
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
created_by=current_user.id,
|
||||
name=data.name,
|
||||
slug=slug,
|
||||
@@ -364,6 +365,7 @@ async def generate_script(
|
||||
generation = ScriptGeneration(
|
||||
template_id=template.id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
session_id=data.session_id,
|
||||
ai_session_id=data.ai_session_id,
|
||||
|
||||
@@ -143,8 +143,8 @@ async def get_session(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
return session
|
||||
@@ -234,8 +234,8 @@ async def update_session(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -281,8 +281,8 @@ async def complete_session(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -319,8 +319,8 @@ async def update_scratchpad(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
session.scratchpad = data.scratchpad
|
||||
@@ -348,8 +348,8 @@ async def update_session_variables(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -387,8 +387,8 @@ async def export_session(
|
||||
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# PDF export — separate path with binary response
|
||||
@@ -830,8 +830,8 @@ async def link_ticket(
|
||||
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||
if not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
# Unlink
|
||||
|
||||
@@ -72,8 +72,8 @@ async def create_share(
|
||||
|
||||
if session.user_id != current_user.id and not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the session owner can create share links"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Require account_id for account-scoped shares
|
||||
@@ -170,8 +170,8 @@ async def revoke_share(
|
||||
|
||||
if share.created_by != current_user.id and not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the share creator can revoke it"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Share not found"
|
||||
)
|
||||
|
||||
share.is_active = False
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.schemas.step_category import (
|
||||
)
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.permissions import can_manage_step_category, can_create_step_category
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
|
||||
router = APIRouter(prefix="/step-categories", tags=["step-categories"])
|
||||
|
||||
@@ -44,13 +45,13 @@ async def list_step_categories(
|
||||
elif current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
StepCategory.account_id.is_(None), # Global
|
||||
StepCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
|
||||
StepCategory.account_id == current_user.account_id # User's account
|
||||
)
|
||||
)
|
||||
else:
|
||||
# User has no account, only show global categories
|
||||
query = query.where(StepCategory.account_id.is_(None))
|
||||
query = query.where(StepCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
|
||||
query = query.order_by(StepCategory.display_order, StepCategory.name)
|
||||
|
||||
@@ -94,8 +95,8 @@ async def get_step_category(
|
||||
# Check access: global categories visible to all, account categories only to account members
|
||||
if category.account_id and category.account_id != current_user.account_id and not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this step category"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Step category not found"
|
||||
)
|
||||
|
||||
return StepCategoryResponse(
|
||||
@@ -155,7 +156,7 @@ async def create_step_category(
|
||||
name=category_data.name,
|
||||
slug=slug,
|
||||
description=category_data.description,
|
||||
account_id=category_data.account_id,
|
||||
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
display_order=max_order + 1,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
@@ -47,10 +47,10 @@ async def get_step_or_404(
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
if check_view and not can_view_step(current_user, step):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this step")
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
if check_edit and not can_edit_step(current_user, step):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
return step
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.user import User
|
||||
from app.schemas.tag import TagCreate, TagResponse, TagListResponse, TagAssignment
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.permissions import can_manage_tree_tags, can_create_tag
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
@@ -33,13 +34,13 @@ async def list_tags(
|
||||
if include_account and current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeTag.account_id.is_(None), # Global
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global
|
||||
TreeTag.account_id == current_user.account_id # User's account
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Only show global tags
|
||||
query = query.where(TreeTag.account_id.is_(None))
|
||||
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
|
||||
|
||||
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name)
|
||||
|
||||
@@ -71,12 +72,12 @@ async def search_tags(
|
||||
if include_account and current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeTag.account_id.is_(None),
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
|
||||
TreeTag.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(TreeTag.account_id.is_(None))
|
||||
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
|
||||
|
||||
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name).limit(limit)
|
||||
|
||||
@@ -105,8 +106,8 @@ async def get_tag(
|
||||
# Check access: global tags visible to all, account tags only to account members
|
||||
if tag.account_id and tag.account_id != current_user.account_id and not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this tag"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tag not found"
|
||||
)
|
||||
|
||||
return TagResponse.model_validate(tag)
|
||||
@@ -147,7 +148,7 @@ async def create_tag(
|
||||
new_tag = TreeTag(
|
||||
name=tag_data.name,
|
||||
slug=slug,
|
||||
account_id=tag_data.account_id,
|
||||
account_id=tag_data.account_id if tag_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(new_tag)
|
||||
@@ -206,7 +207,7 @@ async def add_tags_to_tree(
|
||||
tag_query = select(TreeTag).where(
|
||||
TreeTag.slug == slug,
|
||||
or_(
|
||||
TreeTag.account_id.is_(None), # Global tag
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global tag
|
||||
TreeTag.account_id == tag_account_id # Account tag
|
||||
)
|
||||
)
|
||||
@@ -340,7 +341,7 @@ async def replace_tree_tags(
|
||||
tag_query = select(TreeTag).where(
|
||||
TreeTag.slug == slug,
|
||||
or_(
|
||||
TreeTag.account_id.is_(None),
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
|
||||
TreeTag.account_id == tag_account_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.core.subscriptions import check_tree_limit, get_account_subscription, g
|
||||
from app.core.audit import log_audit
|
||||
from app.core.config import settings
|
||||
from app.core.tree_validation import can_publish_tree
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
|
||||
from app.services.rag_service import index_tree as rag_index_tree
|
||||
|
||||
@@ -391,9 +392,10 @@ async def get_tree(
|
||||
)
|
||||
|
||||
if not tree.is_active or not can_access_tree(current_user, tree):
|
||||
# Always 404, never 403. A 403 confirms the resource exists.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this tree"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
return build_full_tree_response(tree)
|
||||
@@ -470,7 +472,7 @@ async def create_tree(
|
||||
tree_structure=tree_data.tree_structure,
|
||||
intake_form=intake_form_data,
|
||||
author_id=service_account_id if is_default else current_user.id,
|
||||
account_id=None if is_default else current_user.account_id,
|
||||
account_id=PLATFORM_ACCOUNT_ID if is_default else current_user.account_id,
|
||||
is_public=True if is_default else tree_data.is_public, # Default trees are always public
|
||||
is_default=is_default,
|
||||
status=tree_data.status
|
||||
@@ -610,9 +612,17 @@ async def update_tree(
|
||||
)
|
||||
|
||||
if not can_edit_tree(current_user, tree):
|
||||
# If the user can see this tree (same account, team visibility), give a 403 with
|
||||
# a clear message — returning 404 here would be confusing since GET returns 200.
|
||||
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
|
||||
if can_access_tree(current_user, tree):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have permission to edit this flow"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only edit your own trees"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
# Extract tags for separate handling
|
||||
@@ -1144,9 +1154,17 @@ async def update_tree_visibility(
|
||||
)
|
||||
|
||||
if not can_edit_tree(current_user, tree):
|
||||
# If the user can see this tree (same account, team visibility), give a 403 with
|
||||
# a clear message — returning 404 here would be confusing since GET returns 200.
|
||||
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
|
||||
if can_access_tree(current_user, tree):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have permission to edit this flow"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only edit your own trees"
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
# Update visibility
|
||||
|
||||
@@ -255,9 +255,9 @@ async def get_upload_url(
|
||||
if upload is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
# Verify the upload belongs to the user's account
|
||||
# Verify the upload belongs to the user's account — 404 to avoid revealing existence
|
||||
if upload.account_id != current_user.account_id and not current_user.is_super_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
url = storage_service.get_presigned_url(upload.storage_key)
|
||||
return {"url": url}
|
||||
@@ -311,9 +311,9 @@ async def delete_upload(
|
||||
if upload is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
# Verify ownership
|
||||
# Verify ownership — 404 to avoid revealing existence
|
||||
if upload.uploaded_by != current_user.id and not current_user.is_super_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
# Delete from S3
|
||||
await storage_service.delete_file(upload.storage_key)
|
||||
|
||||
@@ -1,51 +1,89 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
||||
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
||||
from app.api.endpoints import ratings, analytics
|
||||
from app.api.endpoints import target_lists
|
||||
from app.api.endpoints import maintenance_schedules
|
||||
from app.api.endpoints import feedback
|
||||
from app.api.endpoints import ai_builder
|
||||
from app.api.endpoints import ai_fix
|
||||
from app.api.endpoints import ai_chat
|
||||
from app.api.endpoints import copilot
|
||||
from app.api.endpoints import assistant_chat
|
||||
from app.api.endpoints import survey
|
||||
from app.api.endpoints import admin_survey
|
||||
from app.api.endpoints import tree_transfer
|
||||
from app.api.endpoints import ai_suggestions
|
||||
from app.api.endpoints import kb_accelerator
|
||||
from app.api.endpoints import beta_signup
|
||||
from app.api.endpoints import scripts
|
||||
from app.api.endpoints import integrations
|
||||
from app.api.endpoints import onboarding
|
||||
from app.api.endpoints import branding
|
||||
from app.api.endpoints import supporting_data
|
||||
from app.api.endpoints import ai_sessions
|
||||
from app.api.endpoints import flow_proposals
|
||||
from app.api.endpoints import flowpilot_analytics
|
||||
from app.api.endpoints import notifications
|
||||
from app.api.endpoints import public_templates
|
||||
from app.api.endpoints import admin_gallery
|
||||
from app.api.endpoints import uploads
|
||||
from app.api.endpoints import script_builder
|
||||
from app.api.endpoints import beta_feedback
|
||||
from app.api.endpoints import session_branches
|
||||
from app.api.endpoints import session_handoffs
|
||||
from app.api.endpoints import session_resolutions
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import require_tenant_context
|
||||
from app.api.endpoints import (
|
||||
admin,
|
||||
admin_audit,
|
||||
admin_categories,
|
||||
admin_dashboard,
|
||||
admin_feature_flags,
|
||||
admin_gallery,
|
||||
admin_plan_limits,
|
||||
admin_settings,
|
||||
admin_survey,
|
||||
ai_builder,
|
||||
ai_chat,
|
||||
ai_fix,
|
||||
ai_sessions,
|
||||
ai_suggestions,
|
||||
analytics,
|
||||
assistant_chat,
|
||||
auth,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
branding,
|
||||
categories,
|
||||
copilot,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
folders,
|
||||
integrations,
|
||||
invite,
|
||||
kb_accelerator,
|
||||
maintenance_schedules,
|
||||
notifications,
|
||||
onboarding,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
script_builder,
|
||||
session_branches,
|
||||
session_handoffs,
|
||||
session_resolutions,
|
||||
sessions,
|
||||
shared,
|
||||
shares,
|
||||
sidebar,
|
||||
step_categories,
|
||||
steps,
|
||||
supporting_data,
|
||||
survey,
|
||||
tags,
|
||||
target_lists,
|
||||
tree_markdown,
|
||||
tree_transfer,
|
||||
trees,
|
||||
uploads,
|
||||
webhooks,
|
||||
accounts,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public / unauthenticated endpoints — no tenant context
|
||||
#
|
||||
# Note: auth.router contains both public endpoints (register, login,
|
||||
# forgot-password, reset-password, email/verify) and authenticated endpoints
|
||||
# (GET/PATCH /me, logout, change-password, email/send-verification).
|
||||
# The authenticated auth endpoints only query the `users` table, which is
|
||||
# excluded from Phase 1 RLS. They work correctly without tenant context
|
||||
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
|
||||
# ---------------------------------------------------------------------------
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(trees.router)
|
||||
api_router.include_router(sidebar.router)
|
||||
api_router.include_router(sessions.router)
|
||||
api_router.include_router(invite.router)
|
||||
api_router.include_router(categories.router)
|
||||
api_router.include_router(tags.router)
|
||||
api_router.include_router(folders.router)
|
||||
api_router.include_router(step_categories.router)
|
||||
api_router.include_router(steps.router)
|
||||
api_router.include_router(shared.router) # Public share links (no auth)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints — super_admin only
|
||||
# admin_categories, admin_gallery, admin_dashboard, admin query Phase 1 RLS
|
||||
# tables and MUST use get_admin_db (migrated in Task 8). The remaining admin
|
||||
# endpoints (admin_audit, admin_plan_limits, admin_feature_flags,
|
||||
# admin_settings, admin_survey) are safe until Phase 2 extends RLS.
|
||||
# ---------------------------------------------------------------------------
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(admin_dashboard.router)
|
||||
api_router.include_router(admin_audit.router)
|
||||
@@ -53,42 +91,54 @@ api_router.include_router(admin_plan_limits.router)
|
||||
api_router.include_router(admin_feature_flags.router)
|
||||
api_router.include_router(admin_settings.router)
|
||||
api_router.include_router(admin_categories.router)
|
||||
api_router.include_router(accounts.router)
|
||||
api_router.include_router(webhooks.router)
|
||||
api_router.include_router(shares.router)
|
||||
api_router.include_router(shared.router) # Public endpoints (no auth)
|
||||
api_router.include_router(tree_markdown.router)
|
||||
api_router.include_router(ratings.router)
|
||||
api_router.include_router(analytics.router)
|
||||
api_router.include_router(target_lists.router)
|
||||
api_router.include_router(maintenance_schedules.router)
|
||||
api_router.include_router(feedback.router)
|
||||
api_router.include_router(ai_builder.router)
|
||||
api_router.include_router(ai_fix.router)
|
||||
api_router.include_router(ai_chat.router)
|
||||
api_router.include_router(copilot.router)
|
||||
api_router.include_router(assistant_chat.router)
|
||||
api_router.include_router(survey.router)
|
||||
api_router.include_router(admin_survey.router)
|
||||
api_router.include_router(tree_transfer.router)
|
||||
api_router.include_router(ai_suggestions.router)
|
||||
api_router.include_router(kb_accelerator.router)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(scripts.router)
|
||||
api_router.include_router(integrations.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(supporting_data.router)
|
||||
api_router.include_router(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(ai_sessions.router)
|
||||
api_router.include_router(flow_proposals.router)
|
||||
api_router.include_router(flowpilot_analytics.router)
|
||||
api_router.include_router(notifications.router)
|
||||
api_router.include_router(public_templates.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
api_router.include_router(uploads.router)
|
||||
api_router.include_router(script_builder.router)
|
||||
api_router.include_router(beta_feedback.router)
|
||||
api_router.include_router(session_branches.router)
|
||||
api_router.include_router(session_handoffs.router)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-facing endpoints — tenant context required
|
||||
# ---------------------------------------------------------------------------
|
||||
_tenant_deps = [Depends(require_tenant_context)]
|
||||
|
||||
api_router.include_router(trees.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(invite.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tags.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(folders.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(steps.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(accounts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(shares.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ratings.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(feedback.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(copilot.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(survey.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(scripts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(integrations.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(branding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
|
||||
# session_handoffs queue router must come before ai_sessions to avoid conflict
|
||||
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(notifications.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(uploads.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
||||
|
||||
36
backend/app/core/admin_database.py
Normal file
36
backend/app/core/admin_database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# backend/app/core/admin_database.py
|
||||
"""
|
||||
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
|
||||
|
||||
Use ONLY for /admin/* endpoints and internal tooling.
|
||||
Never use this engine from user-facing endpoints.
|
||||
"""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
admin_engine = create_async_engine(
|
||||
settings.ADMIN_DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
_admin_session_factory = async_sessionmaker(
|
||||
admin_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an admin DB session (BYPASSRLS). Use only on /admin/* endpoints."""
|
||||
async with _admin_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -23,10 +23,33 @@ class Settings(BaseSettings):
|
||||
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return v
|
||||
|
||||
@property
|
||||
def DATABASE_URL_SYNC(self) -> str:
|
||||
"""Get sync URL by removing asyncpg prefix from DATABASE_URL."""
|
||||
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
# Sync URL for Alembic migrations. Defaults to DATABASE_URL (sync-converted).
|
||||
# Set explicitly in .env to use a different role for migrations (e.g. superuser)
|
||||
# when DATABASE_URL has been switched to the app role.
|
||||
DATABASE_URL_SYNC: str = ""
|
||||
|
||||
@field_validator("DATABASE_URL_SYNC", mode="before")
|
||||
@classmethod
|
||||
def default_database_url_sync(cls, v: str, info) -> str:
|
||||
"""Fall back to sync-converted DATABASE_URL if not explicitly set."""
|
||||
if not v:
|
||||
base = info.data.get("DATABASE_URL", "")
|
||||
return base.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
return v
|
||||
|
||||
# Admin database — resolutionflow_admin role, BYPASSRLS.
|
||||
# Used by /admin/* endpoints. Defaults to DATABASE_URL for local dev.
|
||||
ADMIN_DATABASE_URL: str = ""
|
||||
|
||||
@field_validator("ADMIN_DATABASE_URL", mode="before")
|
||||
@classmethod
|
||||
def default_admin_database_url(cls, v: str, info) -> str:
|
||||
"""Fall back to DATABASE_URL if ADMIN_DATABASE_URL is not set."""
|
||||
if not v:
|
||||
return info.data.get("DATABASE_URL", "")
|
||||
if v.startswith("postgresql://"):
|
||||
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return v
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = _DEFAULT_SECRET_KEY
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from .config import settings
|
||||
from app.core.tenant_context import register_tenant_listener
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
@@ -16,6 +17,11 @@ async_session_maker = async_sessionmaker(
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# Register the RLS tenant context listener on the app engine.
|
||||
# Fires at the start of every transaction; issues set_config automatically.
|
||||
# Must NOT be called on admin_engine — admin connections bypass RLS.
|
||||
register_tenant_listener(engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Centralized query filters for ResolutionFlow.
|
||||
|
||||
Provides reusable SQLAlchemy filter builders for tree access control
|
||||
and step visibility, used across multiple endpoint modules.
|
||||
Provides reusable SQLAlchemy filter builders for tree access control,
|
||||
step visibility, and the canonical tenant_filter used by all queries
|
||||
on tenant-scoped tables.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import or_, and_, true as sa_true
|
||||
@@ -13,6 +15,18 @@ if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def tenant_filter(model, account_id: uuid.UUID):
|
||||
"""Primary app-layer tenant filter.
|
||||
|
||||
MUST be used in every SELECT/UPDATE/DELETE on tenant tables.
|
||||
RLS (Phase 2) is the safety net — this is the primary enforcement.
|
||||
|
||||
Usage:
|
||||
stmt = select(Tree).where(tenant_filter(Tree, current_user.account_id), ...)
|
||||
"""
|
||||
return model.account_id == account_id
|
||||
|
||||
|
||||
def build_tree_access_filter(current_user: User):
|
||||
"""Build the access filter for trees based on user permissions.
|
||||
|
||||
@@ -36,10 +50,11 @@ def build_tree_access_filter(current_user: User):
|
||||
Tree.author_id == current_user.id,
|
||||
]
|
||||
if current_user.account_id:
|
||||
# Team-visible trees: use tenant_filter as the account match
|
||||
conditions.append(
|
||||
and_(
|
||||
Tree.visibility == 'team',
|
||||
Tree.account_id == current_user.account_id
|
||||
tenant_filter(Tree, current_user.account_id),
|
||||
)
|
||||
)
|
||||
return or_(*conditions)
|
||||
@@ -58,11 +73,14 @@ def build_step_visibility_filter(current_user: User):
|
||||
if current_user.account_id:
|
||||
return or_(
|
||||
StepLibrary.visibility == 'public',
|
||||
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id),
|
||||
StepLibrary.created_by == current_user.id # Own private steps
|
||||
and_(
|
||||
StepLibrary.visibility == 'team',
|
||||
tenant_filter(StepLibrary, current_user.account_id),
|
||||
),
|
||||
StepLibrary.created_by == current_user.id,
|
||||
)
|
||||
else:
|
||||
return or_(
|
||||
StepLibrary.visibility == 'public',
|
||||
StepLibrary.created_by == current_user.id
|
||||
StepLibrary.created_by == current_user.id,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
|
||||
SERVICE_ACCOUNT_NAME = "ResolutionFlow"
|
||||
|
||||
# Well-known UUID for the platform account — owns all default/global content.
|
||||
# Created by migration 3a40fe11b427_create_global_content_tables.
|
||||
PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
SYSTEM_ACCOUNT_NAME = "ResolutionFlow System"
|
||||
SYSTEM_ACCOUNT_DISPLAY_CODE = "RF-SYS-1"
|
||||
|
||||
|
||||
92
backend/app/core/tenant_context.py
Normal file
92
backend/app/core/tenant_context.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# backend/app/core/tenant_context.py
|
||||
"""
|
||||
Per-request tenant context for row-level security.
|
||||
|
||||
Flow:
|
||||
1. require_tenant_context (FastAPI dep) calls set_current_account_id().
|
||||
2. The SQLAlchemy transaction-begin listener fires on every new transaction
|
||||
and calls set_config('app.current_account_id', <id>, true) automatically.
|
||||
3. PostgreSQL RLS policies read current_setting('app.current_account_id', TRUE)
|
||||
to filter rows.
|
||||
|
||||
The ContextVar is asyncio-task-scoped: each concurrent request has its own value.
|
||||
set_config with is_local=true is transaction-scoped: it resets on COMMIT or
|
||||
ROLLBACK, so the listener re-applies it at the start of every transaction.
|
||||
"""
|
||||
import contextvars
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import event, or_, text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
# One slot per async task — each concurrent request gets its own value.
|
||||
_current_account_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"current_account_id", default=None
|
||||
)
|
||||
|
||||
# Platform account — global content visible to all tenants.
|
||||
PLATFORM_ACCOUNT_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
def set_current_account_id(account_id: UUID) -> contextvars.Token:
|
||||
"""Set tenant context for the current request coroutine.
|
||||
|
||||
Returns a token so the caller can reset it after the request.
|
||||
"""
|
||||
return _current_account_id.set(str(account_id))
|
||||
|
||||
|
||||
def clear_current_account_id(token: contextvars.Token) -> None:
|
||||
"""Reset the ContextVar to its previous value (call in finally block)."""
|
||||
_current_account_id.reset(token)
|
||||
|
||||
|
||||
def get_current_account_id() -> str | None:
|
||||
"""Return the account_id string for the current request, or None."""
|
||||
return _current_account_id.get()
|
||||
|
||||
|
||||
def register_tenant_listener(engine: AsyncEngine) -> None:
|
||||
"""Register the transaction-begin listener on the given engine.
|
||||
|
||||
Must be called once at application startup, AFTER the engine is created.
|
||||
The listener issues set_config() at the start of every transaction so that
|
||||
the setting is re-applied automatically even when a request commits
|
||||
mid-flight and starts a new transaction.
|
||||
|
||||
Do NOT call this on admin_engine — admin connections must never set tenant
|
||||
context automatically.
|
||||
"""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "begin")
|
||||
def _on_transaction_begin(conn) -> None: # noqa: ANN001
|
||||
account_id = _current_account_id.get()
|
||||
if account_id:
|
||||
# set_config(name, value, is_local=true) ≡ SET LOCAL.
|
||||
# Unlike SET LOCAL, set_config IS parameterisable.
|
||||
conn.execute(
|
||||
text("SELECT set_config('app.current_account_id', :id, true)"),
|
||||
{"id": account_id},
|
||||
)
|
||||
# If no account_id is set, do nothing. The RLS policy falls back to a
|
||||
# null-matching UUID and returns zero rows — fail-closed behaviour.
|
||||
|
||||
|
||||
def tenant_filter(Model, current_user: "User"): # noqa: ANN001
|
||||
"""SQLAlchemy filter clause for tables that contain platform-owned rows.
|
||||
|
||||
Use for: tree_tags, tree_categories, step_categories, step_library,
|
||||
template_trees, platform_steps.
|
||||
|
||||
For tenant-only tables (trees, sessions, psa_connections, etc.) use:
|
||||
Model.account_id == current_user.account_id
|
||||
directly.
|
||||
"""
|
||||
return or_(
|
||||
Model.account_id == current_user.account_id,
|
||||
Model.account_id == PLATFORM_ACCOUNT_ID,
|
||||
)
|
||||
@@ -54,6 +54,8 @@ from .session_branch import SessionBranch
|
||||
from .fork_point import ForkPoint
|
||||
from .session_handoff import SessionHandoff
|
||||
from .session_resolution_output import SessionResolutionOutput
|
||||
from .template_tree import TemplateTree
|
||||
from .platform_step import PlatformStep
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -122,4 +124,6 @@ __all__ = [
|
||||
"ForkPoint",
|
||||
"SessionHandoff",
|
||||
"SessionResolutionOutput",
|
||||
"TemplateTree",
|
||||
"PlatformStep",
|
||||
]
|
||||
|
||||
@@ -50,6 +50,13 @@ class AISessionStep(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Denormalized from ai_sessions.account_id for direct tenant filtering.",
|
||||
)
|
||||
step_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
comment="Sequential position in the session (0-indexed)",
|
||||
|
||||
@@ -28,6 +28,12 @@ class AISuggestion(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),
|
||||
|
||||
@@ -20,6 +20,12 @@ class Attachment(Base):
|
||||
ForeignKey("sessions.id"),
|
||||
nullable=False
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
@@ -39,10 +39,10 @@ class TreeCategory(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
@@ -11,7 +10,7 @@ class Feedback(Base):
|
||||
__tablename__ = "feedback"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
feedback_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
@@ -46,6 +46,12 @@ class UserFolder(Base):
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
|
||||
icon: Mapped[str] = mapped_column(String(50), nullable=False, default="folder")
|
||||
|
||||
@@ -23,6 +23,12 @@ class ForkPoint(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
parent_branch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False)
|
||||
trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
|
||||
fork_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -23,6 +23,12 @@ class MaintenanceSchedule(Base):
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
|
||||
target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
|
||||
@@ -31,6 +31,12 @@ class NotificationLog(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="sent")
|
||||
|
||||
37
backend/app/models/platform_step.py
Normal file
37
backend/app/models/platform_step.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Platform step model — platform-owned steps, readable by all users.
|
||||
|
||||
No account_id. No RLS. Readable by any authenticated user.
|
||||
Populated by promoting visibility='public' steps from step_library.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PlatformStep(Base):
|
||||
__tablename__ = "platform_steps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
source_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("step_library.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -25,6 +25,12 @@ class PsaMemberMapping(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
|
||||
@@ -35,6 +35,12 @@ class PsaPostLog(Base):
|
||||
ForeignKey("psa_connections.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ticket_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
note_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
content_posted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -29,6 +29,12 @@ class ScriptBuilderSession(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id", ondelete="SET NULL"),
|
||||
|
||||
@@ -44,6 +44,12 @@ class ScriptTemplate(Base):
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -97,6 +103,12 @@ class ScriptGeneration(Base):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
@@ -31,6 +31,12 @@ class Session(Base):
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
@@ -35,6 +35,12 @@ class SessionBranch(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True)
|
||||
fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
|
||||
branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
@@ -27,6 +27,12 @@ class SessionHandoff(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
handed_off_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
intent: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
@@ -23,6 +23,12 @@ class SessionResolutionOutput(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
output_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
generated_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="For KB: {symptoms, root_cause, steps, tags}")
|
||||
|
||||
@@ -38,10 +38,10 @@ class StepCategory(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
@@ -46,10 +46,10 @@ class StepLibrary(Base):
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=True
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
@@ -143,6 +143,13 @@ class StepRating(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Account of the RATER (not the step owner).",
|
||||
)
|
||||
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
|
||||
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
@@ -187,6 +194,13 @@ class StepUsageLog(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Account of the user who logged this usage.",
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("sessions.id", ondelete="CASCADE"),
|
||||
|
||||
@@ -14,6 +14,12 @@ class SessionSupportingData(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -51,10 +51,10 @@ class TreeTag(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.core.database import Base
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.team import Team
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
class TargetList(Base):
|
||||
@@ -21,6 +22,12 @@ class TargetList(Base):
|
||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
40
backend/app/models/template_tree.py
Normal file
40
backend/app/models/template_tree.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Template tree model — platform-owned troubleshooting trees, readable by all users.
|
||||
|
||||
No account_id. No RLS. Readable by any authenticated user.
|
||||
Populated by promoting is_default=TRUE trees from the trees table.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TemplateTree(Base):
|
||||
__tablename__ = "template_trees"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
tree_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -76,10 +76,10 @@ class Tree(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
@@ -37,10 +37,10 @@ class TreeEmbedding(Base):
|
||||
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
)
|
||||
chunk_type: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
|
||||
@@ -43,10 +43,10 @@ class User(Base):
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
# Account-based multi-tenancy (new)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
|
||||
@@ -24,6 +24,12 @@ class UserPinnedTree(Base):
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||
|
||||
@@ -127,6 +127,7 @@ class SessionDocumentation(BaseModel):
|
||||
diagnostic_steps: list[DocumentationStep]
|
||||
resolution_summary: str | None = None
|
||||
escalation_reason: str | None = None
|
||||
follow_up_recommendations: list[str] = []
|
||||
total_steps: int
|
||||
duration_display: str | None = None
|
||||
generated_at: datetime
|
||||
@@ -146,7 +147,7 @@ class StatusUpdateRequest(BaseModel):
|
||||
"""Generate a mid-session or post-session status update."""
|
||||
audience: str = Field(
|
||||
...,
|
||||
pattern="^(ticket_notes|client_update|email_draft)$",
|
||||
pattern="^(ticket_notes|client_update|email_draft|request_info)$",
|
||||
description="Who is this update for?",
|
||||
)
|
||||
length: str = Field(
|
||||
|
||||
@@ -77,6 +77,9 @@ scope narrows it to this endpoint.
|
||||
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
|
||||
- 1-3 questions per response
|
||||
- Do NOT ask questions inline in your prose. ALL questions go in the marker.
|
||||
- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \
|
||||
those as questions/actions in your next response UNLESS you are ≥75% confident the \
|
||||
information is no longer needed to resolve the issue. Default to keeping them.
|
||||
|
||||
**[ACTIONS] marker format:**
|
||||
- JSON array of objects with `label` (required), `command` (optional), `description` (required)
|
||||
@@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
||||
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
||||
No exceptions. Not even when forking. A response without at least one of these markers \
|
||||
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
|
||||
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
|
||||
in your markers unless you are ≥75% confident that information is no longer relevant.
|
||||
"""
|
||||
|
||||
|
||||
@@ -298,6 +303,8 @@ async def _call_anthropic_cached(
|
||||
}
|
||||
]
|
||||
|
||||
_mcp_active = mcp_servers is not anthropic.NOT_GIVEN
|
||||
|
||||
try:
|
||||
response = await client.beta.messages.create(
|
||||
model=settings.AI_MODEL_ANTHROPIC,
|
||||
@@ -308,12 +315,22 @@ async def _call_anthropic_cached(
|
||||
tools=tools,
|
||||
betas=["mcp-client-2025-11-20"],
|
||||
)
|
||||
except anthropic.BadRequestError as e:
|
||||
# MCP server failures (rate limits, connection errors) should not
|
||||
# block the assistant entirely — retry without MCP tools.
|
||||
if "MCP server" in str(e) and mcp_servers is not anthropic.NOT_GIVEN:
|
||||
logger.warning("MCP server error, retrying without MCP: %s", e)
|
||||
response = await client.beta.messages.create(
|
||||
except Exception as e:
|
||||
# MCP server failures surface as many error types — BadRequestError,
|
||||
# APIStatusError, APIConnectionError, APITimeoutError. Always retry
|
||||
# without MCP when MCP was active, so a flaky external server never
|
||||
# blocks the assistant entirely.
|
||||
_is_mcp_error = _mcp_active and (
|
||||
"MCP server" in str(e)
|
||||
or "mcp" in type(e).__name__.lower()
|
||||
or isinstance(e, (anthropic.BadRequestError, anthropic.APIStatusError))
|
||||
)
|
||||
if _is_mcp_error:
|
||||
logger.warning(
|
||||
"MCP server error (%s), retrying without MCP: %s",
|
||||
type(e).__name__, e,
|
||||
)
|
||||
response = await client.messages.create(
|
||||
model=settings.AI_MODEL_ANTHROPIC,
|
||||
max_tokens=max_tokens,
|
||||
system=system_blocks,
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -103,13 +103,23 @@ async def start_conversation(
|
||||
|
||||
Returns (conversation, greeting_message).
|
||||
"""
|
||||
# Load tree
|
||||
# Load tree — must be accessible to this account.
|
||||
# Allows own account's trees, default trees, and public trees.
|
||||
# Raises ValueError (caught by endpoint as 404) if not found or not accessible.
|
||||
result = await db.execute(
|
||||
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == tree_id)
|
||||
select(Tree).options(selectinload(Tree.tags)).where(
|
||||
Tree.id == tree_id,
|
||||
or_(
|
||||
Tree.account_id == account_id,
|
||||
Tree.author_id == user_id,
|
||||
Tree.is_default == True,
|
||||
Tree.is_public == True,
|
||||
),
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise ValueError(f"Tree {tree_id} not found")
|
||||
raise ValueError(f"Tree {tree_id} not found or not accessible")
|
||||
|
||||
conversation = CopilotConversation(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -911,16 +911,36 @@ async def generate_status_update(
|
||||
steps_summary = []
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
text = content.get("text", "")
|
||||
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
text = content.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
# Resolve option label instead of raw machine value
|
||||
response = None
|
||||
if step.was_skipped:
|
||||
response = "Skipped"
|
||||
elif step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
outcome = None
|
||||
if step.action_result:
|
||||
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
|
||||
entry = f"Step {step.step_order + 1}: {text}"
|
||||
if response:
|
||||
entry += f"\n Engineer response: {response}"
|
||||
entry = f"{step.step_order + 1}. {text}"
|
||||
if response and response != "Skipped":
|
||||
entry += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
entry += " (skipped)"
|
||||
if outcome:
|
||||
entry += f"\n Outcome: {outcome}"
|
||||
entry += f" [{outcome}]"
|
||||
steps_summary.append(entry)
|
||||
|
||||
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
|
||||
@@ -929,13 +949,8 @@ async def generate_status_update(
|
||||
now = datetime.now(timezone.utc)
|
||||
ref_time = session.resolved_at or now
|
||||
delta = ref_time - session.created_at
|
||||
total_minutes = int(delta.total_seconds() / 60)
|
||||
if total_minutes < 60:
|
||||
time_display = f"{total_minutes} minutes"
|
||||
else:
|
||||
hours = total_minutes // 60
|
||||
remaining = total_minutes % 60
|
||||
time_display = f"{hours}h {remaining}m"
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
time_display = f"{total_hrs} hrs"
|
||||
|
||||
# Extract client name from intake or ticket data
|
||||
client_name = None
|
||||
@@ -1135,8 +1150,9 @@ def _build_status_update_prompt(
|
||||
|
||||
Rules:
|
||||
- Be technical, concise, and factual
|
||||
- Use markdown formatting (bold headers, bullet lists)
|
||||
- Include: current status, steps completed, findings, what's been ruled out, next steps
|
||||
- Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text)
|
||||
- Structure as: current status paragraph, then "What We Know" section, then next steps
|
||||
- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line
|
||||
- Do NOT soften language or add pleasantries
|
||||
- Do NOT include greetings or sign-offs
|
||||
- {length_instruction}
|
||||
@@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
elif audience == "client_update":
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
return f"""You are generating a client-facing {context_label}.
|
||||
context_guidance = {
|
||||
"status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.",
|
||||
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
|
||||
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
|
||||
}.get(context, "")
|
||||
return f"""You are generating a brief client-facing {context_label}.
|
||||
|
||||
Rules:
|
||||
- Be professional, reassuring, and non-technical
|
||||
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.)
|
||||
- NEVER include server names, IP addresses, internal tool names, or technical identifiers
|
||||
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.)
|
||||
- NEVER include server names, IP addresses, internal tool names, or ticket IDs
|
||||
- Explain findings in plain language a non-technical business owner would understand
|
||||
- {client_greeting}
|
||||
- Sign off with: {engineer_name}
|
||||
- {length_instruction}
|
||||
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""}
|
||||
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
|
||||
- {context_guidance}
|
||||
|
||||
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
elif audience == "request_info":
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
return f"""You are generating a brief, professional message requesting information from the client.
|
||||
|
||||
Rules:
|
||||
- Be friendly, concise, and non-technical
|
||||
- Start with one sentence explaining what you're currently working on (plain language, no jargon)
|
||||
- Then list the specific questions you need answered, as a numbered list
|
||||
- Each question should be clear and answerable by a non-technical user
|
||||
- NEVER use technical jargon, server names, IP addresses, or internal tool names
|
||||
- {client_greeting}
|
||||
- Sign off with: {engineer_name}
|
||||
- Keep it short — this is a targeted ask, not a status update
|
||||
|
||||
Output ONLY the message text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
else: # email_draft
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
subject_hints = {
|
||||
"status": "Update: [brief issue description]",
|
||||
"resolution": "Resolved: [brief issue description]",
|
||||
"escalation": "Update: [brief issue description] — Specialist Review",
|
||||
"escalation": "Update: [brief issue description] — Specialist Assistance",
|
||||
"need_info": "Quick Question: [brief issue description]",
|
||||
}
|
||||
context_guidance = {
|
||||
"status": "We're actively working on it. Describe progress and next steps without giving a timeline.",
|
||||
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
|
||||
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
|
||||
}.get(context, "")
|
||||
return f"""You are generating a complete email draft for client communication.
|
||||
|
||||
Rules:
|
||||
@@ -1177,15 +1219,63 @@ Rules:
|
||||
- {client_greeting}
|
||||
- Be professional, reassuring, and non-technical
|
||||
- NEVER use technical jargon, server names, IP addresses, or internal tool names
|
||||
- Include a professional sign-off with:
|
||||
{engineer_name}
|
||||
- Include a professional sign-off with: {engineer_name}
|
||||
- {length_instruction}
|
||||
{"- This is good news — the issue is resolved." if context == "resolution" else ""}
|
||||
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
|
||||
- {context_guidance}
|
||||
|
||||
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
|
||||
def _build_what_we_know(session: AISession) -> str:
|
||||
"""Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps.
|
||||
|
||||
When the cockpit branch merges, session.evidence_items will be populated by the AI
|
||||
with confirmed/ruled_out/pending classifications. Until then, we derive findings
|
||||
from completed diagnostic steps.
|
||||
"""
|
||||
evidence_items = getattr(session, 'evidence_items', None)
|
||||
if evidence_items:
|
||||
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
|
||||
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
|
||||
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
|
||||
parts = []
|
||||
if confirmed:
|
||||
parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed))
|
||||
if ruled_out:
|
||||
parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out))
|
||||
if pending:
|
||||
parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending))
|
||||
return "\n".join(parts)
|
||||
|
||||
# Derive from completed steps
|
||||
findings = []
|
||||
for step in sorted(session.steps or [], key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description or step.was_skipped:
|
||||
continue
|
||||
response = None
|
||||
if step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
if response:
|
||||
findings.append(f"{description} — {response}")
|
||||
|
||||
if not findings:
|
||||
return ""
|
||||
return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings)
|
||||
|
||||
|
||||
def _build_status_update_context(
|
||||
session: AISession,
|
||||
steps_text: str,
|
||||
@@ -1206,24 +1296,17 @@ def _build_status_update_context(
|
||||
if session.psa_ticket_id:
|
||||
parts.append(f"Ticket ID: {session.psa_ticket_id}")
|
||||
|
||||
parts.append(f"\nDiagnostic steps:\n{steps_text}")
|
||||
what_we_know = _build_what_we_know(session)
|
||||
if what_we_know:
|
||||
parts.append(f"\nWhat we know:\n{what_we_know}")
|
||||
|
||||
parts.append(f"\nDiagnostic steps taken:\n{steps_text}")
|
||||
|
||||
if context == "resolution" and session.resolution_summary:
|
||||
parts.append(f"\nResolution: {session.resolution_summary}")
|
||||
if context == "escalation" and session.escalation_reason:
|
||||
parts.append(f"\nEscalation reason: {session.escalation_reason}")
|
||||
|
||||
# Include recent conversation messages for richer context
|
||||
messages = session.conversation_messages or []
|
||||
if messages:
|
||||
recent = messages[-10:] # Last 10 messages
|
||||
convo_text = "\n".join(
|
||||
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
|
||||
for m in recent
|
||||
if isinstance(m, dict) and "role" in m and "content" in m
|
||||
)
|
||||
parts.append(f"\nRecent conversation:\n{convo_text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -1420,6 +1503,7 @@ def _create_step_from_parsed(
|
||||
def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
"""Generate structured documentation from a session's steps."""
|
||||
diagnostic_steps = []
|
||||
follow_up_recommendations: list[str] = []
|
||||
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
@@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
outcome=outcome,
|
||||
))
|
||||
|
||||
# Collect follow-up recommendations from resolution suggestion steps
|
||||
if content.get("type") == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_up_recommendations.extend(recs)
|
||||
|
||||
# Calculate duration
|
||||
duration_display = None
|
||||
if session.resolved_at and session.created_at:
|
||||
@@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
diagnostic_steps=diagnostic_steps,
|
||||
resolution_summary=session.resolution_summary,
|
||||
escalation_reason=session.escalation_reason,
|
||||
follow_up_recommendations=follow_up_recommendations,
|
||||
total_steps=session.step_count,
|
||||
duration_display=duration_display,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str:
|
||||
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
|
||||
|
||||
|
||||
def _get_engineer_response(step) -> str | None:
|
||||
"""Extract the engineer's response label from a step."""
|
||||
if step.was_skipped:
|
||||
return "Skipped"
|
||||
if step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
return opt.get("label", step.selected_option)
|
||||
return step.selected_option
|
||||
if step.selected_option:
|
||||
return step.selected_option
|
||||
if step.free_text_input:
|
||||
return step.free_text_input
|
||||
return None
|
||||
|
||||
|
||||
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format a resolved session as a plain-text note for CW."""
|
||||
lines = [
|
||||
"═══ FlowPilot Session Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
]
|
||||
|
||||
# Engineer name from relationship if loaded, otherwise user_id
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Engineer: {engineer_name.name}")
|
||||
engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown"
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at)}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
f"Ended: {_format_datetime(session.resolved_at)}",
|
||||
])
|
||||
|
||||
# Duration
|
||||
duration_str = ""
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
if minutes < 60:
|
||||
lines.append(f"Duration: {minutes}m")
|
||||
else:
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
if session.problem_domain:
|
||||
lines.append(f"Domain: {session.problem_domain}")
|
||||
lines = [
|
||||
f"FlowPilot Session — {engineer_display}{duration_str}",
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
# Diagnostic steps
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Diagnosis Path ──")
|
||||
lines.append("Steps:")
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", step.step_type).capitalize()
|
||||
description = content.get("text", "")
|
||||
|
||||
response_text = ""
|
||||
if step.was_skipped:
|
||||
response_text = "Skipped"
|
||||
elif step.selected_option:
|
||||
# Try to find the label
|
||||
if step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response_text = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response_text = step.free_text_input
|
||||
|
||||
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
|
||||
if response_text:
|
||||
lines.append(f" → Response: {response_text}")
|
||||
if step.action_result:
|
||||
result = step.action_result
|
||||
outcome = "Succeeded" if result.get("success") else "Did not resolve"
|
||||
if details := result.get("details"):
|
||||
outcome += f" — {details}"
|
||||
lines.append(f" → Result: {outcome}")
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
continue # Not a diagnostic step
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# Resolution
|
||||
lines.append("")
|
||||
lines.append("── Resolution ──")
|
||||
lines.append(session.resolution_summary or "No resolution summary")
|
||||
lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}")
|
||||
if session.resolution_action:
|
||||
lines.append(session.resolution_action)
|
||||
|
||||
# Confidence
|
||||
lines.append("")
|
||||
lines.append("── AI Confidence ──")
|
||||
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
|
||||
# Follow-up recommendations from resolution suggestion step
|
||||
follow_ups: list[str] = []
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
if content.get("type") == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
if follow_ups:
|
||||
lines.append("")
|
||||
lines.append("Follow-up:")
|
||||
for rec in follow_ups:
|
||||
lines.append(f"- {rec}")
|
||||
|
||||
# Timing section (always present)
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
lines.append(f"End: {_format_datetime(session.resolved_at)}")
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (confirmed, ruled_out, pending) findings.
|
||||
|
||||
Uses session.evidence_items when the cockpit branch is merged; falls back
|
||||
to deriving from completed diagnostic steps.
|
||||
"""
|
||||
evidence_items = getattr(session, 'evidence_items', None)
|
||||
if evidence_items:
|
||||
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
|
||||
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
|
||||
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
|
||||
return confirmed, ruled_out, pending
|
||||
|
||||
# Derive from completed steps — all answered steps become findings
|
||||
findings = []
|
||||
for step in sorted(session.steps or [], key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description or step.was_skipped:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
if response:
|
||||
findings.append(f"{description} — {response}")
|
||||
return findings, [], []
|
||||
|
||||
|
||||
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format an escalated session as a plain-text note for CW."""
|
||||
engineer_obj = getattr(session, 'user', None)
|
||||
engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown"
|
||||
|
||||
escalated_to_obj = getattr(session, 'escalated_to', None)
|
||||
escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None
|
||||
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
duration_str = ""
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
header = f"FlowPilot Escalation — {engineer_display}{duration_str}"
|
||||
if escalated_to_display:
|
||||
header += f" → {escalated_to_display}"
|
||||
lines = [
|
||||
"═══ FlowPilot Escalation Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
header,
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Escalated by: {engineer_name.name}")
|
||||
|
||||
escalated_to = getattr(session, 'escalated_to', None)
|
||||
if escalated_to and hasattr(escalated_to, 'name'):
|
||||
lines.append(f"Escalated to: {escalated_to.name}")
|
||||
else:
|
||||
lines.append("Escalated to: Unassigned")
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
])
|
||||
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
|
||||
# Work completed
|
||||
# Work completed with responses
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Work Completed ──")
|
||||
for step in session.steps:
|
||||
lines.append("Work completed:")
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
description = content.get("text", "")
|
||||
lines.append(f"{step.step_order + 1}. {description}")
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# What We Know
|
||||
confirmed, ruled_out, pending = _derive_what_we_know(session)
|
||||
if confirmed or ruled_out or pending:
|
||||
lines.append("")
|
||||
lines.append("What we know:")
|
||||
for f in confirmed:
|
||||
lines.append(f" ✓ {f}")
|
||||
for f in ruled_out:
|
||||
lines.append(f" ✗ {f}")
|
||||
for f in pending:
|
||||
lines.append(f" ? {f}")
|
||||
|
||||
# Escalation reason
|
||||
lines.append("")
|
||||
lines.append("── Escalation Reason ──")
|
||||
lines.append(session.escalation_reason or "No reason provided")
|
||||
lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}")
|
||||
|
||||
# Escalation package details
|
||||
# Suggested next steps from escalation package
|
||||
pkg = session.escalation_package or {}
|
||||
if hypotheses := pkg.get("remaining_hypotheses"):
|
||||
lines.append("")
|
||||
lines.append("── Remaining Hypotheses ──")
|
||||
if isinstance(hypotheses, list):
|
||||
for h in hypotheses:
|
||||
lines.append(f"- {h}")
|
||||
else:
|
||||
lines.append(str(hypotheses))
|
||||
|
||||
if suggestions := pkg.get("suggested_next_steps"):
|
||||
lines.append("")
|
||||
lines.append("── Suggested Next Steps ──")
|
||||
if isinstance(suggestions, list):
|
||||
for s in suggestions:
|
||||
lines.append(f"- {s}")
|
||||
else:
|
||||
lines.append(str(suggestions))
|
||||
lines.append("Suggested next steps:")
|
||||
items = suggestions if isinstance(suggestions, list) else [str(suggestions)]
|
||||
for s in items:
|
||||
lines.append(f"- {s}")
|
||||
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -83,19 +83,55 @@ class ResolutionOutputGenerator:
|
||||
return output
|
||||
|
||||
def _build_session_context(self, session: AISession) -> str:
|
||||
intake = session.intake_content or {}
|
||||
intake_text = intake.get("text", "") or str(intake)
|
||||
parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Original intake: {intake_text[:300]}",
|
||||
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
||||
f"Steps taken: {session.step_count}",
|
||||
]
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
parts.append("\nConversation highlights:")
|
||||
for msg in msgs[-10:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:200]
|
||||
parts.append(f" [{role}]: {content}")
|
||||
|
||||
steps = sorted(session.steps or [], key=lambda s: s.step_order)
|
||||
diagnostic = []
|
||||
follow_ups: list[str] = []
|
||||
for step in steps:
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = None
|
||||
if step.was_skipped:
|
||||
response = "skipped"
|
||||
elif step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
entry = f" {step.step_order + 1}. {description}"
|
||||
if response and response != "skipped":
|
||||
entry += f" — {response}"
|
||||
diagnostic.append(entry)
|
||||
|
||||
if diagnostic:
|
||||
parts.append("\nDiagnostic steps:")
|
||||
parts.extend(diagnostic)
|
||||
if follow_ups:
|
||||
parts.append("\nRecommended follow-up:")
|
||||
parts.extend(f" - {r}" for r in follow_ups)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _psa_notes_prompt(self, context: str) -> str:
|
||||
|
||||
@@ -237,8 +237,10 @@ async def send_chat_message(
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args)
|
||||
|
||||
# Update branch conversation
|
||||
# Strip _(not yet completed)_ markers before storage (same reason as main path)
|
||||
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
|
||||
msgs = list(branch.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "user", "content": stored_message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
@@ -350,8 +352,14 @@ async def send_chat_message(
|
||||
# Store DISPLAY content (markers stripped) in conversation_messages.
|
||||
# The format reminder in the user message + system prompt final reminder
|
||||
# are sufficient to keep the AI emitting markers on subsequent turns.
|
||||
#
|
||||
# Strip _(not yet completed)_ task markers from the stored user message.
|
||||
# The AI processes them correctly on the current turn, but persisting them
|
||||
# into history causes the AI to re-inject stale task lane items from prior
|
||||
# turns — even across unrelated topics in a long session.
|
||||
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
|
||||
msgs = list(session.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "user", "content": stored_message})
|
||||
msgs.append({"role": "assistant", "content": display_content})
|
||||
session.conversation_messages = msgs
|
||||
session.step_count += 2 # message count for display
|
||||
|
||||
91
backend/scripts/check_tenant_filters.py
Normal file
91
backend/scripts/check_tenant_filters.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tenant filter enforcement check.
|
||||
|
||||
Scans endpoint and service files for SQLAlchemy select() calls on known
|
||||
tenant tables and warns when account_id or tenant_filter is not present
|
||||
in the surrounding 15 lines (the typical extent of a single query).
|
||||
|
||||
Usage:
|
||||
python scripts/check_tenant_filters.py # warn mode (exits 0)
|
||||
python scripts/check_tenant_filters.py --fail # block mode (exits 1 on findings)
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Tables that must always be filtered by account_id or tenant_filter.
|
||||
# Extend this list as new tenant tables are added.
|
||||
TENANT_MODELS = [
|
||||
"Tree", "AISession", "Session", "StepLibrary", "FlowProposal",
|
||||
"CopilotConversation", "AssistantChat", "FileUpload", "KBImport",
|
||||
"PsaConnection", "PsaPostLog", "PsaMemberMapping", "AIChatSession",
|
||||
"AIConversation", "AIUsage", "Subscription", "AccountInvite",
|
||||
"Notification", "NotificationConfig", "SessionShare", "UserFolder",
|
||||
"UserPinnedTree", "SessionBranch", "SessionHandoff",
|
||||
"SessionResolutionOutput", "ForkPoint", "AISessionStep",
|
||||
"AISuggestion", "StepCategory", "TreeCategory", "TreeTag",
|
||||
"Attachment", "SessionSupportingData", "MaintenanceSchedule",
|
||||
"AuditLog", "ScriptBuilderSession", "ScriptTemplate",
|
||||
"StepRating", "StepUsageLog", "TargetList",
|
||||
]
|
||||
|
||||
# Directories to scan
|
||||
SCAN_DIRS = [
|
||||
Path("app/api/endpoints"),
|
||||
Path("app/services"),
|
||||
]
|
||||
|
||||
# Patterns that indicate the query is correctly scoped.
|
||||
# NOTE: user_id scoping is accepted for user-owned resources (sessions, folders, notifications).
|
||||
# For account-shared resources (trees, steps, etc.) use tenant_filter or account_id.
|
||||
SAFE_PATTERNS = [
|
||||
r"tenant_filter",
|
||||
r"account_id",
|
||||
r"user_id", # User-scoped resources (sessions, folders, notifications, etc.)
|
||||
r"is_super_admin", # Super admin queries intentionally bypass tenant filter
|
||||
r"# cross-tenant: approved", # Explicit approval comment
|
||||
]
|
||||
|
||||
SKIP_FILES = {
|
||||
"admin.py", # Super admin endpoints intentionally bypass tenant filter
|
||||
"admin_gallery.py", # Gallery management — super admin only, no tenant scoping needed
|
||||
"public_templates.py",# Public template browser — intentionally cross-tenant
|
||||
"auth.py", # Auth/registration — no account context during login/register
|
||||
"ratings.py", # Session ratings — user-scoped via session lookup chain
|
||||
}
|
||||
|
||||
findings = []
|
||||
|
||||
for scan_dir in SCAN_DIRS:
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for path in sorted(scan_dir.glob("*.py")):
|
||||
if path.name in SKIP_FILES:
|
||||
continue
|
||||
lines = path.read_text().splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
for model in TENANT_MODELS:
|
||||
if re.search(rf"\bselect\s*\(\s*{model}\b", line):
|
||||
# Check surrounding 15 lines for a safe pattern
|
||||
start = max(0, i - 2)
|
||||
end = min(len(lines), i + 15)
|
||||
context = "\n".join(lines[start:end])
|
||||
if not any(re.search(p, context) for p in SAFE_PATTERNS):
|
||||
findings.append(
|
||||
f"{path}:{i + 1}: select({model}) — no tenant_filter or account_id found in context"
|
||||
)
|
||||
|
||||
if findings:
|
||||
print(f"\n⚠ Tenant filter check — {len(findings)} warning(s):\n")
|
||||
for f in findings:
|
||||
print(f" {f}")
|
||||
print()
|
||||
if "--fail" in sys.argv:
|
||||
print("Run with --fail: exiting 1")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Run in warn mode — not blocking. Pass --fail to block.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("✓ Tenant filter check passed — no unscoped tenant table queries found.")
|
||||
sys.exit(0)
|
||||
545
backend/tests/test_phase1_migrations.py
Normal file
545
backend/tests/test_phase1_migrations.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""Phase 1 migration tests — verify account_id backfill correctness.
|
||||
|
||||
These tests create objects via ORM (which uses the updated models),
|
||||
then verify account_id is populated correctly. They run against a
|
||||
real PostgreSQL test DB (same as all other integration tests).
|
||||
"""
|
||||
import pytest
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.session import Session
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.supporting_data import SessionSupportingData
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
from app.models.ai_session import AISession
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _make_account_and_user(db: AsyncSession, suffix: str) -> tuple[Account, User]:
|
||||
account = Account(name=f"Corp {suffix}", display_code=uuid.uuid4().hex[:8])
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=f"user-{suffix}-{uuid.uuid4().hex[:6]}@example.com",
|
||||
name=f"User {suffix}",
|
||||
password_hash=get_password_hash("TestPass123!"),
|
||||
is_active=True,
|
||||
account_id=account.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return account, user
|
||||
|
||||
|
||||
async def _make_tree(db: AsyncSession, account: Account, user: User) -> Tree:
|
||||
tree = Tree(
|
||||
name=f"Tree {uuid.uuid4().hex[:6]}",
|
||||
account_id=account.id,
|
||||
author_id=user.id,
|
||||
visibility="team",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
db.add(tree)
|
||||
await db.flush()
|
||||
return tree
|
||||
|
||||
|
||||
async def _make_session(db: AsyncSession, account: Account, user: User, tree: Tree) -> Session:
|
||||
s = Session(
|
||||
tree_id=tree.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
tree_snapshot={},
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
return s
|
||||
|
||||
|
||||
# ── Group 1: Core sessions ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_account_id_matches_user(test_db: AsyncSession):
|
||||
"""sessions.account_id must equal the user's account_id."""
|
||||
account, user = await _make_account_and_user(test_db, "s1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
session = await _make_session(test_db, account, user, tree)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(Session).where(Session.id == session.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id, f"Expected {account.id}, got {row.account_id}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_account_id_matches_session(test_db: AsyncSession):
|
||||
"""attachments.account_id must match the parent session's account_id."""
|
||||
account, user = await _make_account_and_user(test_db, "att1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
session = await _make_session(test_db, account, user, tree)
|
||||
|
||||
attachment = Attachment(
|
||||
session_id=session.id,
|
||||
account_id=account.id,
|
||||
file_name="test.png",
|
||||
file_type="image/png",
|
||||
)
|
||||
test_db.add(attachment)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(Attachment).where(Attachment.id == attachment.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_supporting_data_account_id(test_db: AsyncSession):
|
||||
"""session_supporting_data.account_id must match parent session's account_id."""
|
||||
account, user = await _make_account_and_user(test_db, "sd1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
session = await _make_session(test_db, account, user, tree)
|
||||
|
||||
sd = SessionSupportingData(
|
||||
session_id=session.id,
|
||||
account_id=account.id,
|
||||
label="Log snippet",
|
||||
data_type="text_snippet",
|
||||
content="error: connection refused",
|
||||
)
|
||||
test_db.add(sd)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(SessionSupportingData).where(SessionSupportingData.id == sd.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_resolution_output_account_id(test_db: AsyncSession):
|
||||
"""session_resolution_outputs.account_id must match the parent ai_session's account_id.
|
||||
|
||||
NOTE: session_resolution_outputs.session_id FK points to ai_sessions (not sessions).
|
||||
"""
|
||||
account, user = await _make_account_and_user(test_db, "sro1")
|
||||
|
||||
ai_session = AISession(
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
problem_summary="test resolution output",
|
||||
problem_domain="networking",
|
||||
status="active",
|
||||
)
|
||||
test_db.add(ai_session)
|
||||
await test_db.flush()
|
||||
|
||||
output = SessionResolutionOutput(
|
||||
session_id=ai_session.id,
|
||||
account_id=account.id,
|
||||
output_type="psa_ticket_notes",
|
||||
generated_content="Ticket notes content",
|
||||
generated_by_model="gpt-4",
|
||||
)
|
||||
test_db.add(output)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(SessionResolutionOutput).where(SessionResolutionOutput.id == output.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 2: AI & branching ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_branch_account_id_matches_ai_session(test_db: AsyncSession):
|
||||
"""session_branches.account_id must match parent ai_session.account_id."""
|
||||
from app.models.session_branch import SessionBranch
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "sb1")
|
||||
ai_session = AISession(
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
problem_summary="test",
|
||||
problem_domain="networking",
|
||||
status="active",
|
||||
)
|
||||
test_db.add(ai_session)
|
||||
await test_db.flush()
|
||||
|
||||
branch = SessionBranch(
|
||||
session_id=ai_session.id,
|
||||
account_id=account.id,
|
||||
label="Branch A",
|
||||
branch_order=1,
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(branch)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == branch.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_suggestion_account_id_matches_user(test_db: AsyncSession):
|
||||
"""ai_suggestions.account_id must match the creating user's account_id."""
|
||||
from app.models.ai_suggestion import AISuggestion
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "ais1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
|
||||
suggestion = AISuggestion(
|
||||
tree_id=tree.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
action_type="add_node",
|
||||
changes_json={},
|
||||
status="pending",
|
||||
)
|
||||
test_db.add(suggestion)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AISuggestion).where(AISuggestion.id == suggestion.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 3: Steps & ratings ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_rating_account_id_is_rater_account(test_db: AsyncSession):
|
||||
"""step_ratings.account_id must be the RATER's account, not the step's account."""
|
||||
from app.models.step_library import StepLibrary, StepRating
|
||||
|
||||
account_a, user_a = await _make_account_and_user(test_db, "sr-rater")
|
||||
account_b, user_b = await _make_account_and_user(test_db, "sr-step-owner")
|
||||
|
||||
# Step owned by account_b
|
||||
step = StepLibrary(
|
||||
title="A step",
|
||||
step_type="action",
|
||||
content={"text": "do something"},
|
||||
created_by=user_b.id,
|
||||
account_id=account_b.id,
|
||||
visibility="public",
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.flush()
|
||||
|
||||
# user_a (account_a) rates the step
|
||||
rating = StepRating(
|
||||
step_id=step.id,
|
||||
user_id=user_a.id,
|
||||
account_id=account_a.id, # rater's account, not step owner's
|
||||
was_helpful=True,
|
||||
is_verified_use=False,
|
||||
is_visible=True,
|
||||
)
|
||||
test_db.add(rating)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(StepRating).where(StepRating.id == rating.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account_a.id, (
|
||||
f"account_id should be rater's account ({account_a.id}), got {row.account_id}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_usage_log_account_id_is_logger_account(test_db: AsyncSession):
|
||||
"""step_usage_log.account_id must be the LOGGER's account (user who used the step)."""
|
||||
from app.models.step_library import StepLibrary, StepUsageLog
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "sul1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
session = await _make_session(test_db, account, user, tree)
|
||||
|
||||
step = StepLibrary(
|
||||
title="A usage step",
|
||||
step_type="action",
|
||||
content={"text": "do something"},
|
||||
created_by=user.id,
|
||||
account_id=account.id,
|
||||
visibility="team",
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.flush()
|
||||
|
||||
log = StepUsageLog(
|
||||
step_id=step.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
session_id=session.id,
|
||||
)
|
||||
test_db.add(log)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(StepUsageLog).where(StepUsageLog.id == log.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id, (
|
||||
f"account_id should be logger's account ({account.id}), got {row.account_id}"
|
||||
)
|
||||
|
||||
|
||||
# ── Group 4: User personalization ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_folder_account_id_matches_user(test_db: AsyncSession):
|
||||
"""user_folders.account_id must match the owning user's account_id."""
|
||||
from app.models.folder import UserFolder
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "uf1")
|
||||
folder = UserFolder(
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
name="My Folder",
|
||||
color="#6366f1",
|
||||
icon="folder",
|
||||
display_order=0,
|
||||
)
|
||||
test_db.add(folder)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(UserFolder).where(UserFolder.id == folder.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_pinned_tree_account_id_matches_user(test_db: AsyncSession):
|
||||
"""user_pinned_trees.account_id must match the pinning user's account_id."""
|
||||
from app.models.user_pinned_tree import UserPinnedTree
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "pt1")
|
||||
tree = await _make_tree(test_db, account, user)
|
||||
pin = UserPinnedTree(
|
||||
user_id=user.id,
|
||||
tree_id=tree.id,
|
||||
account_id=account.id,
|
||||
display_order=0,
|
||||
)
|
||||
test_db.add(pin)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(select(UserPinnedTree).where(UserPinnedTree.id == pin.id))
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 5: PSA & notifications ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_psa_member_mapping_account_id_matches_connection(test_db: AsyncSession):
|
||||
"""psa_member_mappings.account_id must match psa_connection's account_id."""
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.psa_member_mapping import PsaMemberMapping
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "psa1")
|
||||
conn = PsaConnection(
|
||||
account_id=account.id,
|
||||
provider="connectwise",
|
||||
display_name="Test CW",
|
||||
site_url="https://cw.example.com",
|
||||
company_id="TEST",
|
||||
credentials_encrypted="placeholder",
|
||||
)
|
||||
test_db.add(conn)
|
||||
await test_db.flush()
|
||||
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
external_member_id="cw-123",
|
||||
external_member_name="Test User",
|
||||
matched_by="manual_admin",
|
||||
)
|
||||
test_db.add(mapping)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(PsaMemberMapping).where(PsaMemberMapping.id == mapping.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 6: Maintenance ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_schedule_account_id_matches_tree(test_db: AsyncSession):
|
||||
"""maintenance_schedules.account_id must match the tree's account_id."""
|
||||
from app.models.maintenance_schedule import MaintenanceSchedule
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "ms1")
|
||||
tree = Tree(
|
||||
name="Maintenance Flow",
|
||||
account_id=account.id,
|
||||
author_id=user.id,
|
||||
visibility="team",
|
||||
tree_type="maintenance",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
test_db.add(tree)
|
||||
await test_db.flush()
|
||||
|
||||
schedule = MaintenanceSchedule(
|
||||
tree_id=tree.id,
|
||||
account_id=account.id,
|
||||
created_by=user.id,
|
||||
cron_expression="0 9 * * 1",
|
||||
timezone="UTC",
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(schedule)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 7: Legacy team_id tables ───────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_builder_session_account_id(test_db: AsyncSession):
|
||||
"""script_builder_sessions.account_id must match user's account_id."""
|
||||
from app.models.script_builder_session import ScriptBuilderSession
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "sbs1")
|
||||
sbs = ScriptBuilderSession(
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
language="powershell",
|
||||
)
|
||||
test_db.add(sbs)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(ScriptBuilderSession).where(ScriptBuilderSession.id == sbs.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 8: TargetList ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_target_list_account_id_from_team_admin(test_db: AsyncSession):
|
||||
"""target_lists.account_id must be set to the team admin's account_id."""
|
||||
from app.models.target_list import TargetList
|
||||
from app.models.team import Team
|
||||
|
||||
account, user = await _make_account_and_user(test_db, "tl1")
|
||||
# Make user a team admin
|
||||
team = Team(name=f"Team {uuid.uuid4().hex[:6]}")
|
||||
test_db.add(team)
|
||||
await test_db.flush()
|
||||
|
||||
user.team_id = team.id
|
||||
user.is_team_admin = True
|
||||
await test_db.flush()
|
||||
|
||||
target_list = TargetList(
|
||||
team_id=team.id,
|
||||
account_id=account.id,
|
||||
created_by=user.id,
|
||||
name="Server Targets",
|
||||
targets=[{"label": "SRV-01"}],
|
||||
)
|
||||
test_db.add(target_list)
|
||||
await test_db.commit()
|
||||
|
||||
result = await test_db.execute(
|
||||
select(TargetList).where(TargetList.id == target_list.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
|
||||
|
||||
# ── Group 10 (runs first): Global content tables ──────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_trees_table_exists_and_has_no_account_id(test_db: AsyncSession):
|
||||
"""template_trees must exist and must NOT have an account_id column."""
|
||||
result = await test_db.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'template_trees'
|
||||
"""))
|
||||
columns = {row[0] for row in result.fetchall()}
|
||||
assert 'id' in columns, "template_trees.id must exist"
|
||||
assert 'account_id' not in columns, "template_trees must not have account_id (global content)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_steps_table_exists_and_has_no_account_id(test_db: AsyncSession):
|
||||
"""platform_steps must exist and must NOT have an account_id column."""
|
||||
result = await test_db.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'platform_steps'
|
||||
"""))
|
||||
columns = {row[0] for row in result.fetchall()}
|
||||
assert 'id' in columns, "platform_steps.id must exist"
|
||||
assert 'account_id' not in columns, "platform_steps must not have account_id (global content)"
|
||||
|
||||
|
||||
# ── Group 9: SET NOT NULL on existing nullable columns ────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_account_id_is_not_null(test_db: AsyncSession):
|
||||
"""trees.account_id must be NOT NULL after Phase 1 — enforced at DB level."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
with pytest.raises(IntegrityError):
|
||||
test_db.add(Tree(
|
||||
name="Bad tree",
|
||||
# account_id intentionally omitted
|
||||
author_id=None,
|
||||
visibility="private",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={},
|
||||
is_active=True,
|
||||
status="draft",
|
||||
))
|
||||
await test_db.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_account_id_is_not_null(test_db: AsyncSession):
|
||||
"""users.account_id must be NOT NULL after Phase 1."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
with pytest.raises(IntegrityError):
|
||||
test_db.add(User(
|
||||
email=f"orphan-{uuid.uuid4().hex[:6]}@example.com",
|
||||
name="Orphan",
|
||||
password_hash=get_password_hash("x"),
|
||||
is_active=True,
|
||||
role="engineer",
|
||||
account_role="engineer",
|
||||
# account_id intentionally omitted
|
||||
))
|
||||
await test_db.flush()
|
||||
266
backend/tests/test_rls_isolation.py
Normal file
266
backend/tests/test_rls_isolation.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# backend/tests/test_rls_isolation.py
|
||||
"""
|
||||
RLS foundation tests.
|
||||
|
||||
Connect directly as resolutionflow_app (not superuser) and verify:
|
||||
- Tenant A cannot read Tenant B's rows
|
||||
- No tenant context set → zero rows for private data (fail-closed)
|
||||
- Platform rows (PLATFORM_ACCOUNT_ID) are visible to all tenants
|
||||
|
||||
Tests bypass FastAPI entirely — raw asyncpg connections only.
|
||||
MUST FAIL before Task 10 (RLS migration) and PASS after it.
|
||||
|
||||
Run with:
|
||||
DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
|
||||
|
||||
The test DB is patherly_test (matches conftest.py default).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
|
||||
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
|
||||
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
|
||||
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
|
||||
_ADMIN_DSN = f"postgresql://postgres:postgres@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
|
||||
|
||||
PLATFORM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def admin_conn():
|
||||
"""Superuser asyncpg connection for fixture setup and teardown."""
|
||||
conn = await asyncpg.connect(_ADMIN_DSN)
|
||||
yield conn
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
async def seed_rls_test_data(admin_conn):
|
||||
"""
|
||||
Create two isolated test accounts, one user per account, and one private
|
||||
tree per account. Trees require a valid author_id FK to users, so users
|
||||
must be created first.
|
||||
|
||||
accounts.display_code must be unique and 8 chars (NOT NULL constraint).
|
||||
"""
|
||||
# Insert accounts
|
||||
await admin_conn.execute(f"""
|
||||
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
|
||||
VALUES
|
||||
('{ACCOUNT_A_ID}', 'RLS Tenant A', 'RLSA0001', NOW(), NOW()),
|
||||
('{ACCOUNT_B_ID}', 'RLS Tenant B', 'RLSB0001', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""")
|
||||
|
||||
# Insert one user per account (users.account_id NOT NULL, password_hash NOT NULL)
|
||||
user_a_id = str(uuid.uuid4())
|
||||
user_b_id = str(uuid.uuid4())
|
||||
await admin_conn.execute(f"""
|
||||
INSERT INTO users (
|
||||
id, email, password_hash, name, role, is_active, account_id,
|
||||
account_role, created_at
|
||||
) VALUES
|
||||
('{user_a_id}', 'rls-user-a@example.com',
|
||||
'placeholder', 'RLS User A', 'engineer', TRUE,
|
||||
'{ACCOUNT_A_ID}', 'engineer', NOW()),
|
||||
('{user_b_id}', 'rls-user-b@example.com',
|
||||
'placeholder', 'RLS User B', 'engineer', TRUE,
|
||||
'{ACCOUNT_B_ID}', 'engineer', NOW())
|
||||
ON CONFLICT (email) DO NOTHING
|
||||
""")
|
||||
|
||||
# Look up the user IDs we just inserted (ON CONFLICT may have skipped)
|
||||
row_a = await admin_conn.fetchrow(
|
||||
"SELECT id FROM users WHERE email = 'rls-user-a@example.com'"
|
||||
)
|
||||
row_b = await admin_conn.fetchrow(
|
||||
"SELECT id FROM users WHERE email = 'rls-user-b@example.com'"
|
||||
)
|
||||
actual_user_a = str(row_a["id"])
|
||||
actual_user_b = str(row_b["id"])
|
||||
|
||||
# Insert one private tree per account with explicit author_id
|
||||
await admin_conn.execute(f"""
|
||||
INSERT INTO trees (
|
||||
id, name, tree_structure, account_id, author_id, is_active, is_default,
|
||||
is_public, visibility, tree_type, created_at, updated_at
|
||||
) VALUES
|
||||
(gen_random_uuid(), 'RLS Tree A', '[]'::jsonb, '{ACCOUNT_A_ID}', '{actual_user_a}',
|
||||
TRUE, FALSE, FALSE, 'private', 'troubleshooting', NOW(), NOW()),
|
||||
(gen_random_uuid(), 'RLS Tree B', '[]'::jsonb, '{ACCOUNT_B_ID}', '{actual_user_b}',
|
||||
TRUE, FALSE, FALSE, 'private', 'troubleshooting', NOW(), NOW())
|
||||
""")
|
||||
|
||||
# One platform-owned tree_tag (global, visible to all tenants)
|
||||
await admin_conn.execute(f"""
|
||||
INSERT INTO tree_tags (
|
||||
id, name, slug, account_id, usage_count, created_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), 'rls-global-tag', 'rls-global-tag',
|
||||
'{PLATFORM_ACCOUNT_ID}', 0, NOW()
|
||||
) ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
await admin_conn.execute(
|
||||
f"DELETE FROM trees WHERE account_id IN ('{ACCOUNT_A_ID}', '{ACCOUNT_B_ID}')"
|
||||
)
|
||||
await admin_conn.execute(
|
||||
"DELETE FROM users WHERE email IN "
|
||||
"('rls-user-a@example.com', 'rls-user-b@example.com')"
|
||||
)
|
||||
await admin_conn.execute(
|
||||
f"DELETE FROM accounts WHERE id IN ('{ACCOUNT_A_ID}', '{ACCOUNT_B_ID}')"
|
||||
)
|
||||
await admin_conn.execute("DELETE FROM tree_tags WHERE slug = 'rls-global-tag'")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn_a():
|
||||
"""App-role connection, tenant context = Account A."""
|
||||
conn = await asyncpg.connect(
|
||||
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
|
||||
user="resolutionflow_app", password=_APP_PASSWORD,
|
||||
)
|
||||
await conn.execute(
|
||||
"SELECT set_config('app.current_account_id', $1, false)", ACCOUNT_A_ID
|
||||
)
|
||||
yield conn
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn_b():
|
||||
"""App-role connection, tenant context = Account B."""
|
||||
conn = await asyncpg.connect(
|
||||
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
|
||||
user="resolutionflow_app", password=_APP_PASSWORD,
|
||||
)
|
||||
await conn.execute(
|
||||
"SELECT set_config('app.current_account_id', $1, false)", ACCOUNT_B_ID
|
||||
)
|
||||
yield conn
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn_no_context():
|
||||
"""App-role connection with NO tenant context set."""
|
||||
conn = await asyncpg.connect(
|
||||
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
|
||||
user="resolutionflow_app", password=_APP_PASSWORD,
|
||||
)
|
||||
yield conn
|
||||
await conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0, "Account A should not see Account B trees"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trees_account_a_can_see_own_rows(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
|
||||
)
|
||||
assert len(rows) >= 1, "Account A should see its own trees"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trees_no_context_sees_no_private_trees(conn_no_context):
|
||||
rows = await conn_no_context.fetch(
|
||||
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
|
||||
)
|
||||
assert len(rows) == 0, "No-context connection should see no private trees"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tree_tags — platform visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
|
||||
rows_a = await conn_a.fetch(
|
||||
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
|
||||
)
|
||||
rows_b = await conn_b.fetch(
|
||||
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
|
||||
)
|
||||
assert len(rows_a) >= 1, "Account A should see platform tags"
|
||||
assert len(rows_b) >= 1, "Account B should see platform tags"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tree_categories — platform visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step_categories — platform visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_categories_account_a_cannot_see_account_b(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# psa_connections — tenant-only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# flow_proposals — tenant-only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
|
||||
rows = await conn_a.fetch(
|
||||
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||
)
|
||||
assert len(rows) == 0
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Integration tests for Script Template Editor permissions and share endpoint."""
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
@@ -65,6 +67,9 @@ class TestScriptTemplatePermissions:
|
||||
data = resp.json()
|
||||
assert data["name"] == "Test Template"
|
||||
assert data["created_by"] is not None
|
||||
result = await test_db.execute(select(ScriptTemplate).where(ScriptTemplate.id == PyUUID(data["id"])))
|
||||
template = result.scalar_one()
|
||||
assert template.account_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db):
|
||||
|
||||
@@ -6,14 +6,18 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.models.script_template import ScriptGeneration
|
||||
from app.models.user import User
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
async def seed_script_data(test_db):
|
||||
async def seed_script_data(test_db, test_user):
|
||||
"""Seed script categories and templates into the test database."""
|
||||
now = datetime.now(timezone.utc)
|
||||
cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
user_result = await test_db.execute(sa.select(User).where(User.email == test_user["email"]))
|
||||
user = user_result.scalar_one()
|
||||
|
||||
# Insert category
|
||||
await test_db.execute(
|
||||
@@ -142,20 +146,20 @@ async def seed_script_data(test_db):
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates (
|
||||
id, category_id, name, slug, description,
|
||||
id, category_id, account_id, name, slug, description,
|
||||
script_body, parameters_schema, default_values, validation_rules,
|
||||
tags, complexity, estimated_runtime, requires_elevation,
|
||||
requires_modules, version, is_verified, is_active, usage_count,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :category_id, :name, :slug, :description,
|
||||
:id, :category_id, :account_id, :name, :slug, :description,
|
||||
:script_body, CAST(:parameters_schema AS jsonb), '{}'::jsonb, '{}'::jsonb,
|
||||
CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation,
|
||||
'[]'::jsonb, 1, true, true, 0,
|
||||
:now, :now
|
||||
)
|
||||
"""),
|
||||
{**tmpl, "category_id": cat_id, "now": now},
|
||||
{**tmpl, "category_id": cat_id, "account_id": user.account_id, "now": now},
|
||||
)
|
||||
|
||||
await test_db.commit()
|
||||
@@ -245,7 +249,7 @@ async def test_get_template_detail_not_found(client, auth_headers):
|
||||
# ── Generate ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_script_success(client, auth_headers, seed_script_data):
|
||||
async def test_generate_script_success(client, auth_headers, seed_script_data, test_db, test_user):
|
||||
list_resp = await client.get(
|
||||
"/api/v1/scripts/templates?search=unlock",
|
||||
headers=auth_headers,
|
||||
@@ -265,6 +269,13 @@ async def test_generate_script_success(client, auth_headers, seed_script_data):
|
||||
assert "script" in data
|
||||
assert "jsmith" in data["script"]
|
||||
assert "id" in data
|
||||
generation_result = await test_db.execute(
|
||||
sa.select(ScriptGeneration).where(ScriptGeneration.id == uuid.UUID(data["id"]))
|
||||
)
|
||||
generation = generation_result.scalar_one()
|
||||
user_result = await test_db.execute(sa.select(User).where(User.email == test_user["email"]))
|
||||
user = user_result.scalar_one()
|
||||
assert generation.account_id == user.account_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
58
backend/tests/test_tenant_context.py
Normal file
58
backend/tests/test_tenant_context.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.core.tenant_context import (
|
||||
set_current_account_id,
|
||||
clear_current_account_id,
|
||||
get_current_account_id,
|
||||
)
|
||||
|
||||
|
||||
def test_contextvar_is_none_by_default():
|
||||
assert get_current_account_id() is None
|
||||
|
||||
|
||||
def test_set_and_clear():
|
||||
account_id = UUID("aaaaaaaa-0000-0000-0000-000000000001")
|
||||
token = set_current_account_id(account_id)
|
||||
assert get_current_account_id() == str(account_id)
|
||||
clear_current_account_id(token)
|
||||
assert get_current_account_id() is None
|
||||
|
||||
|
||||
def test_tasks_are_isolated():
|
||||
"""Each asyncio task has its own ContextVar value."""
|
||||
results = {}
|
||||
|
||||
async def set_in_task(name: str, value: str):
|
||||
token = set_current_account_id(UUID(value))
|
||||
await asyncio.sleep(0)
|
||||
results[name] = get_current_account_id()
|
||||
clear_current_account_id(token)
|
||||
|
||||
async def run():
|
||||
await asyncio.gather(
|
||||
set_in_task("a", "aaaaaaaa-0000-0000-0000-000000000001"),
|
||||
set_in_task("b", "bbbbbbbb-0000-0000-0000-000000000002"),
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
assert results["a"] == "aaaaaaaa-0000-0000-0000-000000000001"
|
||||
assert results["b"] == "bbbbbbbb-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_tenant_context_raises_403_when_no_account():
|
||||
from fastapi import HTTPException
|
||||
from app.api.deps import require_tenant_context
|
||||
|
||||
user = MagicMock()
|
||||
user.account_id = None
|
||||
|
||||
gen = require_tenant_context(current_user=user)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await gen.__anext__()
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "account required" in exc_info.value.detail.lower()
|
||||
578
backend/tests/test_tenant_isolation_p0.py
Normal file
578
backend/tests/test_tenant_isolation_p0.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""Phase 0 tenant-isolation tests.
|
||||
|
||||
Verifies that endpoints respect account boundaries and don't leak data
|
||||
across tenants. Each task group tests a specific endpoint fix.
|
||||
"""
|
||||
import uuid
|
||||
import datetime as dt
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _create_account_and_user(db: AsyncSession, prefix: str):
|
||||
"""Create a fresh account + engineer user. Returns (account, user, plain_password)."""
|
||||
password = "TestPass123!"
|
||||
account = Account(
|
||||
name=f"{prefix}-corp",
|
||||
display_code=uuid.uuid4().hex[:8],
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
|
||||
user = User(
|
||||
email=f"{prefix}-{uuid.uuid4().hex[:6]}@example.com",
|
||||
name=f"{prefix} user",
|
||||
password_hash=get_password_hash(password),
|
||||
is_active=True,
|
||||
account_id=account.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return account, user, password
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str, password: str) -> dict:
|
||||
"""Log in and return Authorization headers."""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
token = resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def _create_private_tree(db: AsyncSession, account: Account, user: User) -> Tree:
|
||||
"""Create a private tree owned by the given account/user."""
|
||||
tree = Tree(
|
||||
name=f"Private Tree {uuid.uuid4().hex[:6]}",
|
||||
account_id=account.id,
|
||||
author_id=user.id,
|
||||
visibility="private",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
db.add(tree)
|
||||
await db.flush()
|
||||
return tree
|
||||
|
||||
|
||||
# ── Task 3: Analytics flow endpoint ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_flow_cannot_read_other_account_tree(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""Account A cannot read flow analytics for Account B's private tree."""
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "anl-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "anl-b")
|
||||
tree_b = await _create_private_tree(test_db, acct_b, user_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/analytics/flows/{tree_b.id}",
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ── Task 4: Category tree count ───────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_category_tree_count_scoped_to_account(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""tree_count on a category must not include trees from other accounts."""
|
||||
from app.models.category import TreeCategory
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b")
|
||||
|
||||
# Shared category (account_id=None means global)
|
||||
category = TreeCategory(
|
||||
name="Shared Category",
|
||||
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
|
||||
account_id=None,
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(category)
|
||||
await test_db.flush()
|
||||
|
||||
# 3 trees for account_b under this category
|
||||
for i in range(3):
|
||||
tree = Tree(
|
||||
name=f"B Tree {i}",
|
||||
account_id=acct_b.id,
|
||||
author_id=user_b.id,
|
||||
category_id=category.id,
|
||||
visibility="team",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
test_db.add(tree)
|
||||
|
||||
# 1 tree for account_a under this category
|
||||
tree_a = Tree(
|
||||
name="A Tree",
|
||||
account_id=acct_a.id,
|
||||
author_id=user_a.id,
|
||||
category_id=category.id,
|
||||
visibility="team",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
test_db.add(tree_a)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/categories/{category.id}",
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# account_a should only see their 1 tree, not account_b's 3
|
||||
assert resp.json()["tree_count"] == 1, (
|
||||
f"Expected tree_count=1 (own trees only), got {resp.json()['tree_count']}"
|
||||
)
|
||||
|
||||
|
||||
# ── Task 5: AI session search scope ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_session_search_cannot_see_other_users_sessions(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A cannot find User B's AI sessions via the search endpoint,
|
||||
even when both users are in the same account."""
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
# Two users in the SAME account
|
||||
account = Account(name="Shared Corp", display_code=uuid.uuid4().hex[:8])
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
|
||||
password = "TestPass123!"
|
||||
user_a = User(
|
||||
email=f"user-a-{uuid.uuid4().hex[:6]}@shared.com",
|
||||
name="User A",
|
||||
password_hash=get_password_hash(password),
|
||||
is_active=True,
|
||||
account_id=account.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
user_b = User(
|
||||
email=f"user-b-{uuid.uuid4().hex[:6]}@shared.com",
|
||||
name="User B",
|
||||
password_hash=get_password_hash(password),
|
||||
is_active=True,
|
||||
account_id=account.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add_all([user_a, user_b])
|
||||
await test_db.flush()
|
||||
|
||||
# Session belonging to user_b with distinctive problem_summary
|
||||
session_b = AISession(
|
||||
user_id=user_b.id,
|
||||
account_id=account.id,
|
||||
problem_summary="CONFIDENTIAL: user_b's session",
|
||||
problem_domain="networking",
|
||||
status="resolved",
|
||||
)
|
||||
test_db.add(session_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, password)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/ai-sessions/search",
|
||||
params={"q": "CONFIDENTIAL"},
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
results = resp.json()
|
||||
ids = [r["id"] for r in results]
|
||||
assert str(session_b.id) not in ids, (
|
||||
"User A can see User B's session via search — cross-user leak within account"
|
||||
)
|
||||
|
||||
|
||||
# ── Task 6: Cross-tenant UUID audit ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tree_returns_404_not_403_for_other_account(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""Account A gets 404 (not 403) when accessing Account B's private tree."""
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-tree-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-tree-b")
|
||||
tree_b = await _create_private_tree(test_db, acct_b, user_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/trees/{tree_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-tenant tree access, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tree_returns_404_not_403_for_other_account(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""Account A gets 404 (not 403) when trying to update Account B's tree."""
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-upd-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-upd-b")
|
||||
tree_b = await _create_private_tree(test_db, acct_b, user_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.put(
|
||||
f"/api/v1/trees/{tree_b.id}",
|
||||
json={"name": "Hacked"},
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-tenant tree update, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_returns_404_not_403_for_other_user(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 (not 403) when accessing User B's session."""
|
||||
from app.models.session import Session
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-sess-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-sess-b")
|
||||
tree_b = await _create_private_tree(test_db, acct_b, user_b)
|
||||
|
||||
session_b = Session(
|
||||
tree_id=tree_b.id,
|
||||
user_id=user_b.id,
|
||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
)
|
||||
test_db.add(session_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/sessions/{session_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-user session access, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_session_get_returns_404_not_403_for_other_user(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 (not 403) when accessing User B's AI session."""
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-ais-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-ais-b")
|
||||
|
||||
ai_session_b = AISession(
|
||||
user_id=user_b.id,
|
||||
account_id=acct_b.id,
|
||||
problem_summary="Test session",
|
||||
problem_domain="networking",
|
||||
status="active",
|
||||
)
|
||||
test_db.add(ai_session_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/ai-sessions/{ai_session_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-user AI session access, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_session_retry_psa_push_requires_ownership(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A cannot retry PSA push for User B's AI session."""
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-psa-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-psa-b")
|
||||
|
||||
ai_session_b = AISession(
|
||||
user_id=user_b.id,
|
||||
account_id=acct_b.id,
|
||||
problem_summary="PSA test",
|
||||
problem_domain="networking",
|
||||
status="resolved",
|
||||
)
|
||||
test_db.add(ai_session_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{ai_session_b.id}/retry-psa-push",
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-user retry-psa-push, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_url_returns_404_not_403_for_other_account(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 (not 403) when accessing User B's upload URL."""
|
||||
from app.models.file_upload import FileUpload
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-upl-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-upl-b")
|
||||
|
||||
upload_b = FileUpload(
|
||||
account_id=acct_b.id,
|
||||
uploaded_by=user_b.id,
|
||||
filename="secret.png",
|
||||
content_type="image/png",
|
||||
size_bytes=1024,
|
||||
storage_key="test/secret.png",
|
||||
)
|
||||
test_db.add(upload_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/uploads/{upload_b.id}/url", headers=headers_a)
|
||||
assert resp.status_code in (404, 503), (
|
||||
f"Expected 404 (or 503 if storage not configured) for cross-account upload, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_revoke_returns_404_not_403_for_other_user(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 (not 403) when revoking User B's share."""
|
||||
from app.models.session import Session
|
||||
from app.models.session_share import SessionShare
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-shr-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-shr-b")
|
||||
tree_b = await _create_private_tree(test_db, acct_b, user_b)
|
||||
|
||||
session_b = Session(
|
||||
tree_id=tree_b.id,
|
||||
user_id=user_b.id,
|
||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
)
|
||||
test_db.add(session_b)
|
||||
await test_db.flush()
|
||||
|
||||
share_b = SessionShare(
|
||||
session_id=session_b.id,
|
||||
account_id=acct_b.id,
|
||||
share_token="test-token-unique-" + uuid.uuid4().hex[:8],
|
||||
share_name="Test",
|
||||
visibility="public",
|
||||
created_by=user_b.id,
|
||||
)
|
||||
test_db.add(share_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.delete(f"/api/v1/shares/{share_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-user share revoke, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ── Task 6 (continued): steps, tags, step_categories, maintenance_schedules ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_other_account_step(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 when reading a team-visibility step owned by Account B."""
|
||||
from app.models.step_library import StepLibrary
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-step-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-step-b")
|
||||
|
||||
# Create a team-visibility step owned by account B
|
||||
step_b = StepLibrary(
|
||||
title="Account B Confidential Step",
|
||||
step_type="action",
|
||||
content={"description": "secret step"},
|
||||
created_by=user_b.id,
|
||||
account_id=acct_b.id,
|
||||
visibility="team",
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(step_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/steps/{step_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-account step access, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_other_account_tag(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 when reading a tag scoped to Account B."""
|
||||
from app.models.tag import TreeTag
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-tag-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-tag-b")
|
||||
|
||||
# Create an account-scoped tag for account B
|
||||
tag_b = TreeTag(
|
||||
name=f"account-b-tag-{uuid.uuid4().hex[:6]}",
|
||||
slug=f"account-b-tag-{uuid.uuid4().hex[:6]}",
|
||||
account_id=acct_b.id,
|
||||
)
|
||||
test_db.add(tag_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/tags/{tag_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-account tag access, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_other_account_step_category(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 when reading a step category scoped to Account B."""
|
||||
from app.models.step_category import StepCategory
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-scat-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-scat-b")
|
||||
|
||||
# Create an account-scoped step category for account B
|
||||
category_b = StepCategory(
|
||||
name=f"Account B Category {uuid.uuid4().hex[:6]}",
|
||||
slug=f"account-b-cat-{uuid.uuid4().hex[:6]}",
|
||||
account_id=acct_b.id,
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(category_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/step-categories/{category_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-account step category access, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_schedule_returns_404_for_other_team(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""User A gets 404 when reading a maintenance schedule belonging to Team B's tree."""
|
||||
from app.models.team import Team
|
||||
from app.models.maintenance_schedule import MaintenanceSchedule
|
||||
|
||||
# Create two separate teams
|
||||
team_a = Team(name="Team A Corp")
|
||||
team_b = Team(name="Team B Corp")
|
||||
test_db.add_all([team_a, team_b])
|
||||
await test_db.flush()
|
||||
|
||||
# Create accounts and users, assign to respective teams
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-ms-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-ms-b")
|
||||
user_a.team_id = team_a.id
|
||||
user_b.team_id = team_b.id
|
||||
await test_db.flush()
|
||||
|
||||
# Create a maintenance tree owned by team B
|
||||
tree_b = Tree(
|
||||
name="Team B Maintenance Flow",
|
||||
account_id=acct_b.id,
|
||||
author_id=user_b.id,
|
||||
team_id=team_b.id,
|
||||
visibility="team",
|
||||
tree_type="maintenance",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
status="published",
|
||||
)
|
||||
test_db.add(tree_b)
|
||||
await test_db.flush()
|
||||
|
||||
# Create a schedule for that tree
|
||||
schedule_b = MaintenanceSchedule(
|
||||
tree_id=tree_b.id,
|
||||
created_by=user_b.id,
|
||||
cron_expression="0 2 * * 0",
|
||||
timezone="UTC",
|
||||
is_active=True,
|
||||
next_run_at=dt.datetime(2026, 12, 31, tzinfo=dt.timezone.utc),
|
||||
)
|
||||
test_db.add(schedule_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_b.id}", headers=headers_a)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 for cross-team maintenance schedule access, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_documentation_returns_404_for_other_user_session(
|
||||
client: AsyncClient, test_db: AsyncSession
|
||||
):
|
||||
"""GET /ai-sessions/{id}/documentation must return 404 (not 403) for cross-user access."""
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "doc-a")
|
||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "doc-b")
|
||||
|
||||
session_b = AISession(
|
||||
user_id=user_b.id,
|
||||
account_id=acct_b.id,
|
||||
problem_summary="B's confidential session",
|
||||
problem_domain="networking",
|
||||
status="resolved",
|
||||
)
|
||||
test_db.add(session_b)
|
||||
await test_db.commit()
|
||||
|
||||
headers_a = await _login(client, user_a.email, pass_a)
|
||||
resp = await client.get(
|
||||
f"/api/v1/ai-sessions/{session_b.id}/documentation",
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"
|
||||
@@ -447,3 +447,55 @@ class TestVisibilityFilter:
|
||||
assert "author_name" in trees[0]
|
||||
# visibility key should be present
|
||||
assert "visibility" in trees[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tree_returns_404_not_403_for_other_account_tree(
|
||||
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Account A must not learn that Account B's private tree exists."""
|
||||
from app.models.tree import Tree
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.core.security import get_password_hash
|
||||
import uuid
|
||||
|
||||
# Create a second account and user
|
||||
account_b = Account(name="Other Corp", display_code="OTH00001")
|
||||
test_db.add(account_b)
|
||||
await test_db.flush()
|
||||
|
||||
user_b = User(
|
||||
email=f"user-b-{uuid.uuid4().hex[:6]}@example.com",
|
||||
name="User B",
|
||||
password_hash=get_password_hash("TestPass123!"),
|
||||
is_active=True,
|
||||
account_id=account_b.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(user_b)
|
||||
await test_db.flush()
|
||||
|
||||
# Create a private tree belonging to account_b
|
||||
private_tree = Tree(
|
||||
name="Secret Tree",
|
||||
account_id=account_b.id,
|
||||
author_id=user_b.id,
|
||||
visibility="private",
|
||||
tree_type="troubleshooting",
|
||||
tree_structure={"id": "root", "type": "start", "children": []},
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
is_public=False,
|
||||
status="published",
|
||||
)
|
||||
test_db.add(private_tree)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{private_tree.id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404, (
|
||||
f"Expected 404 but got {response.status_code} — "
|
||||
"leaking tree existence to wrong tenant"
|
||||
)
|
||||
|
||||
363
docs/cockpit/2026-04-01-msp-assistant-harness-design.md
Normal file
363
docs/cockpit/2026-04-01-msp-assistant-harness-design.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# MSP Assistant Harness — Design Spec
|
||||
**Date:** 2026-04-01
|
||||
**Status:** Draft — pending user review
|
||||
**Source:** MSP_Assistant_Harness_Implementation_Plan.docx (v2.0, March 2026) + brainstorming session
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The `/assistant` page currently works as a generic AI chat surface with a task lane side panel and a chat sidebar for session history. It functions well but reads as "AI chat with extras" rather than an MSP engineer's operational tool.
|
||||
|
||||
The goal is to reframe the page as a **live triage cockpit** — the place where an engineer opens a ticket, works through it from intake to resolution, and closes with a structured handoff artifact. The underlying session, branching, and chat architecture is preserved. What changes is layout hierarchy, information density, field labelling, and the conclude output.
|
||||
|
||||
Scope is broader than the original docx: includes all required backend changes to support the frontend properly.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Overall Layout — Stacked Zones
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Incident Header (labelled fields, 1 row) │
|
||||
├────────────────────────┬────────────────────┤
|
||||
│ │ FlowPilot Asks │
|
||||
│ Steps Checklist │ (quick replies) │
|
||||
│ (left, ~55%) ├────────────────────┤
|
||||
│ │ What We Know │
|
||||
│ │ (evidence list) │
|
||||
├────────────────────────┴────────────────────┤ ← drag handle
|
||||
│ Conversation Log (muted, darker bg) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Compose area │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Work zone (top) and conversation log (bottom) are **drag-resizable** via a handle
|
||||
- Default split: ~55% work zone, ~45% chat
|
||||
- Existing left sidebar (session history) unchanged
|
||||
- Compose area is always pinned to bottom, spans full width
|
||||
- `workZoneHeight` persisted to `localStorage` so split survives refresh
|
||||
|
||||
### 2. Incident Header
|
||||
|
||||
Single row with explicit micro-labels above each field:
|
||||
|
||||
```
|
||||
CLIENT DEVICE CATEGORY HYPOTHESIS
|
||||
Contoso Ltd ✏ jsmith-desktop ✏ DNS / Network ✏ Corrupted DNS cache on NIC ✏
|
||||
[CW #48291] [Resolve ▾] [⋯]
|
||||
```
|
||||
|
||||
- Each field has its own `✏` icon (visible on hover) that opens an inline edit popover
|
||||
- Fields populate from: (a) intake form on session create, (b) AI-inferred updates mid-session via `triage_update`, (c) manual engineer edits via `PATCH /ai-sessions/{id}/triage`
|
||||
- PSA ticket number shown if linked; action buttons (Resolve, overflow menu) on the right
|
||||
- Empty fields show muted placeholder text — never blank
|
||||
|
||||
### 3. Work Zone — Steps + FlowPilot Asks + What We Know
|
||||
|
||||
**Left panel (~55%): ordered step checklist**
|
||||
- Steps displayed as a vertical list: `✓` completed, `→` active (blue border, white text), `○` pending
|
||||
- Active step is visually distinct
|
||||
- "Generate Script" CTA appears at the bottom when a script-generation step is active
|
||||
|
||||
**Right panel (~45%): two stacked mini-panels**
|
||||
- **FlowPilot Asks** (top, amber label): current question from AI. When `options` are provided, renders as quick-reply buttons — clicking a button submits that answer as a chat message. When no `options`, renders a compact free-text input. Panel is empty/hidden when no pending question.
|
||||
- **What We Know** (bottom, muted label): running evidence list. Each entry: `✓ confirmed` / `✗ ruled out` / `? pending`. AI appends via `triage_update.evidence_items`; engineer can manually add or edit entries.
|
||||
|
||||
### 4. Conversation Log Zone
|
||||
|
||||
- Lives below the work zone, separated by a **drag handle**
|
||||
- Background: `#13151c` (one step darker than page) — visually recedes
|
||||
- Label: "CONVERSATION LOG" in muted colour (`text-muted`)
|
||||
- Messages are compact: `you:` / `fp:` prefixes instead of full name/avatar bubbles
|
||||
- Scrolls independently
|
||||
- Not collapsible — drag handle gives control
|
||||
|
||||
### 5. Conclude / Handoff Modal (redesigned)
|
||||
|
||||
On opening "Close Case":
|
||||
|
||||
1. **Header**: "Close Case — [Client Name]" + outcome selector (Resolved / Escalated / Parked)
|
||||
2. **Structured fields** — pre-filled by streaming `/handoff-draft`, all editable:
|
||||
- **Root Cause** (short text input)
|
||||
- **Resolution** (what fixed it)
|
||||
- **Steps Taken** (list, auto-populated from step checklist)
|
||||
- **Recommendations** (next steps / preventive actions)
|
||||
3. **Output destinations** (checkboxes): Post to CW ticket note / Save to Knowledge Base / Send client summary
|
||||
4. **Confirm** button — triggers resolve/escalate/pause and passes structured fields into `ResolutionOutputGenerator`
|
||||
|
||||
The existing `SessionResolutionOutput` model and `ResolutionOutputGenerator` service are reused. The `/handoff-draft` stream starts immediately on modal open — the engineer can begin editing while fields fill in.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes Required
|
||||
|
||||
### 1. New AISession columns (Alembic migration)
|
||||
|
||||
Add to `ai_sessions` table:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `client_name` | `VARCHAR(255)` | MSP client name for incident header |
|
||||
| `asset_name` | `VARCHAR(255)` | Device / asset / user being worked on |
|
||||
| `issue_category` | `VARCHAR(100)` | Human-readable category (e.g. "DNS / Networking") |
|
||||
| `triage_hypothesis` | `TEXT` | Current working hypothesis — AI-updated + engineer-editable |
|
||||
| `evidence_items` | `JSONB` | "What We Know" list — persisted for session resume |
|
||||
|
||||
`evidence_items` format: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
|
||||
|
||||
Note: `problem_domain` (existing) is an internal classifier slug. `issue_category` is the human-readable display label for the header. Both coexist.
|
||||
|
||||
### 2. New PATCH endpoint — triage metadata
|
||||
|
||||
```
|
||||
PATCH /ai-sessions/{session_id}/triage
|
||||
Auth: require_engineer_or_admin
|
||||
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
|
||||
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
|
||||
```
|
||||
|
||||
Used when the engineer edits any header field or evidence list manually.
|
||||
|
||||
### 3. Updated schemas — TriageUpdate and QuestionItem.options
|
||||
|
||||
**New `TriageUpdate` model** (returned in chat response when AI infers session context):
|
||||
|
||||
```python
|
||||
class TriageUpdate(BaseModel):
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict] | None = None # appends to existing list
|
||||
```
|
||||
|
||||
**Updated `ChatMessageResponse`:**
|
||||
```python
|
||||
class ChatMessageResponse(BaseModel):
|
||||
# existing fields unchanged...
|
||||
triage_update: TriageUpdate | None = None
|
||||
```
|
||||
|
||||
**Updated `QuestionItem`** — add `options` for quick-reply buttons:
|
||||
```python
|
||||
class QuestionItem(BaseModel):
|
||||
text: str
|
||||
context: str = ""
|
||||
options: list[str] | None = None # quick-reply labels; null = free-text fallback
|
||||
```
|
||||
|
||||
### 4. unified_chat_service.py — triage extraction
|
||||
|
||||
After generating each AI response, run a lightweight extraction to populate `triage_update`. Implementation options (pick one during implementation):
|
||||
|
||||
- **Option A (recommended):** Embed structured extraction in the system prompt using an `[TRIAGE_UPDATE]` marker, similar to existing `[QUESTIONS]` / `[ACTIONS]` markers. AI emits the block if it has new triage signals; service parses it.
|
||||
- **Option B:** Post-response extraction pass using a fast model (`claude-haiku-4-5`) with the last 3 messages as context.
|
||||
|
||||
When `triage_update` contains non-null fields, the service auto-PATCHes the session record (so fields are persisted) AND returns `triage_update` in the response for the frontend to update the header immediately.
|
||||
|
||||
### 5. New streaming endpoint — handoff draft
|
||||
|
||||
```
|
||||
POST /ai-sessions/{session_id}/handoff-draft
|
||||
Auth: require_engineer_or_admin
|
||||
Response: StreamingResponse (text/event-stream)
|
||||
```
|
||||
|
||||
Streams a structured handoff JSON object:
|
||||
```json
|
||||
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
|
||||
```
|
||||
|
||||
Built from session context: `problem_summary`, `triage_hypothesis`, `evidence_items`, `conversation_messages` (last 20), step checklist from saved task lane state.
|
||||
|
||||
### 6. Updated conclude schemas
|
||||
|
||||
Add optional structured fields to `ResolveSessionRequest` and `EscalateSessionRequest`:
|
||||
|
||||
```python
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
```
|
||||
|
||||
Pass these into `ResolutionOutputGenerator._build_session_context()` to enrich `psa_ticket_notes` and `client_summary` outputs.
|
||||
|
||||
### 7. Session read endpoint — include new triage fields
|
||||
|
||||
Ensure the session detail response (`GET /ai-sessions/{id}`) returns the new fields so the frontend can restore header state on session resume.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes Required
|
||||
|
||||
### 1. AssistantChatPage layout refactor
|
||||
|
||||
Replace current layout (sidebar + chat column + TaskLane side panel) with the stacked cockpit layout described above.
|
||||
|
||||
**New state:**
|
||||
- `triageMeta: TriageMeta` — `{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
|
||||
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
|
||||
|
||||
**On session load / resume:** populate `triageMeta` from the session response (new fields).
|
||||
|
||||
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial update, preserve existing non-null values unless AI overwrites).
|
||||
|
||||
### 2. New component: `IncidentHeader`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/IncidentHeader.tsx
|
||||
```
|
||||
|
||||
Props: `triageMeta`, `psaTicketId`, `sessionId`, `onFieldSave(field, value)`, `onResolve`, `onOverflow`
|
||||
|
||||
- Renders labelled single-row header
|
||||
- Each field: micro-label + value + `✏` icon (visible on hover)
|
||||
- `✏` opens an `EditPopover` (small popover with text input + Save/Cancel)
|
||||
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
|
||||
- Empty field shows muted placeholder (e.g. "Unknown client")
|
||||
|
||||
### 3. Refactored component: `StepsPanel` (from TaskLane)
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/StepsPanel.tsx
|
||||
```
|
||||
|
||||
Same `activeActions` data source. Renders as ordered checklist:
|
||||
- Completed: `✓` + strikethrough label, muted
|
||||
- Active: `→` + blue left border, white text, full opacity
|
||||
- Pending: `○` + muted text
|
||||
|
||||
Script generation CTA: shown at bottom when the active step has `command` containing "script" or when AI has flagged it.
|
||||
|
||||
### 4. New component: `FlowPilotAsks`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/FlowPilotAsks.tsx
|
||||
```
|
||||
|
||||
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
|
||||
|
||||
- Shows first unanswered question (or empty/hidden state if none)
|
||||
- When `question.options` is non-null: renders as button row, clicking calls `onAnswer(option)`
|
||||
- When `question.options` is null: renders compact text input with Send button
|
||||
- `onAnswer` calls `handleSend` in the parent page with the answer text
|
||||
|
||||
### 5. New component: `WhatWeKnow`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/WhatWeKnow.tsx
|
||||
```
|
||||
|
||||
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
|
||||
|
||||
- Renders evidence list with status icons: `✓` (confirmed, green), `✗` (ruled out, red), `?` (pending, muted)
|
||||
- "+ Add finding" link at bottom opens an inline input row
|
||||
- Items are editable inline (click to edit)
|
||||
- State lives in `AssistantChatPage` as part of `triageMeta.evidence_items`, synced to backend via `PATCH /triage`
|
||||
|
||||
### 6. Drag handle — resizable split
|
||||
|
||||
Implement as a thin handle bar between work zone and conversation log. On drag:
|
||||
- Update `workZoneHeight` in state
|
||||
- Persist to `localStorage`
|
||||
|
||||
On mount: restore from `localStorage`, default to `55%` of available height.
|
||||
|
||||
### 7. Compact conversation log
|
||||
|
||||
Replace current `<ChatMessage>` bubble rendering in the log zone with a compact list:
|
||||
|
||||
```
|
||||
you: Can't resolve external DNS, internal fine
|
||||
fp: Ping passed — layer 3 OK. Run nslookup google.com.
|
||||
you: Timed out on 1.1.1.1 too.
|
||||
```
|
||||
|
||||
`ChatMessage` component still used for rich rendering (suggested flows, forks) but in a more compact variant. Full bubble rendering available on hover/expand if needed.
|
||||
|
||||
### 8. Redesigned `ConcludeSessionModal`
|
||||
|
||||
Replaces current simple textarea with the structured handoff form. On open:
|
||||
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` — streaming — populate fields as stream arrives
|
||||
2. Render outcome selector + 4 structured fields (all `<textarea>` with labels)
|
||||
3. Render output destination checkboxes
|
||||
4. On Confirm: call resolve/escalate/pause with enriched request body
|
||||
|
||||
### 9. MSP-native language pass
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| "AI Assistant" | "FlowPilot" |
|
||||
| "New Chat" | "New Case" |
|
||||
| "Messages" | "Conversation Log" |
|
||||
| "Task Lane" (panel header) | "Steps" |
|
||||
| "Conclude" | "Close Case" |
|
||||
| "Chat history" (sidebar label) | "Case History" |
|
||||
|
||||
---
|
||||
|
||||
## What This Is NOT
|
||||
|
||||
- Not a redesign of the FlowPilot session page (`/pilot`) — separate page, untouched
|
||||
- Not a rebuild of session, branching, or PSA architecture
|
||||
- Not a new data model for conversations — `conversation_messages` JSONB is unchanged
|
||||
- Not a mobile-first redesign — mobile degrades cleanly but desktop is primary
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Harness Feel Test (primary — subjective)
|
||||
- Open `/assistant`, start a new case: does the page read as an MSP triage cockpit within 3 seconds without reading labels?
|
||||
- Is the current active step obvious without scrolling through chat?
|
||||
- Do FlowPilot Asks quick-reply buttons submit answers and update the steps list?
|
||||
- Does the incident header update mid-session as the AI infers context?
|
||||
- Drag the handle, refresh: does the split restore correctly?
|
||||
|
||||
### Functional Regression
|
||||
- Free-text chat, image paste, suggested flows, forks, branching: all work
|
||||
- Session pause, resume, and handoff end-to-end: works
|
||||
- ConcludeSessionModal resolves / escalates / parks correctly
|
||||
- Handoff draft streams and pre-fills the modal fields
|
||||
- Manual header edit saves and persists across reload
|
||||
|
||||
### MSP Scenario Coverage (from docx)
|
||||
Run end-to-end: single-user endpoint issue · M365/tenant-wide issue · network/VPN outage · escalation and resume after handoff.
|
||||
|
||||
### Backend Checks
|
||||
```bash
|
||||
# Migration
|
||||
alembic upgrade head
|
||||
|
||||
# Verify new columns
|
||||
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
|
||||
|
||||
# Smoke test endpoints (with valid token)
|
||||
curl -X PATCH .../ai-sessions/{id}/triage -d '{"client_name":"Test"}'
|
||||
curl -X POST .../ai-sessions/{id}/handoff-draft # should stream JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/models/ai_session.py` | Add 5 new columns |
|
||||
| `backend/app/schemas/ai_session.py` | Add `TriageUpdate`, extend `QuestionItem`, update request/response schemas |
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | Add `PATCH /{id}/triage`, `POST /{id}/handoff-draft` |
|
||||
| `backend/app/services/unified_chat_service.py` | Extract and return `triage_update` per AI response |
|
||||
| `backend/app/services/resolution_output_generator.py` | Accept structured handoff fields in context builder |
|
||||
| `backend/alembic/versions/NNN_add_triage_fields_to_ai_sessions.py` | Sequential migration (check `ls backend/alembic/versions/ \| sort \| tail -1` for NNN) |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Full layout refactor — cockpit structure |
|
||||
| `frontend/src/components/assistant/IncidentHeader.tsx` | New component |
|
||||
| `frontend/src/components/assistant/StepsPanel.tsx` | Refactored from `TaskLane` |
|
||||
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New component |
|
||||
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New component |
|
||||
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
|
||||
| `frontend/src/api/aiSessions.ts` | Add `updateTriage()`, `getHandoffDraft()` |
|
||||
| `frontend/src/types/ai-session.ts` | Add `TriageUpdate`, `TriageMeta`, `EvidenceItem`; extend `QuestionItem` |
|
||||
609
docs/cockpit/2026-04-01-msp-assistant-harness-plan-claude.md
Normal file
609
docs/cockpit/2026-04-01-msp-assistant-harness-plan-claude.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# MSP Assistant Harness — Super Plan
|
||||
**Date:** 2026-04-01
|
||||
**Status:** Approved — ready to execute
|
||||
**Sources:** `MSP_Assistant_Harness_Implementation_Plan.docx` (v2.0) + `2026-04-01-msp-assistant-harness-design.md` (brainstorming session)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Reframe `/assistant` from a generic AI chat surface into a **live MSP triage cockpit**. An engineer arrives with an open ticket; the page immediately reads as their operational tool — not an AI chatbot that's been adapted for IT work.
|
||||
|
||||
The change is a UI and data layer reframe. The existing session, branching, PSA, and conclude architecture is preserved and extended, not rebuilt.
|
||||
|
||||
### Key Architectural Choices
|
||||
|
||||
This plan explicitly chooses:
|
||||
|
||||
- **`FlowPilot`** as the primary page/product label (not "Assistant Harness")
|
||||
- **Backend triage + handoff contracts required in v1** — not deferred to a later phase
|
||||
- **Desktop-first cockpit layout** with clean mobile degradation
|
||||
- **Explicit persisted triage fields** on the session model, not purely derived/computed header state
|
||||
- **Prompt-embedded structured extraction** (`[TRIAGE_UPDATE]` marker) as the primary AI triage path, with post-response model pass only as fallback
|
||||
- **Sidebar visual demotion** — existing sidebar stays but is visually de-emphasized so the cockpit reads as an operations surface, not a chat app
|
||||
|
||||
---
|
||||
|
||||
## What Phase 0 Resolved
|
||||
|
||||
The brainstorming session (2026-04-01) locked these decisions. They are not open questions.
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Layout structure | Stacked zones: incident header → work zone → (drag handle) → conversation log → compose |
|
||||
| Incident header style | Single row, explicit micro-labels above each field, per-field `✏` edit |
|
||||
| Work zone left panel | Ordered step checklist (✓ / → / ○) |
|
||||
| Work zone right panel | Two stacked mini-panels: FlowPilot Asks (top) + What We Know (bottom) |
|
||||
| Chat zone treatment | Drag-resizable split, compact `you:` / `fp:` prefix style, darker background |
|
||||
| Chat collapsibility | Not collapsible — drag handle gives control |
|
||||
| Scope | Includes all required backend changes, not UI-layer only |
|
||||
| Conclude modal | Fully redesigned as structured handoff artifact |
|
||||
| Page label | "FlowPilot" (not "AI Assistant") |
|
||||
| "New Chat" label | "New Case" |
|
||||
| "Conclude" label | "Close Case" |
|
||||
| Hypothesis language | "Hypothesis" (direct, not softened to "working theory") |
|
||||
| What We Know editability | Engineer-editable + AI-appended |
|
||||
| Header field population | Intake form + AI-inferred mid-session + manual engineer override |
|
||||
|
||||
---
|
||||
|
||||
## Cockpit Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [Left sidebar — Case History, unchanged] │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ INCIDENT HEADER (single row, labelled fields) │ │
|
||||
│ │ CLIENT DEVICE CATEGORY HYPOTHESIS │ │
|
||||
│ │ Contoso ✏ jsmith-04 ✏ DNS/Net ✏ Cache fail ✏ │ │
|
||||
│ │ [CW #48291][Resolve⋯]│ │
|
||||
│ ├───────────────────────┬───────────────────────────────┤ │
|
||||
│ │ │ ▸ FLOWPILOT ASKS (amber) │ │
|
||||
│ │ STEPS (~55%) │ Did nslookup time out? │ │
|
||||
│ │ ✓ Ping 8.8.8.8 │ [Time out] [Wrong IP] [Both] │ │
|
||||
│ │ → nslookup ←active ├───────────────────────────────┤ │
|
||||
│ │ ○ Flush DNS │ WHAT WE KNOW │ │
|
||||
│ │ ○ Check NIC │ ✓ Gateway reachable │ │
|
||||
│ │ │ ✗ DNS 1.1.1.1 — timeout │ │
|
||||
│ │ [⚡ Generate Script] │ ? DNS 8.8.8.8 — pending │ │
|
||||
│ ├───────────────────────┴───── ≡ drag handle ───────────┤ │
|
||||
│ │ CONVERSATION LOG (compact, darker bg) │ │
|
||||
│ │ you: Can't resolve external DNS, internal fine │ │
|
||||
│ │ fp: Ping test passed. Run nslookup google.com. │ │
|
||||
│ │ you: Timed out on 1.1.1.1 too. │ │
|
||||
│ ├───────────────────────────────────────────────────────┤ │
|
||||
│ │ Describe next finding or ask FlowPilot... [Send] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contract Decisions (Codex Readiness Review)
|
||||
|
||||
The following decisions were flagged as ambiguous by the Codex readiness review. Each is now resolved.
|
||||
|
||||
### RED — Canonical handoff artifact
|
||||
|
||||
**Decision:** `ResolutionOutputGenerator` is the single canonical generator. Everything else is transport or UI.
|
||||
|
||||
- `POST /handoff-draft` is a **preview** endpoint — streams a draft for the conclude modal UI. Does not persist. Does not generate final artifacts.
|
||||
- On confirm (resolve/escalate), the page calls the existing resolve/escalate endpoints, which trigger `ResolutionOutputGenerator.generate_all()` as today. The structured fields from the modal (`root_cause`, `steps_taken`, `recommendations`) are passed into `_build_session_context()` to enrich the final outputs.
|
||||
- `/documentation/stream` and `/status-update` remain untouched — they are separate transport channels for the same canonical outputs.
|
||||
- `/handoff-draft` is **assistant-only** in v1 (not shared with guided FlowPilot sessions on `/pilot`).
|
||||
|
||||
### RED — AI vs manual field authority
|
||||
|
||||
**Decision:** Manual edits win. AI does not overwrite manual edits.
|
||||
|
||||
| Rule | Behavior |
|
||||
|------|----------|
|
||||
| AI auto-fill | Only fills fields that are currently `null` or empty. Never overwrites a non-null value. |
|
||||
| Manual edit | Persists immediately via `PATCH /triage`. Sets the field as "manually set." |
|
||||
| AI after manual edit | AI may **suggest** an update (shown as a subtle inline prompt: "FlowPilot suggests: Contoso Corp → Contoso Ltd"), but does not auto-write. |
|
||||
| Evidence items — AI | Appends new items only. Does not modify or remove existing items. |
|
||||
| Evidence items — engineer | Full authority: add, edit status, edit text, remove. |
|
||||
|
||||
Implementation: add a `triage_manual_fields` set (stored in frontend `localStorage` per session) tracking which fields the engineer has manually edited. AI `triage_update` skips those fields unless the engineer explicitly accepts the suggestion.
|
||||
|
||||
### RED — `evidence_items` write model
|
||||
|
||||
**Decision:** Full-list replacement for all writes. Keep it simple.
|
||||
|
||||
- `PATCH /triage` sends the complete `evidence_items` array. Backend replaces the stored array.
|
||||
- AI appends: frontend receives `triage_update.evidence_items`, appends to the current local list, then PATCHes the full merged list.
|
||||
- Engineer edits: frontend modifies the local list, PATCHes the full list.
|
||||
- No partial-update or append-only semantics on the backend. The frontend is the merge authority.
|
||||
|
||||
### YELLOW — TaskLane persistence in StepsPanel
|
||||
|
||||
**Decision:** `StepsPanel` is presentation only. All persistence behavior stays in `AssistantChatPage`.
|
||||
|
||||
`TaskLane` currently owns sessionStorage drafts, debounced backend saves, and restoration. In the cockpit refactor:
|
||||
- `AssistantChatPage` lifts all persistence logic out of `TaskLane` into the page (or a custom hook like `useTaskPersistence`)
|
||||
- `StepsPanel` receives `activeActions` as a prop and renders them — no persistence responsibility
|
||||
- `TaskLane.tsx` remains in the codebase untouched (other pages may still use it)
|
||||
|
||||
### YELLOW — Quick-reply submission semantics
|
||||
|
||||
**Decision:** Quick replies are **immediate-send** controls.
|
||||
|
||||
- Clicking a quick-reply button calls `handleSend(option)` — the answer goes directly to the AI as a chat message
|
||||
- No local-only "select then send" workflow
|
||||
- The answer appears in the conversation log as a regular `you:` message
|
||||
- This is a full-stack change: prompt instructions must tell the AI to include `options` on constrained questions, parser must extract them, schema must carry them, frontend must render and submit them
|
||||
|
||||
### YELLOW — `issue_category` format
|
||||
|
||||
**Decision:** Free text in v1. No controlled taxonomy.
|
||||
|
||||
- AI infers a human-readable category string (e.g., "DNS / Networking", "Microsoft 365", "Active Directory")
|
||||
- Engineer can edit to any value via the header `✏` popover
|
||||
- Future: may introduce a taxonomy dropdown populated from session history — but not in v1
|
||||
|
||||
### YELLOW — `asset_name` when user and device differ
|
||||
|
||||
**Decision:** Free text. The engineer enters whatever is most operationally relevant.
|
||||
|
||||
- Could be a device name ("jsmith-desktop-04"), a user ("John Smith"), or both ("jsmith-desktop-04 / John Smith")
|
||||
- AI infers from conversation context — typically the entity being troubleshot
|
||||
- No enforced format in v1
|
||||
|
||||
### YELLOW — Structured conclude fields persistence
|
||||
|
||||
**Decision:** Structured conclude fields (`root_cause`, `steps_taken`, `recommendations`) are **passed through to `ResolutionOutputGenerator`** but are NOT stored as separate session columns.
|
||||
|
||||
- They arrive in the resolve/escalate request body
|
||||
- `_build_session_context()` uses them to generate richer PSA notes and client summaries
|
||||
- The generated outputs (stored in `session_resolution_outputs`) are the persisted artifacts
|
||||
- If we later need the raw structured fields, add columns then — not speculatively now
|
||||
|
||||
### Fallback — `[TRIAGE_UPDATE]` unreliability
|
||||
|
||||
**Decision:** If prompt-embedded extraction proves unreliable after testing against 5 real sessions:
|
||||
|
||||
1. **First fallback:** Post-response extraction using `claude-haiku-4-5` with last 3 messages as context. Cheap, fast, decoupled from the main prompt.
|
||||
2. **Second fallback:** Fully manual header — engineer fills in fields, AI never auto-updates. Cockpit still works; it just requires more manual input.
|
||||
|
||||
Gate: Phase 2 step 15 ("verify extraction in a live session") must pass before wiring `triage_update` into the visible header.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
These are hard rules during implementation, not suggestions.
|
||||
|
||||
1. **Do not let AI write speculative values into the header.** Every AI-inferred field must trace to ticket data or explicit conversation evidence. If the AI can't ground it, the field stays empty.
|
||||
2. **Do not redesign conclude UX until the canonical handoff source-of-truth is wired.** Phase 6 (conclude modal) depends on Phase 1 (backend) being stable.
|
||||
3. **Do not treat `TaskLane` as presentation-only until its persistence behavior has been lifted.** Extract persistence into a hook or the page before building `StepsPanel`.
|
||||
4. **Do not wire header auto-updates from `[TRIAGE_UPDATE]` until real-session reliability is tested.** Phase 2 step 15 is a gate.
|
||||
5. **Run `npx tsc -b` after every phase.** Do not batch TypeScript error fixes (lesson #92).
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No redesign of `/pilot` (FlowPilot session page) — separate page, untouched
|
||||
- No rebuild of session, branching, or PSA architecture
|
||||
- No new data model for conversations — `conversation_messages` JSONB unchanged
|
||||
- No mobile-first redesign — mobile degrades cleanly, desktop is primary
|
||||
- No generic "assistant polish" that does not tighten the harness
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### B1 — Alembic migration `071`
|
||||
|
||||
File: `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py`
|
||||
|
||||
Add to `ai_sessions`:
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `client_name` | `VARCHAR(255)` | MSP client for incident header |
|
||||
| `asset_name` | `VARCHAR(255)` | Device / user being worked on |
|
||||
| `issue_category` | `VARCHAR(100)` | Human-readable category ("DNS / Networking") |
|
||||
| `triage_hypothesis` | `TEXT` | Working hypothesis — AI-updated + editable |
|
||||
| `evidence_items` | `JSONB` | What We Know list — persisted for resume |
|
||||
|
||||
`evidence_items` schema: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
|
||||
|
||||
Note: existing `problem_domain` is an internal classifier slug and is unchanged. `issue_category` is the human-readable display label. Both coexist.
|
||||
|
||||
### B2 — Updated schemas (`backend/app/schemas/ai_session.py`)
|
||||
|
||||
**New `TriageUpdate`:**
|
||||
```python
|
||||
class TriageUpdate(BaseModel):
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict] | None = None # appends to existing list
|
||||
```
|
||||
|
||||
**Updated `ChatMessageResponse`:**
|
||||
```python
|
||||
class ChatMessageResponse(BaseModel):
|
||||
# ... existing fields unchanged ...
|
||||
triage_update: TriageUpdate | None = None
|
||||
```
|
||||
|
||||
**Updated `QuestionItem`** — add quick-reply options:
|
||||
```python
|
||||
class QuestionItem(BaseModel):
|
||||
text: str
|
||||
context: str = ""
|
||||
options: list[str] | None = None # quick-reply labels; null → free-text input
|
||||
```
|
||||
|
||||
**Updated `ResolveSessionRequest` / `EscalateSessionRequest`:**
|
||||
```python
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
```
|
||||
|
||||
### B3 — New `PATCH /ai-sessions/{id}/triage` endpoint
|
||||
|
||||
```
|
||||
PATCH /ai-sessions/{session_id}/triage
|
||||
Auth: require_engineer_or_admin
|
||||
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
|
||||
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
|
||||
```
|
||||
|
||||
Called on every manual header field edit. Partial update — only supplied fields are written.
|
||||
|
||||
### B4 — New `POST /ai-sessions/{id}/handoff-draft` endpoint
|
||||
|
||||
```
|
||||
POST /ai-sessions/{session_id}/handoff-draft
|
||||
Auth: require_engineer_or_admin
|
||||
Response: StreamingResponse (text/event-stream)
|
||||
```
|
||||
|
||||
Streams structured handoff JSON built from session context:
|
||||
```json
|
||||
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
|
||||
```
|
||||
|
||||
Uses: `problem_summary`, `triage_hypothesis`, `evidence_items`, last 20 `conversation_messages`, saved task lane state.
|
||||
|
||||
Called immediately on conclude modal open — engineer can edit while stream fills in.
|
||||
|
||||
### B5 — `unified_chat_service.py` — triage extraction
|
||||
|
||||
After each AI response, extract triage signals and return as `triage_update`.
|
||||
|
||||
**Recommended approach:** Add a `[TRIAGE_UPDATE]` structured marker to the system prompt, following the existing `[QUESTIONS]` / `[ACTIONS]` / `[FORK]` marker pattern. The AI emits the block only when it has new signal:
|
||||
|
||||
```
|
||||
[TRIAGE_UPDATE]
|
||||
client_name: Contoso Ltd
|
||||
issue_category: DNS / Networking
|
||||
triage_hypothesis: Corrupted DNS cache on NIC
|
||||
evidence_items:
|
||||
- confirmed: Gateway 192.168.1.1 reachable
|
||||
- ruled_out: DNS 1.1.1.1 — timeout
|
||||
[/TRIAGE_UPDATE]
|
||||
```
|
||||
|
||||
Service parses this, strips it from `display_content`, auto-PATCHes the session record, and returns `triage_update` in the response.
|
||||
|
||||
### B6 — `resolution_output_generator.py` — accept structured fields
|
||||
|
||||
Update `_build_session_context()` to incorporate `root_cause`, `steps_taken`, and `recommendations` when supplied, producing richer `psa_ticket_notes` and `client_summary` outputs.
|
||||
|
||||
### B7 — Session detail response — expose new triage fields
|
||||
|
||||
`GET /ai-sessions/{id}` (and the session list item) must return the 5 new fields so the frontend can restore header state on session load and resume.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### F1 — `AssistantChatPage.tsx` — cockpit layout refactor
|
||||
|
||||
Replace current layout (sidebar + chat column + TaskLane right rail) with the stacked cockpit structure.
|
||||
|
||||
**New state:**
|
||||
- `triageMeta: TriageMeta` — `{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
|
||||
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
|
||||
|
||||
**On session load / resume:** populate `triageMeta` from session response new fields.
|
||||
|
||||
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial — preserve existing non-null values unless AI explicitly overwrites).
|
||||
|
||||
**Work zone layout:** left `StepsPanel` + right column with `FlowPilotAsks` stacked above `WhatWeKnow`.
|
||||
|
||||
**Chat zone layout:** compact `ConversationLog` below drag handle, independent scroll.
|
||||
|
||||
### F2 — New `IncidentHeader.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/IncidentHeader.tsx
|
||||
```
|
||||
|
||||
Props: `triageMeta: TriageMeta`, `psaTicketId: string | null`, `sessionId: string`, `onFieldSave(field, value)`, `onResolve()`, `onOverflow()`
|
||||
|
||||
- Single-row bar with micro-labels (CLIENT / DEVICE / CATEGORY / HYPOTHESIS)
|
||||
- Each field: `✏` icon visible on hover → opens inline `EditPopover` (text input + Save/Cancel)
|
||||
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
|
||||
- Empty fields: muted placeholder ("Unknown client", "No device specified", etc.)
|
||||
- Right side: PSA ticket badge (if linked) + Resolve button + `⋯` overflow menu
|
||||
|
||||
### F3 — Refactored `StepsPanel.tsx` (from `TaskLane`)
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/StepsPanel.tsx
|
||||
```
|
||||
|
||||
Preserves all `TaskLane` data logic and persistence. Changes rendering only:
|
||||
|
||||
| State | Icon | Style |
|
||||
|-------|------|-------|
|
||||
| Completed | `✓` | Strikethrough, muted, green icon |
|
||||
| Active | `→` | Blue left border, white text, full opacity |
|
||||
| Pending | `○` | Muted text |
|
||||
|
||||
Script generation CTA: shown at bottom when active step `command` references "script" or AI has flagged it.
|
||||
|
||||
`TaskLane.tsx` can remain for now (no renames required in this phase) — `StepsPanel` is a new component that consumes the same `activeActions` prop.
|
||||
|
||||
### F4 — New `FlowPilotAsks.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/FlowPilotAsks.tsx
|
||||
```
|
||||
|
||||
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
|
||||
|
||||
- Renders first unanswered question
|
||||
- `question.options` non-null → button row; clicking calls `onAnswer(option)`
|
||||
- `question.options` null → compact text input + Send
|
||||
- `onAnswer` calls parent's `handleSend` with the answer string
|
||||
- Hidden entirely when `questions` is empty
|
||||
|
||||
### F5 — New `WhatWeKnow.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/WhatWeKnow.tsx
|
||||
```
|
||||
|
||||
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
|
||||
|
||||
- Evidence list: `✓` confirmed (green) / `✗` ruled out (red) / `?` pending (muted)
|
||||
- "+ Add finding" inline entry at bottom
|
||||
- Click any item to edit inline
|
||||
- State lives in `AssistantChatPage` (`triageMeta.evidence_items`), synced to backend via `PATCH /triage`
|
||||
|
||||
### F6 — Drag-resizable split
|
||||
|
||||
Thin handle bar between work zone and conversation log. On drag: update `workZoneHeight` in state, persist to `localStorage`. On mount: restore, default `55%`.
|
||||
|
||||
### F7 — Compact `ConversationLog` rendering
|
||||
|
||||
Replace current full `<ChatMessage>` bubbles in the log zone with a compact list: `you: ...` / `fp: ...` prefix style, tighter line height, no avatars. `ChatMessage` can still be used for rich content (forks, suggested flows) in a compact variant. Individual messages should support click-to-expand for full rendering when the engineer needs to re-read a longer response or review a suggested flow.
|
||||
|
||||
### F8 — Redesigned `ConcludeSessionModal.tsx`
|
||||
|
||||
On open:
|
||||
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` (streaming) — fields fill in as stream arrives
|
||||
2. Render: outcome selector (Resolved / Escalated / Parked)
|
||||
3. Render 4 structured editable fields: Root Cause, Resolution, Steps Taken, Recommendations
|
||||
4. Render output destination checkboxes: Post to CW note / Save to KB / Send client summary
|
||||
5. Confirm → call resolve/escalate/pause with enriched request body including structured fields
|
||||
|
||||
### F9 — Sidebar visual demotion
|
||||
|
||||
The existing `ChatSidebar` stays functionally unchanged but should be visually softened so the cockpit — not the session list — reads as the primary surface. Specific changes:
|
||||
|
||||
- Reduce sidebar background contrast (use `bg-sidebar` or one step darker)
|
||||
- Reduce sidebar header prominence (smaller label, no bold "Chat History" heading)
|
||||
- Rename "Chat History" → "Case History" (part of language pass)
|
||||
- Default sidebar to collapsed state on first cockpit load (existing collapse toggle + `localStorage`)
|
||||
|
||||
### F10 — MSP-native language pass
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| "AI Assistant" (page title, meta) | "FlowPilot" |
|
||||
| "New Chat" | "New Case" |
|
||||
| "Messages" | "Conversation Log" |
|
||||
| "Task Lane" (panel label) | "Steps" |
|
||||
| "Conclude" | "Close Case" |
|
||||
| "Chat history" (sidebar label) | "Case History" |
|
||||
| Compose placeholder | "Describe finding, paste log output, or ask FlowPilot..." |
|
||||
|
||||
### F11 — New API methods (`aiSessions.ts`)
|
||||
|
||||
```typescript
|
||||
updateTriage(sessionId: string, fields: Partial<TriageMeta>): Promise<TriageMeta>
|
||||
getHandoffDraft(sessionId: string): AsyncGenerator<HandoffDraftChunk>
|
||||
```
|
||||
|
||||
### F12 — New types (`types/ai-session.ts`)
|
||||
|
||||
```typescript
|
||||
interface TriageMeta {
|
||||
client_name: string | null
|
||||
asset_name: string | null
|
||||
issue_category: string | null
|
||||
triage_hypothesis: string | null
|
||||
evidence_items: EvidenceItem[]
|
||||
}
|
||||
|
||||
interface EvidenceItem {
|
||||
text: string
|
||||
status: 'confirmed' | 'ruled_out' | 'pending'
|
||||
}
|
||||
|
||||
interface TriageUpdate extends Partial<TriageMeta> {}
|
||||
|
||||
// Extend existing:
|
||||
interface QuestionItem {
|
||||
text: string
|
||||
context: string
|
||||
options?: string[] // new
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phased Execution Order
|
||||
|
||||
### Phase 1 — Backend Foundation
|
||||
|
||||
> Lock backend schema and API changes first so the cockpit can be built against stable session contracts.
|
||||
|
||||
1. Write migration `071` — add 5 columns to `ai_sessions`
|
||||
2. Run `alembic upgrade head`, verify columns
|
||||
3. Update `AISession` model with new mapped columns
|
||||
4. Add `TriageUpdate` schema, extend `QuestionItem`, extend `ChatMessageResponse`
|
||||
5. Extend `ResolveSessionRequest` / `EscalateSessionRequest` with structured fields
|
||||
6. Add `PATCH /{id}/triage` endpoint
|
||||
7. Add `POST /{id}/handoff-draft` streaming endpoint
|
||||
8. Update `GET /ai-sessions/{id}` response to include new triage fields
|
||||
9. Update `resolution_output_generator._build_session_context()` to use structured fields
|
||||
10. Run backend tests — `pytest --override-ini="addopts="`
|
||||
|
||||
### Phase 2 — Triage Extraction (AI layer)
|
||||
11. Add `[TRIAGE_UPDATE]` marker to `unified_chat_service.py` system prompt
|
||||
12. Implement `_parse_triage_update_marker()` in the service (follow existing `_parse_questions_marker` / `_parse_actions_marker` pattern)
|
||||
13. Auto-PATCH session on non-null `triage_update` (respect manual-edit authority: skip fields in `triage_manual_fields`)
|
||||
14. Add `options` generation instructions to `[QUESTIONS]` system prompt section
|
||||
15. **GATE:** Verify extraction in 5 real sessions. If `[TRIAGE_UPDATE]` is emitted reliably (≥4/5), proceed. Otherwise switch to Haiku post-response fallback before wiring into the header.
|
||||
|
||||
### Phase 3 — New Frontend Types + API
|
||||
16. Add `TriageMeta`, `EvidenceItem`, `TriageUpdate` to `types/ai-session.ts`
|
||||
17. Extend `QuestionItem` type
|
||||
18. Add `updateTriage()` and `getHandoffDraft()` to `aiSessions.ts`
|
||||
|
||||
### Phase 4 — New Work Zone Components
|
||||
19. Extract `TaskLane` persistence logic into `useTaskPersistence` hook (sessionStorage drafts, debounced saves, restoration) — prerequisite for StepsPanel
|
||||
20. Build `IncidentHeader.tsx` with `EditPopover`
|
||||
21. Build `StepsPanel.tsx` (presentation only — receives props from hook)
|
||||
22. Build `FlowPilotAsks.tsx`
|
||||
23. Build `WhatWeKnow.tsx`
|
||||
|
||||
### Phase 5 — Page Layout Refactor
|
||||
24. Refactor `AssistantChatPage.tsx` — implement stacked cockpit layout
|
||||
25. Wire `triageMeta` state, session load population, `triage_update` merge (with `triage_manual_fields` guard)
|
||||
26. Implement drag-resizable split with `localStorage` persistence
|
||||
27. Compact `ConversationLog` rendering (with click-to-expand for long messages)
|
||||
|
||||
### Phase 6 — Handoff Modal + Language Pass + Sidebar
|
||||
28. Redesign `ConcludeSessionModal.tsx` — structured handoff form (calls `/handoff-draft` for preview, confirms via existing resolve/escalate endpoints which trigger `ResolutionOutputGenerator`)
|
||||
29. Sidebar visual demotion — background, label prominence, default-collapsed
|
||||
30. MSP-native language pass across all assistant components
|
||||
31. Update `<PageMeta>` title
|
||||
|
||||
### Phase 7 — QA + Hardening
|
||||
32. `npx tsc -b` — fix any TypeScript errors
|
||||
33. `npm run build` — production build clean
|
||||
34. Functional regression: all chat flows, session switching, conclude/resume
|
||||
35. Harness feel test: cockpit within 3 seconds?
|
||||
36. Mobile viewport check
|
||||
37. Stress test: 50+ messages, 10+ steps, long outputs
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| `[TRIAGE_UPDATE]` marker extraction is unreliable — AI doesn't emit it consistently | Gate Phase 2 on a pass/fail test with 5 real sessions before wiring it to the header. Fall back to Option B (post-response Haiku pass) if needed. |
|
||||
| Header fields feel fabricated — AI guesses wrong client or hypothesis | Show confidence-aware placeholder copy ("FlowPilot is building context…") until a field has real data. Never invent. |
|
||||
| Task lane visual promotion breaks established chat patterns | Keep all send/respond behavior intact. Change hierarchy only. Verify every task-lane state transition manually. |
|
||||
| Handoff modal exposes weak underlying summaries | Reuse existing `ResolutionOutputGenerator` output where possible. Add guardrail copy for empty fields. |
|
||||
| Mobile loses compose or step access | Test responsive layout as a first-class deliverable in Phase 7, not a final sweep. Enforce scroll isolation between all zones. |
|
||||
| `tsc -b` errors after component refactor | Run `npx tsc -b` after every phase. Trace unused imports/props immediately — don't batch (lesson #92). |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Harness Feel (primary, subjective)
|
||||
- Does the page read as an MSP triage cockpit within 3 seconds on first load?
|
||||
- Is the active step obvious without reading chat?
|
||||
- Do FlowPilot Asks quick-reply buttons work and update the step list?
|
||||
- Does the incident header update mid-session as AI learns context?
|
||||
- Drag handle, refresh — does split restore?
|
||||
- Does the conclude modal look like a case handoff or a chat closure?
|
||||
|
||||
### Functional Regression
|
||||
- New session (no PSA) — header degrades gracefully
|
||||
- New session (with CW ticket) — header populates from ticket data
|
||||
- Send message → `triage_update` updates header
|
||||
- Click quick-reply button → answer submitted, step advances
|
||||
- Add finding to What We Know → persisted via PATCH
|
||||
- Edit header field via `✏` → saved and survives refresh
|
||||
- Conclude as Resolved → handoff draft fills modal → post to CW note
|
||||
- Conclude as Escalated → same
|
||||
- Pause and resume → triage header restores from saved session fields
|
||||
- Session switching (currentChatRef guard) — no stale state
|
||||
- Image paste, forks, suggested flows — all still work
|
||||
|
||||
### MSP Scenarios (from docx)
|
||||
1. Single-user endpoint issue (basic triage flow, script generation)
|
||||
2. M365 / tenant-wide issue (multi-user context, issue category)
|
||||
3. Network / VPN outage (asset targeting, hypothesis tracking)
|
||||
4. Escalation and resume (session persistence, structured handoff)
|
||||
|
||||
### Edge Cases
|
||||
- 50+ messages — layout hierarchy stays intact
|
||||
- 10+ steps — step panel scrolls, compose remains accessible
|
||||
- Long issue titles / hypothesis text — header truncates gracefully
|
||||
- Missing PSA context — placeholder copy, not blank fields
|
||||
- Narrow mobile viewport — all zones reachable
|
||||
|
||||
### Backend Checks
|
||||
```bash
|
||||
# Migration
|
||||
alembic upgrade head
|
||||
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
|
||||
|
||||
# Triage PATCH
|
||||
curl -X PATCH http://localhost:8000/ai-sessions/{id}/triage \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"client_name":"Test Client","triage_hypothesis":"Cache corruption"}'
|
||||
|
||||
# Handoff draft stream
|
||||
curl -X POST http://localhost:8000/ai-sessions/{id}/handoff-draft \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Desktop is the primary target; mobile must remain usable but does not drive the layout.
|
||||
- `/assistant` remains the chat-session cockpit; `/pilot` is out of scope.
|
||||
- New triage fields are **additive** — they do not replace `problem_summary`, `problem_domain`, `ticket_data`, or `conversation_messages`.
|
||||
- `issue_category` is the operator-facing display field; `problem_domain` remains the internal classifier. Both coexist.
|
||||
- `evidence_items` is editable by both AI and engineer; engineer edits persist through the triage PATCH endpoint.
|
||||
- PSA context is optional — every triage header field must degrade gracefully when PSA is absent or session is free-text-only.
|
||||
- The existing `TaskLane.tsx` component remains in the codebase — `StepsPanel` is a new component that consumes the same props with different rendering. No risky renames during this work.
|
||||
|
||||
---
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py` | New migration |
|
||||
| `backend/app/models/ai_session.py` | Add 5 new mapped columns |
|
||||
| `backend/app/schemas/ai_session.py` | `TriageUpdate`, `QuestionItem.options`, extended request/response schemas |
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | `PATCH /triage`, `POST /handoff-draft` |
|
||||
| `backend/app/services/unified_chat_service.py` | `[TRIAGE_UPDATE]` marker extraction, auto-PATCH |
|
||||
| `backend/app/services/resolution_output_generator.py` | Structured fields in context builder |
|
||||
| `frontend/src/types/ai-session.ts` | `TriageMeta`, `EvidenceItem`, `TriageUpdate`; extend `QuestionItem` |
|
||||
| `frontend/src/api/aiSessions.ts` | `updateTriage()`, `getHandoffDraft()` |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Full cockpit layout refactor |
|
||||
| `frontend/src/components/assistant/IncidentHeader.tsx` | New |
|
||||
| `frontend/src/components/assistant/StepsPanel.tsx` | New (from TaskLane logic) |
|
||||
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New |
|
||||
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New |
|
||||
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
|
||||
1136
docs/superpowers/plans/2026-04-09-tenant-isolation-phase-0.md
Normal file
1136
docs/superpowers/plans/2026-04-09-tenant-isolation-phase-0.md
Normal file
File diff suppressed because it is too large
Load Diff
2527
docs/superpowers/plans/2026-04-09-tenant-isolation-phase-1.md
Normal file
2527
docs/superpowers/plans/2026-04-09-tenant-isolation-phase-1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,655 @@
|
||||
# Tenant Data Isolation — Design Spec
|
||||
|
||||
> **Date:** 2026-04-09
|
||||
> **Status:** Approved — ready for implementation planning
|
||||
> **Approach:** PostgreSQL RLS as safety net; application-layer filtering as primary enforcement
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ResolutionFlow is a multi-tenant SaaS. The tenant boundary is `account_id` (UUID foreign key to `accounts.id`). Two accounts must be completely isolated at every layer: one client must never be able to see, access, query, or receive data belonging to another client — even if there is a bug in application code.
|
||||
|
||||
This spec establishes the complete foundation that must be in place before further feature development proceeds.
|
||||
|
||||
### Design Principle
|
||||
|
||||
**Application code is the primary enforcement mechanism. PostgreSQL Row-Level Security (RLS) is the safety net.**
|
||||
|
||||
Both layers must always be present. Developers must never rely on RLS to do filtering that application code should be doing. A missing `tenant_filter()` in application code is a code review failure even if RLS would have caught it.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- Tenant boundary: `account_id` (not `team_id` — that column is legacy)
|
||||
- Isolation today: 100% application-layer, manual per-endpoint `.where(account_id == ...)`
|
||||
- No RLS, no DB session variables, no middleware tenant injection
|
||||
- Nullable `account_id` on ~8 core models (User, Tree, TreeCategory, etc.)
|
||||
- ~20 tables with no direct `account_id` (scoped only through join chains)
|
||||
- 4 models with `team_id` only, no `account_id`: TargetList, ScriptBuilderSession, ScriptTemplate, ScriptGeneration
|
||||
- Known existing gaps: see Section 4
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Schema Changes
|
||||
|
||||
### 1a. Denormalize `account_id` onto all tenant-relevant tables
|
||||
|
||||
Add `account_id UUID NOT NULL` (with FK to `accounts.id` and index) to each of the following tables that currently lack it:
|
||||
|
||||
| Table | Backfill path |
|
||||
|---|---|
|
||||
| `sessions` | `sessions.user_id → users.account_id` |
|
||||
| `attachments` | `attachments.session_id → sessions.user_id → users.account_id` |
|
||||
| `session_supporting_data` | `session_supporting_data.session_id → sessions.user_id → users.account_id` |
|
||||
| `audit_logs` | `audit_logs.user_id → users.account_id` |
|
||||
| `maintenance_schedules` | `maintenance_schedules.tree_id → trees.account_id` |
|
||||
| `user_folders` | `user_folders.user_id → users.account_id` |
|
||||
| `user_pinned_trees` | `user_pinned_trees.user_id → users.account_id` |
|
||||
| `session_branches` | `session_branches.ai_session_id → ai_sessions.account_id` (verify column name — may be `session_id`) |
|
||||
| `session_handoffs` | `session_handoffs.ai_session_id → ai_sessions.account_id` (verify column name — may be `session_id`) |
|
||||
| `session_resolution_outputs` | `session_resolution_outputs.session_id → sessions.user_id → users.account_id` |
|
||||
| `fork_points` | `fork_points.session_id → ai_sessions.account_id` |
|
||||
| `ai_session_steps` | `ai_session_steps.session_id → ai_sessions.account_id` |
|
||||
| `ai_suggestions` | `ai_suggestions.tree_id → trees.account_id` |
|
||||
| `step_ratings` | `step_ratings.user_id → users.account_id` (backfill from rater, not the step) |
|
||||
| `step_usage_logs` | `step_usage_logs.user_id → users.account_id` (backfill from user, not the step) |
|
||||
| `psa_post_logs` | `psa_post_logs.psa_connection_id → psa_connections.account_id` (psa_connections already has account_id NOT NULL) |
|
||||
| `psa_member_mappings` | `psa_member_mappings.psa_connection_id → psa_connections.account_id` |
|
||||
| `notification_logs` | `notification_logs.notification_config_id → notification_configs.account_id` |
|
||||
|
||||
**Deferred:** `tree_shares` — backfill strategy and RLS policy depend on whether sharing is intra-tenant only or cross-tenant. Must be resolved before RLS is enabled on this table. See Section 7 (Open Questions).
|
||||
|
||||
### 1b. Make existing nullable `account_id` columns NOT NULL
|
||||
|
||||
These tables have `account_id` already but it is nullable:
|
||||
|
||||
- `users`
|
||||
- `trees`
|
||||
- `tree_categories`
|
||||
- `tree_tags`
|
||||
- `step_categories`
|
||||
- `step_library`
|
||||
- `tree_embeddings`
|
||||
- `feedback`
|
||||
|
||||
Action: Backfill any NULL rows (assign to correct account or delete if orphaned test/seed data), then `ALTER COLUMN account_id SET NOT NULL`.
|
||||
|
||||
### 1c. Global / template content
|
||||
|
||||
Move global content out of tenant tables into dedicated tables with no RLS:
|
||||
|
||||
- **`template_trees`** — default and publicly visible trees (currently `is_default=True` or `visibility='public'` in `trees`). All tenants can read; no RLS.
|
||||
- **`platform_steps`** — public steps from step library (currently `visibility='public'` in `step_library`). All tenants can read; no RLS.
|
||||
- Tables already global (no change needed): `script_categories`, `platform_settings`, `plan_limits`, `feature_flags`, `plan_feature_defaults`.
|
||||
|
||||
### 1d. Migration sequence (per table)
|
||||
|
||||
Every table that gains `account_id` follows this exact sequence. Each step must succeed before the next begins:
|
||||
|
||||
1. `ADD COLUMN account_id UUID` (nullable)
|
||||
2. Backfill via `UPDATE ... JOIN ...`
|
||||
3. `SELECT COUNT(*) WHERE account_id IS NULL` — must be zero before proceeding
|
||||
4. `ALTER COLUMN account_id SET NOT NULL`
|
||||
5. `CREATE INDEX ON <table>(account_id)`
|
||||
6. Enable RLS (in Phase 3, not here)
|
||||
|
||||
Any migration that cannot reach step 3 (zero NULLs) must roll back completely. No partial state.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: PostgreSQL Roles & RLS Infrastructure
|
||||
|
||||
### 2a. Database roles
|
||||
|
||||
Two PostgreSQL roles:
|
||||
|
||||
**`resolutionflow_app`**
|
||||
- Used by all application requests
|
||||
- Subject to all RLS policies
|
||||
- Standard table privileges: `SELECT, INSERT, UPDATE, DELETE` on all tenant tables
|
||||
|
||||
**`resolutionflow_admin`**
|
||||
- Used by: Alembic migrations, seed scripts, super admin API endpoints, scheduled background jobs requiring cross-tenant access
|
||||
- Has `BYPASSRLS` attribute — not subject to RLS policies
|
||||
- Same table privileges as `resolutionflow_app`
|
||||
- Connection string exposed as `DATABASE_ADMIN_URL` env var
|
||||
|
||||
The current `postgres` superuser is replaced in the application by these two roles. The connection string in `DATABASE_URL` transitions to `resolutionflow_app`. Alembic uses `DATABASE_URL_SYNC` pointing to `resolutionflow_admin`.
|
||||
|
||||
### 2b. Per-request tenant context injection
|
||||
|
||||
Every request that passes through `get_db()` must execute, inside a transaction boundary:
|
||||
|
||||
```sql
|
||||
SET LOCAL app.current_account_id = '<account_uuid>';
|
||||
```
|
||||
|
||||
`SET LOCAL` is transaction-scoped — it resets automatically when the transaction ends. No cleanup needed. This is implemented in a modified `get_db()` dependency that receives `current_user` and executes the SET before yielding the session.
|
||||
|
||||
**Fail-closed behavior:** If `app.current_account_id` is not set (e.g., a bug where `SET LOCAL` was skipped), `current_setting('app.current_account_id', false)::uuid` returns NULL. `NULL = NULL` is false in SQL — the RLS policy matches zero rows. This is the correct fail-closed behavior.
|
||||
|
||||
Public endpoints (no authenticated user) do not call `SET LOCAL`. RLS matches zero rows for all tenant tables. This is correct.
|
||||
|
||||
### 2c. RLS policy pattern
|
||||
|
||||
Every tenant table (from Section 1 + all existing tables with account_id) gets:
|
||||
|
||||
```sql
|
||||
ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE <table> FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT
|
||||
CREATE POLICY tenant_select ON <table> FOR SELECT
|
||||
USING (account_id = current_setting('app.current_account_id', false)::uuid);
|
||||
|
||||
-- INSERT
|
||||
CREATE POLICY tenant_insert ON <table> FOR INSERT
|
||||
WITH CHECK (account_id = current_setting('app.current_account_id', false)::uuid);
|
||||
|
||||
-- UPDATE
|
||||
CREATE POLICY tenant_update ON <table> FOR UPDATE
|
||||
USING (account_id = current_setting('app.current_account_id', false)::uuid)
|
||||
WITH CHECK (account_id = current_setting('app.current_account_id', false)::uuid);
|
||||
|
||||
-- DELETE
|
||||
CREATE POLICY tenant_delete ON <table> FOR DELETE
|
||||
USING (account_id = current_setting('app.current_account_id', false)::uuid);
|
||||
```
|
||||
|
||||
**`FORCE ROW LEVEL SECURITY`** ensures the table owner is also subject to policies. The `resolutionflow_admin` role bypasses via its `BYPASSRLS` attribute, not via ownership.
|
||||
|
||||
**`audit_logs` exception:** SELECT policy only. No `WITH CHECK` on INSERT (app inserts audit logs freely). No UPDATE or DELETE policies ever. These constraints are permanent and must be documented in the migration comment.
|
||||
|
||||
**Global tables** (`platform_settings`, `plan_limits`, `feature_flags`, `plan_feature_defaults`, `template_trees`, `platform_steps`): No RLS.
|
||||
|
||||
### 2d. Connection pool reuse safety
|
||||
|
||||
The `SET LOCAL` approach is transaction-scoped. A connection pool reuse test (see Section 6) must verify that a connection returned to the pool after tenant A's request does not carry tenant A's `account_id` into tenant B's request. This is guaranteed by `SET LOCAL` (resets on transaction end) but must be explicitly verified.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Application-Layer Enforcement Patterns
|
||||
|
||||
### 3a. `tenant_filter()` helper
|
||||
|
||||
Add to `backend/app/core/filters.py`:
|
||||
|
||||
```python
|
||||
def tenant_filter(model, account_id: uuid.UUID):
|
||||
"""
|
||||
Primary app-layer tenant filter.
|
||||
MUST be used in every SELECT/UPDATE/DELETE on tenant tables.
|
||||
RLS is the safety net — this is the primary enforcement.
|
||||
"""
|
||||
return model.account_id == account_id
|
||||
```
|
||||
|
||||
All existing filter helpers (`build_tree_access_filter`, `build_step_visibility_filter`) must internally call `tenant_filter()` as their base constraint.
|
||||
|
||||
### 3b. Fetch-and-verify pattern
|
||||
|
||||
For ID-based lookups, filter by **both** `id` AND `account_id` in the query — not fetch-then-check:
|
||||
|
||||
```python
|
||||
# Correct
|
||||
stmt = select(Tree).where(
|
||||
Tree.id == tree_id,
|
||||
tenant_filter(Tree, current_user.account_id)
|
||||
)
|
||||
tree = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
# Prohibited — fetch first, then check
|
||||
tree = await db.get(Tree, tree_id)
|
||||
if tree.account_id != current_user.account_id:
|
||||
raise HTTPException(status_code=403) # Reveals existence — also wrong
|
||||
```
|
||||
|
||||
Endpoints must return **404, not 403**, for cross-tenant ID lookups. Never confirm that a resource exists.
|
||||
|
||||
### 3c. `get_tenant_context` dependency
|
||||
|
||||
Add to `backend/app/api/deps.py`:
|
||||
|
||||
```python
|
||||
async def get_tenant_context(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> uuid.UUID:
|
||||
"""
|
||||
Returns the current user's account_id.
|
||||
Raises 403 if the user has no account association.
|
||||
Inject this instead of accessing current_user.account_id directly.
|
||||
"""
|
||||
if current_user.account_id is None:
|
||||
raise HTTPException(status_code=403, detail="User not associated with any account")
|
||||
return current_user.account_id
|
||||
```
|
||||
|
||||
### 3d. Insert pattern
|
||||
|
||||
All inserts on tenant tables must explicitly set `account_id`. RLS `WITH CHECK` rejects inserts where `account_id` doesn't match the session variable, but application code must also set it:
|
||||
|
||||
```python
|
||||
new_record = Tree(
|
||||
account_id=tenant_account_id, # Required — never omit
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 3e. Code review checklist rule
|
||||
|
||||
Every PR that touches a tenant table must be verified against:
|
||||
|
||||
1. Every SELECT/UPDATE/DELETE includes `tenant_filter()` or a wrapper that calls it
|
||||
2. ID lookups filter by both `id` and `account_id` in the same query
|
||||
3. Inserts explicitly set `account_id`
|
||||
4. 404 (not 403) returned for cross-tenant ID lookups
|
||||
5. A cross-tenant isolation test is included (Phase 2 onwards)
|
||||
|
||||
### 3f. CI grep check
|
||||
|
||||
A grep-based CI check is active from the end of Phase 0 on all PRs. It flags:
|
||||
- Queries on known tenant tables that don't include `tenant_filter` or `account_id` as a filter term
|
||||
- Initial implementation: warn (not block) to allow calibration; switch to block after 2 weeks of false-positive tuning
|
||||
|
||||
Pattern to be defined during Phase 0 implementation.
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Existing Gap Fixes
|
||||
|
||||
### Immediate Hotfix (ships before Phase 0)
|
||||
|
||||
**CRITICAL: Copilot tree access bypass**
|
||||
- **Files:** `backend/app/api/endpoints/copilot.py`, `backend/app/services/copilot_service.py`
|
||||
- **Issue:** `start_conversation()` loads a tree by UUID without verifying the requesting user has access. An attacker who knows a tree UUID from another account can extract its full structure, node names, and descriptions via the AI system prompt.
|
||||
- **Fix:** Add `can_access_tree(current_user, tree)` check in the endpoint after loading the tree. Also add `tenant_filter(Tree, account_id)` to the tree query in `copilot_service.py`. Raise 404 (not 403) if the tree is not found or not accessible.
|
||||
- Ships as an independent hotfix PR, merged immediately.
|
||||
|
||||
### Phase 0 Fixes
|
||||
|
||||
**LOW: Analytics flow endpoint** (`backend/app/api/endpoints/analytics.py`)
|
||||
- `GET /analytics/flows/{tree_id}` returns analytics for any tree by UUID with no access check
|
||||
- Fix: Add `tenant_filter(Tree, current_user.account_id)` to the tree fetch query. 404 if not found.
|
||||
|
||||
**LOW: Category tree count** (`backend/app/api/endpoints/categories.py`)
|
||||
- Tree count per category includes trees from all accounts
|
||||
- Fix: Add `tenant_filter(Tree, current_user.account_id)` to the count subquery
|
||||
|
||||
**LOW: AI session scope inconsistency** (`backend/app/api/endpoints/ai_sessions.py`)
|
||||
- List endpoint is user-scoped (`user_id == current_user.id`); search endpoint uses `OR(user_id, account_id)` exposing other users' session summaries within the same account
|
||||
- Sessions are user-scoped only. Cross-user access permitted only via explicit escalation or session sharing
|
||||
- Fix: Restrict search endpoint to `user_id == current_user.id`. List and search must behave consistently.
|
||||
|
||||
**Phase 0 UUID endpoint audit**
|
||||
- Systematically review every endpoint with a `{resource_id}` URL parameter
|
||||
- For each: verify that the ID lookup either (a) filters by `id AND account_id` in the query, or (b) calls `can_access_<resource>(current_user, resource)` on the fetched object
|
||||
- Document every instance found, classify by severity, fix all before Phase 0 closes
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Legacy `team_id` Migration
|
||||
|
||||
### 5a. TargetList — audit-gated
|
||||
|
||||
Before any migration work on `TargetList`:
|
||||
|
||||
1. Run a full codebase reference audit: grep for all references to `TargetList`, `target_list`, `target-list`
|
||||
2. Query the production and staging databases for row count
|
||||
3. Decision tree:
|
||||
- Zero code references AND zero rows → drop the table entirely
|
||||
- Zero rows but code references exist → deprecate code, then drop
|
||||
- Rows exist → migrate (see sequence below)
|
||||
4. Migration only proceeds after audit result is documented and approved
|
||||
|
||||
If migration proceeds:
|
||||
- Backfill path: `team_id → teams → users WHERE is_team_admin → account_id`
|
||||
- If any row cannot be backfilled to a valid `account_id` → **full rollback**, no exceptions, no manual review queues
|
||||
|
||||
### 5b. Pre-migration: teams-to-accounts orphan check
|
||||
|
||||
Before any backfill using `team_id → account_id` chains, run:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM teams t
|
||||
LEFT JOIN users u ON u.team_id = t.id AND u.account_id IS NOT NULL
|
||||
WHERE u.id IS NULL;
|
||||
```
|
||||
|
||||
Count must be zero before any backfill proceeds. Report and resolve orphaned teams first.
|
||||
|
||||
### 5c. Approved migrations (no audit gate)
|
||||
|
||||
**ScriptBuilderSession:** Add `account_id`, backfill from `user_id → users.account_id`, set NOT NULL.
|
||||
|
||||
**ScriptTemplate:** Add `account_id`, backfill from `created_by_id → users.account_id`, set NOT NULL.
|
||||
|
||||
**ScriptGeneration:** Add `account_id`, backfill from `user_id → users.account_id`, set NOT NULL.
|
||||
|
||||
### 5d. team_id cleanup
|
||||
|
||||
Do NOT drop `team_id` columns during migration. Keep until all application code is updated to use `account_id` exclusively. Drop `team_id` columns in a later cleanup migration after verification.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Testing Strategy
|
||||
|
||||
### Phase 1: RLS validation tests
|
||||
|
||||
**File:** `backend/tests/test_rls_isolation.py`
|
||||
|
||||
This test suite validates RLS policies at the database layer, independent of any application code. It connects to the test database using the `resolutionflow_app` role.
|
||||
|
||||
**Setup fixture:**
|
||||
- Creates two accounts (`account_a`, `account_b`) with seed rows in all tenant tables
|
||||
- Creates an async DB connection using `resolutionflow_app` role
|
||||
- Sets `SET LOCAL app.current_account_id = '<account_a_uuid>'` before each test
|
||||
|
||||
**Test cases per table (~5 cases per table, ~32 tables ≈ ~160 total):**
|
||||
|
||||
1. **SELECT isolation** — querying as account_a returns zero rows for account_b's data
|
||||
2. **INSERT enforcement** — inserting with `account_id = account_b_uuid` raises a PostgreSQL exception (rejected by `WITH CHECK`)
|
||||
3. **INSERT cross-tenant FK** — inserting with correct `account_id` but a FK value (e.g., `tree_id`) that belongs to account_b is also rejected
|
||||
4. **UPDATE enforcement** — updating account_b's rows as account_a affects zero rows
|
||||
5. **DELETE enforcement** — deleting account_b's rows as account_a affects zero rows
|
||||
|
||||
**Fail-closed test:**
|
||||
```python
|
||||
# Unset app.current_account_id — must raise a database exception
|
||||
# Acceptable: psycopg2.errors.InvalidTextRepresentation (NULL::uuid cast fails)
|
||||
# NOT acceptable: query returning zero rows silently
|
||||
with pytest.raises(asyncpg.PostgresError):
|
||||
await conn.execute("SELECT * FROM trees")
|
||||
```
|
||||
|
||||
The exact exception type must be documented based on the behavior of `current_setting('app.current_account_id', false)::uuid` when unset. The test asserts on that specific exception.
|
||||
|
||||
**audit_logs special cases:**
|
||||
- No INSERT `WITH CHECK` test — audit logs must be insertable freely
|
||||
- No UPDATE or DELETE tests — those policies must not exist
|
||||
- Verify via `pg_policies` that only a SELECT policy exists on `audit_logs`
|
||||
|
||||
**tree_shares:** Intentionally deferred. A TODO comment is included:
|
||||
```python
|
||||
# TODO: tree_shares RLS tests deferred pending sharing model decision.
|
||||
# Must be added before RLS is enabled on the tree_shares table.
|
||||
# See: docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md Section 7
|
||||
```
|
||||
|
||||
**Tables covered in Phase 1:**
|
||||
`trees`, `ai_sessions`, `ai_chat_sessions`, `ai_conversations`, `sessions`, `step_library`, `tree_categories`, `tree_tags`, `step_categories`, `flow_proposals`, `attachments`, `audit_logs`, `psa_connections`, `copilot_conversations`, `file_uploads`, `kb_imports`, `subscriptions`, `account_invites`, `ai_usage`, `notifications`, `session_shares`, `script_builder_sessions`, `script_templates`, `script_generations`, `maintenance_schedules`, `user_folders`, `user_pinned_trees`, `session_supporting_data`, `session_branches`, `session_resolution_outputs`, `psa_post_logs`, `notification_logs`
|
||||
|
||||
**Connection pool reuse test:**
|
||||
```python
|
||||
async def test_set_local_does_not_leak_between_connections():
|
||||
"""SET LOCAL must not leak account_id when a connection is returned to the pool."""
|
||||
conn = await pool.acquire()
|
||||
async with conn.transaction():
|
||||
await conn.execute("SET LOCAL app.current_account_id = $1", str(account_a_id))
|
||||
# transaction ends, SET LOCAL resets
|
||||
# Return conn to pool, re-acquire — verify account_id is not set
|
||||
conn2 = await pool.acquire()
|
||||
result = await conn2.fetchval("SELECT current_setting('app.current_account_id', true)")
|
||||
assert result is None or result == ""
|
||||
```
|
||||
|
||||
### Phase 2: Per-endpoint cross-tenant tests
|
||||
|
||||
As each endpoint is touched in any PR from Phase 1 onward, a cross-tenant isolation test is added in the same PR:
|
||||
|
||||
```python
|
||||
async def test_cannot_access_other_account_<resource>(
|
||||
client_account_a, account_b_resource
|
||||
):
|
||||
"""Account A cannot access Account B's resource by UUID."""
|
||||
response = await client_account_a.get(f"/<resource>/{account_b_resource.id}")
|
||||
assert response.status_code == 404 # Not 403 — never reveal existence
|
||||
```
|
||||
|
||||
This is a hard requirement per the code review checklist.
|
||||
|
||||
---
|
||||
|
||||
## Section 7: Phased Rollout Plan
|
||||
|
||||
### Immediate Hotfix (before everything else)
|
||||
|
||||
- Fix CRITICAL: Copilot tree access bypass (see Section 4)
|
||||
- Ships as independent PR, merged immediately, does not wait for Phase 0
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Foundation
|
||||
|
||||
Goal: Fix all existing gaps; establish patterns and tooling; gate future PRs.
|
||||
|
||||
1. Fix LOW: Analytics flow endpoint missing ownership check
|
||||
2. Fix LOW: Category tree count scope
|
||||
3. Fix LOW: AI session search/list inconsistency (restrict both to `user_id`)
|
||||
4. **Full UUID endpoint audit** — every `{resource_id}` URL param checked. All gaps documented and fixed before Phase 0 closes.
|
||||
5. Add `get_tenant_context` dependency to `deps.py`
|
||||
6. Add `tenant_filter()` helper to `filters.py`. Update existing filter helpers to call it.
|
||||
7. **Dead code audit:** TargetList references + database row count. Report result.
|
||||
8. **Orphan check:** Teams without a resolvable account_id. Report result.
|
||||
9. **Define and activate CI grep check** for missing `tenant_filter()` on tenant tables. Active on all PRs from Phase 1 forward.
|
||||
|
||||
Gate: All gaps patched. CI grep check active. TargetList and team orphan audit results documented.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Schema Migration
|
||||
|
||||
Goal: Every tenant-relevant table has a direct, NOT NULL `account_id` column.
|
||||
|
||||
10. Add `account_id` to all tables in Section 1a (migration per logical domain group: core sessions, PSA, AI, steps, notifications)
|
||||
11. Make nullable `account_id` NOT NULL on models from Section 1b
|
||||
12. Migrate `ScriptBuilderSession`, `ScriptTemplate`, `ScriptGeneration` from `team_id` to `account_id`
|
||||
13. `TargetList`: execute result of Phase 0 audit (drop or migrate)
|
||||
14. Create `template_trees` and `platform_steps` tables; migrate global content
|
||||
|
||||
Gate: Zero NULL `account_id` values in any tenant table. All backfills verified. Database passes zero-NULL assertion query for every table in scope.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — PostgreSQL Infrastructure
|
||||
|
||||
Goal: Database roles established; `SET LOCAL` wired into every request; RLS test suite green.
|
||||
|
||||
15. Create `resolutionflow_app` and `resolutionflow_admin` PostgreSQL roles; grant privileges
|
||||
16. Update `DATABASE_URL` to `resolutionflow_app`; add `DATABASE_ADMIN_URL` for admin connections
|
||||
17. Update Alembic to use `DATABASE_ADMIN_URL`
|
||||
18. Modify `get_db()` to execute `SET LOCAL app.current_account_id` per request, inside transaction boundary
|
||||
19. Update super admin endpoints to use `resolutionflow_admin` connection
|
||||
20. Write `test_rls_isolation.py` — all ~160 RLS tests. Must be 100% green before Phase 3. Connection pool reuse test included.
|
||||
21. Measure `SET LOCAL` per-request overhead baseline
|
||||
|
||||
Gate: All RLS tests green. All existing integration tests green. Connection pool reuse test green. Performance baseline documented.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Enable RLS
|
||||
|
||||
Goal: RLS policies active on all tenant tables. Phased by domain, not all at once.
|
||||
|
||||
Enable RLS in these batches. Run full test suite after each batch before proceeding:
|
||||
|
||||
**Batch A: Core data**
|
||||
`trees`, `tree_categories`, `tree_tags`, `sessions`, `attachments`, `session_supporting_data`, `maintenance_schedules`
|
||||
|
||||
**Batch B: AI & sessions**
|
||||
`ai_sessions`, `ai_chat_sessions`, `ai_conversations`, `ai_usage`, `ai_session_steps`, `session_branches`, `session_handoffs`, `session_resolution_outputs`, `fork_points`, `copilot_conversations`, `flow_proposals`
|
||||
|
||||
**Batch C: Steps & library**
|
||||
`step_library`, `step_categories`, `step_ratings`, `step_usage_logs`, `ai_suggestions`, `template_trees` (no RLS), `platform_steps` (no RLS)
|
||||
|
||||
**Batch D: Integrations & users**
|
||||
`psa_connections`, `psa_post_logs`, `psa_member_mappings`, `subscriptions`, `account_invites`, `file_uploads`, `kb_imports`, `notifications`, `notification_logs`, `session_shares`, `user_folders`, `user_pinned_trees`
|
||||
|
||||
**Batch E: Scripts & auth**
|
||||
`script_builder_sessions`, `script_templates`, `script_generations`, `audit_logs`
|
||||
|
||||
For each batch migration: `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` + create all applicable policies.
|
||||
|
||||
Gate: All RLS tests green after each batch. All integration tests green. Staging smoke test after Batch E. No performance regressions.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Ongoing
|
||||
|
||||
- Every PR that touches a tenant endpoint includes a cross-tenant isolation test
|
||||
- CI grep check blocks PRs with missing `tenant_filter()` (warn → block after 2-week calibration)
|
||||
- `tree_shares` RLS: design sharing model, implement tests, enable RLS before any sharing feature ships
|
||||
- `team_id` columns: drop in a cleanup migration after all application code is fully migrated
|
||||
|
||||
---
|
||||
|
||||
## Section 8: Background Job Policy
|
||||
|
||||
Background jobs and scheduled tasks that process tenant data are a distinct isolation surface. Unlike request-scoped endpoints, they do not have a `current_user` and must manage tenant context explicitly.
|
||||
|
||||
### Policy
|
||||
|
||||
Every background job that touches tenant tables must comply with one of two patterns:
|
||||
|
||||
**Pattern A — `resolutionflow_admin` with explicit per-query `account_id` filtering**
|
||||
|
||||
Use when: the job is inherently cross-tenant (e.g., processes all pending work across all accounts in a single pass). The admin role bypasses RLS, so explicit `account_id` filters in every query are mandatory — RLS is not the safety net here.
|
||||
|
||||
```python
|
||||
# Allowed: cross-tenant batch SELECT for IDs only, then loop per-account
|
||||
result = await db.execute(
|
||||
select(Model.id, Model.account_id).where(Model.status == "pending")
|
||||
)
|
||||
for row in result.all():
|
||||
await _process_one(row.id, row.account_id, db) # account_id threaded through
|
||||
|
||||
# In the processing function: all queries must filter by account_id
|
||||
async def _process_one(record_id, account_id, db):
|
||||
result = await db.execute(
|
||||
select(Model).where(Model.id == record_id, Model.account_id == account_id)
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern B — `resolutionflow_app` with `SET LOCAL` per tenant loop iteration**
|
||||
|
||||
Use when: the job processes tenants one at a time and it is practical to set the tenant context per iteration.
|
||||
|
||||
```python
|
||||
for account_id in account_ids:
|
||||
async with async_session_maker() as db:
|
||||
await db.execute(
|
||||
text("SET LOCAL app.current_account_id = :id"),
|
||||
{"id": str(account_id)}
|
||||
)
|
||||
# All queries in this block are RLS-enforced to this tenant
|
||||
```
|
||||
|
||||
### No cross-tenant queries without justification
|
||||
|
||||
No background job may issue a SELECT, UPDATE, or DELETE that spans multiple tenants' data in a single query without explicit written justification in the code comment and in this spec. Approved cross-tenant operations are documented below.
|
||||
|
||||
### Inventory: Current Background Jobs
|
||||
|
||||
| Job | File | Pattern | Status | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Knowledge Flywheel | `knowledge_flywheel_scheduler.py` | Admin + explicit filter | **Needs update** | Batch SELECT across all accounts with no `account_id` filter. Must thread `account_id` from the session into all processing calls. |
|
||||
| PSA Retry | `psa_retry_scheduler.py` | Admin + explicit filter | **Needs update** | Batch SELECT across all accounts with no `account_id` filter. Must add `account_id` to batch query and thread through to `retry_failed_push`. |
|
||||
| Chat Retention Cleanup | `retention_cleanup.py` | Admin + explicit filter | **Correct pattern** | Already loops per-account with explicit `account_id` in all queries. Model for other jobs. Needs role update to `resolutionflow_admin`. |
|
||||
| Maintenance Schedule Firing | `scheduler.py` (`_fire_maintenance_schedule`) | Admin + explicit filter | **Needs update** | Fetches `MaintenanceSchedule` and `Tree` by ID without `account_id` filter. After Phase 1, `Session` creation must set `account_id`. Add `account_id` to all queries. |
|
||||
| AI Conversation Expiry | `scheduler.py` (`_cleanup_expired_ai_conversations`) | Admin, cross-tenant approved | **Correct pattern** | Cross-tenant DELETE by `expires_at` is explicitly justified: rows are expired regardless of tenant. Needs role update to `resolutionflow_admin`. Document this approval in code comment. |
|
||||
|
||||
### Approved cross-tenant queries
|
||||
|
||||
The following cross-tenant operations are explicitly approved. All others require a new entry here before shipping:
|
||||
|
||||
| Job | Query | Justification |
|
||||
|---|---|---|
|
||||
| AI Conversation Expiry | `DELETE FROM ai_conversations WHERE expires_at < NOW()` | Time-based expiry is tenant-independent. Deleting expired rows does not expose data across tenants. |
|
||||
| Chat Retention Cleanup | `SELECT id FROM accounts` | Required to iterate per-account. Read of account IDs only; no tenant data accessed. |
|
||||
| Knowledge Flywheel (after fix) | `SELECT id, account_id FROM ai_sessions WHERE analysis_status='pending'` | Cross-tenant ID harvest only. No tenant data in the SELECT. `account_id` is threaded into per-tenant processing. |
|
||||
| PSA Retry (after fix) | `SELECT id, account_id FROM psa_post_logs WHERE status='pending_retry'` | Cross-tenant ID harvest only. `account_id` threaded into per-tenant processing. |
|
||||
|
||||
### Checklist additions (Phase 2)
|
||||
|
||||
- [ ] All background jobs updated to use `resolutionflow_admin` role via `DATABASE_ADMIN_URL`
|
||||
- [ ] Knowledge Flywheel: `account_id` added to batch query and threaded through to `analyze_session`
|
||||
- [ ] PSA Retry: `account_id` added to batch query and threaded through to `retry_failed_push`
|
||||
- [ ] Maintenance Schedule Firing: all queries include `account_id`; `Session` creation sets `account_id` after Phase 1 migration
|
||||
- [ ] AI Conversation Expiry: cross-tenant approval comment added to code
|
||||
- [ ] All jobs reviewed against this policy before Phase 3 (RLS enable)
|
||||
|
||||
---
|
||||
|
||||
## Section 9: Open Questions
|
||||
|
||||
| Question | Impact | Owner |
|
||||
|---|---|---|
|
||||
| Is tree sharing intra-tenant only, or can trees be shared across accounts? | Determines `tree_shares` schema, backfill strategy, and RLS policy. Tree_shares table deferred until resolved. | Product |
|
||||
| What is the exact PostgreSQL exception raised when `current_setting('app.current_account_id', false)::uuid` is evaluated with no value set? | Determines the fail-closed test assertion. Must be tested in Phase 2. | Engineering |
|
||||
| TargetList audit complete: active code references found in 12+ files across backend and frontend (full CRUD API, frontend page, used in MaintenanceScheduleSection and BatchLaunchModal). Cannot be dropped. Row count confirmed: **0 rows** in production. Decision: migrate to account_id in Phase 1 via backfill from team_id → accounts. Zero rows means backfill is trivial (no data to migrate, just schema change). | ✓ Resolved — migrate in Phase 1. Zero rows confirmed 2026-04-09. | ✓ Done |
|
||||
| Teams orphan check: **0 orphaned teams** confirmed 2026-04-09. Phase 1 backfill using team_id → account_id chain is safe to proceed. | ✓ Resolved — Phase 1 can proceed without team cleanup. | ✓ Done |
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Pre-Implementation Checklist
|
||||
|
||||
Everything that must be in place before writing feature code on any tenant-data endpoint:
|
||||
|
||||
### Foundation (Phase 0)
|
||||
- [ ] Copilot tree access bypass hotfix shipped
|
||||
- [ ] Analytics flow endpoint ownership check added
|
||||
- [ ] Category tree count scoped to account
|
||||
- [ ] AI session list and search both restricted to `user_id`
|
||||
- [ ] Full UUID endpoint audit completed; all gaps documented and fixed
|
||||
- [ ] `get_tenant_context` dependency added to `deps.py`
|
||||
- [ ] `tenant_filter()` helper added to `filters.py`
|
||||
- [ ] Existing filter helpers updated to use `tenant_filter()` internally
|
||||
- [ ] TargetList dead code audit result documented
|
||||
- [ ] Teams orphan count query run and result documented
|
||||
- [ ] CI grep check defined and active
|
||||
|
||||
### Schema (Phase 1)
|
||||
- [ ] `account_id NOT NULL` on all tables in Section 1a denormalization list
|
||||
- [ ] `account_id NOT NULL` on all existing nullable models from Section 1b
|
||||
- [ ] ScriptBuilderSession, ScriptTemplate, ScriptGeneration migrated from team_id
|
||||
- [ ] TargetList: dropped or migrated per audit result
|
||||
- [ ] template_trees and platform_steps tables created
|
||||
- [ ] Zero NULL assertion passes for every tenant table
|
||||
- [ ] Migration sequence (add nullable → backfill → verify → NOT NULL → index) followed for each table
|
||||
|
||||
### Infrastructure (Phase 2)
|
||||
- [ ] `resolutionflow_app` role created with correct privileges
|
||||
- [ ] `resolutionflow_admin` role created with `BYPASSRLS`
|
||||
- [ ] `DATABASE_URL` updated to `resolutionflow_app`
|
||||
- [ ] `DATABASE_ADMIN_URL` added for admin connections
|
||||
- [ ] Alembic uses `DATABASE_ADMIN_URL`
|
||||
- [ ] `get_db()` executes `SET LOCAL app.current_account_id` per request inside transaction
|
||||
- [ ] Super admin endpoints use admin connection
|
||||
- [ ] All background jobs updated to use `resolutionflow_admin` role (see Section 8)
|
||||
- [ ] Knowledge Flywheel: `account_id` threaded through batch query and `analyze_session`
|
||||
- [ ] PSA Retry: `account_id` threaded through batch query and `retry_failed_push`
|
||||
- [ ] Maintenance Schedule: all queries include `account_id`; `Session` creation sets `account_id`
|
||||
- [ ] AI Conversation Expiry: cross-tenant approval comment added to code
|
||||
- [ ] `test_rls_isolation.py` written with ~160 test cases
|
||||
- [ ] All RLS tests pass (100%)
|
||||
- [ ] Connection pool reuse test passes
|
||||
- [ ] Fail-closed exception documented and asserted
|
||||
- [ ] Performance baseline for `SET LOCAL` overhead documented
|
||||
|
||||
### RLS Active (Phase 3)
|
||||
- [ ] Batch A RLS policies applied and all tests green
|
||||
- [ ] Batch B RLS policies applied and all tests green
|
||||
- [ ] Batch C RLS policies applied and all tests green
|
||||
- [ ] Batch D RLS policies applied and all tests green
|
||||
- [ ] Batch E RLS policies applied and all tests green
|
||||
- [ ] Staging smoke test passed
|
||||
- [ ] All existing integration tests green with RLS active
|
||||
|
||||
### Ongoing Standards
|
||||
- [ ] Every new endpoint PR includes cross-tenant isolation test
|
||||
- [ ] CI grep check blocks PRs with missing `tenant_filter()`
|
||||
- [ ] `tree_shares` deferred — not enabled until sharing model documented
|
||||
- [ ] `team_id` cleanup migration scheduled after full migration complete
|
||||
@@ -7,9 +7,10 @@ interface ChatMessageProps {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) {
|
||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) {
|
||||
return (
|
||||
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
@@ -17,7 +18,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
|
||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
role === 'assistant'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-white/[0.08] text-muted-foreground'
|
||||
: 'bg-elevated text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
|
||||
@@ -25,6 +26,21 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
|
||||
|
||||
{/* Content */}
|
||||
<div className={`max-w-[80%] space-y-2 ${role === 'user' ? 'text-right' : ''}`}>
|
||||
{/* Image attachments (user messages only) */}
|
||||
{role === 'user' && imageUrls && imageUrls.length > 0 && (
|
||||
<div className={`flex flex-wrap gap-2 ${role === 'user' ? 'justify-end' : ''}`}>
|
||||
{imageUrls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={url}
|
||||
alt={`Attachment ${i + 1}`}
|
||||
className="h-24 w-auto max-w-[200px] rounded-xl object-cover border border-border cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||
role === 'user'
|
||||
|
||||
@@ -193,7 +193,7 @@ function ChatItem({
|
||||
className={cn(
|
||||
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
|
||||
confirming
|
||||
? 'bg-rose-500/10 border border-rose-500/20'
|
||||
? 'bg-danger-dim border border-danger/20'
|
||||
: isActive
|
||||
? 'bg-accent-dim text-foreground'
|
||||
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
||||
@@ -203,10 +203,10 @@ function ChatItem({
|
||||
<div className="flex-1 min-w-0">
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.75rem] text-rose-400 font-medium">Delete?</span>
|
||||
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
||||
className="text-[0.6875rem] font-medium text-rose-400 hover:text-rose-300 px-1.5 py-0.5 rounded bg-rose-500/15 hover:bg-rose-500/25 transition-colors"
|
||||
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
@@ -230,14 +230,14 @@ function ChatItem({
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
||||
className="p-1 rounded hover:bg-white/[0.08]"
|
||||
className="p-1 rounded hover:bg-elevated"
|
||||
title={chat.pinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirming(true) }}
|
||||
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
|
||||
className="p-1 rounded hover:bg-elevated text-muted-foreground hover:text-danger"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -36,7 +36,7 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
|
||||
label: 'Resolved',
|
||||
description: 'Issue has been fixed or answered',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-emerald-400',
|
||||
color: 'text-success',
|
||||
bg: 'bg-emerald-400/10',
|
||||
border: 'border-emerald-400/30',
|
||||
},
|
||||
@@ -45,17 +45,17 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
|
||||
label: 'Escalate',
|
||||
description: 'Needs to be handed off or escalated',
|
||||
icon: ArrowUpRight,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-400/10',
|
||||
border: 'border-amber-400/30',
|
||||
color: 'text-warning',
|
||||
bg: 'bg-warning-dim',
|
||||
border: 'border-warning/30',
|
||||
},
|
||||
{
|
||||
value: 'paused',
|
||||
label: 'Paused',
|
||||
description: 'Continuing later — saving progress',
|
||||
icon: Pause,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-400/10',
|
||||
color: 'text-accent',
|
||||
bg: 'bg-accent-dim',
|
||||
border: 'border-blue-400/30',
|
||||
},
|
||||
]
|
||||
@@ -362,7 +362,7 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
|
||||
<div className="text-sm text-danger bg-danger-dim border border-danger/20 rounded-lg px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -410,7 +410,7 @@ export function ConcludeSessionModal({
|
||||
<div className="h-3 bg-elevated rounded w-4/5" />
|
||||
</div>
|
||||
) : streamError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-400">
|
||||
<div className="flex items-center gap-2 text-sm text-warning">
|
||||
<AlertTriangle size={14} />
|
||||
{streamError}
|
||||
</div>
|
||||
@@ -467,7 +467,7 @@ export function ConcludeSessionModal({
|
||||
{/* Paused/Escalated: generating spinner */}
|
||||
{(outcome === 'paused' || outcome === 'escalated') && generatingUpdate && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-blue-400" />
|
||||
<Loader2 size={24} className="animate-spin text-accent" />
|
||||
<p className="text-sm text-muted-foreground">Generating status update...</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -544,7 +544,7 @@ export function ConcludeSessionModal({
|
||||
{outcome === 'paused' && (
|
||||
<button
|
||||
onClick={handleResumeNew}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-accent bg-accent-dim border border-accent/20 hover:bg-accent/15 transition-all"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Resume in New Chat
|
||||
@@ -566,7 +566,7 @@ export function ConcludeSessionModal({
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||
copied
|
||||
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
||||
? 'bg-emerald-400/15 text-success border border-emerald-400/30'
|
||||
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
}
|
||||
}, [handleMouseMove, handleMouseUp])
|
||||
|
||||
// Refs so the debounced save always uses the latest questions/actions/tasks
|
||||
const questionsRef = useRef(questions)
|
||||
const actionsRef = useRef(actions)
|
||||
const tasksRef = useRef(tasks)
|
||||
useEffect(() => { questionsRef.current = questions }, [questions])
|
||||
useEffect(() => { actionsRef.current = actions }, [actions])
|
||||
useEffect(() => { tasksRef.current = tasks }, [tasks])
|
||||
|
||||
// Save task state to sessionStorage on every change + debounce to backend
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
aiSessionsApi.saveTaskLane(sessionId, {
|
||||
questions: questions.map(q => ({ text: q.text, context: q.context })),
|
||||
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||
responses: tasks as unknown as Array<Record<string, unknown>>,
|
||||
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
|
||||
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
|
||||
}).catch(() => { /* silent — best-effort save */ })
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
@@ -360,7 +368,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
<Check size={10} className="text-success" />
|
||||
|
||||
@@ -9,9 +9,9 @@ interface AISessionListItemProps {
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { icon: Clock, color: 'text-primary', label: 'Active' },
|
||||
paused: { icon: Pause, color: 'text-amber-400', label: 'Paused' },
|
||||
resolved: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Resolved' },
|
||||
escalated: { icon: ArrowUpRight, color: 'text-amber-400', label: 'Escalated' },
|
||||
paused: { icon: Pause, color: 'text-warning', label: 'Paused' },
|
||||
resolved: { icon: CheckCircle2, color: 'text-success', label: 'Resolved' },
|
||||
escalated: { icon: ArrowUpRight, color: 'text-warning', label: 'Escalated' },
|
||||
abandoned: { icon: AlertCircle, color: 'text-text-muted', label: 'Abandoned' },
|
||||
} as const
|
||||
|
||||
@@ -64,7 +64,7 @@ export function AISessionListItem({ session }: AISessionListItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
{session.session_rating && (
|
||||
<span className="font-sans text-xs text-xs text-amber-400">
|
||||
<span className="font-sans text-xs text-xs text-warning">
|
||||
{'★'.repeat(session.session_rating)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -35,9 +35,9 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
|
||||
return (
|
||||
<Modal isOpen={open} onClose={handleClose} title="Escalate Session" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-amber-400/20 bg-amber-400/5 p-3">
|
||||
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-400">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-warning/20 bg-warning/5 p-3">
|
||||
<AlertTriangle size={16} className="text-warning shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning">
|
||||
This will mark the session as requesting escalation. Team members will see it in their escalation queue and can pick it up with full context.
|
||||
</p>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reason.trim() || reason.trim().length < 5 || isProcessing}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-amber-500/90 px-4 py-2.5 min-h-[44px] text-sm font-semibold text-white hover:bg-amber-500 active:scale-[0.98] disabled:opacity-40 transition-all"
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-warning px-4 py-2.5 min-h-[44px] text-sm font-semibold text-white hover:bg-warning active:scale-[0.98] disabled:opacity-40 transition-all"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={() => { setShowResolve(true); setShowEscalate(false) }}
|
||||
disabled={!canResolve || isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
Resolve
|
||||
@@ -99,7 +99,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={() => setShowEscalate(true)}
|
||||
disabled={!canEscalate || isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={15} />
|
||||
Escalate
|
||||
@@ -108,7 +108,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
title="Share Update"
|
||||
>
|
||||
<FileText size={15} />
|
||||
@@ -166,7 +166,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={resolutionSummary.length < 5 || submitting}
|
||||
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-emerald-400 hover:bg-emerald-500/30 disabled:opacity-50 transition-colors"
|
||||
className="rounded-lg bg-success/20 border border-success/30 px-4 py-2 min-h-[44px] text-sm font-medium text-success hover:bg-success/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Resolving...' : 'Resolve Session'}
|
||||
</button>
|
||||
@@ -193,7 +193,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={handleAbandon}
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
|
||||
className="rounded-lg bg-danger/20 border border-danger/30 px-4 py-2 min-h-[44px] text-sm font-medium text-danger hover:bg-danger/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Closing...' : 'Close Session'}
|
||||
</button>
|
||||
|
||||
@@ -155,7 +155,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearTicket}
|
||||
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-white/[0.06] hover:text-foreground transition-colors"
|
||||
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-elevated hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -215,7 +215,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
Pull from Ticket
|
||||
</button>
|
||||
{psaChecked && !psaConnection && (
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-amber-400/80">
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-warning/80">
|
||||
<AlertTriangle size={10} />
|
||||
No PSA connected
|
||||
</span>
|
||||
|
||||
@@ -243,17 +243,17 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveUpload(upload.id)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors"
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-danger/20 transition-colors"
|
||||
>
|
||||
<X size={8} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{upload.status === 'error' && (
|
||||
<div
|
||||
className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer"
|
||||
className="absolute inset-0 bg-danger/20 border-2 border-danger flex items-center justify-center cursor-pointer"
|
||||
onClick={() => retryUpload(upload.id)}
|
||||
>
|
||||
<RotateCcw size={10} className="text-rose-500" />
|
||||
<RotateCcw size={10} className="text-danger" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function FlowPilotSession({
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowShareCommunication(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-2.5 text-sm font-medium text-blue-400 hover:bg-blue-500/20 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-dim border border-accent/20 px-4 py-2.5 text-sm font-medium text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<FileText size={16} />
|
||||
{shareLabel}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user