Compare commits
84 Commits
feat/netwo
...
feat/cockp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a45f66358 | ||
|
|
6f12e42ebe | ||
|
|
c0d8163e98 | ||
|
|
af4f07cec6 | ||
|
|
5eac5fecff | ||
|
|
e042cf6186 | ||
|
|
c53ced8725 | ||
|
|
e3b2f73f38 | ||
|
|
866b02833c | ||
|
|
80c82f0b48 | ||
|
|
f989c1e487 | ||
|
|
375e739348 | ||
|
|
f4b05a2ff5 | ||
|
|
73ea126f4c | ||
|
|
93b1398b5c | ||
|
|
654b9cb4ac | ||
|
|
ee32fdbf78 | ||
|
|
db9d5a8393 | ||
|
|
289b3cacfc | ||
|
|
6b7bc3fd42 | ||
|
|
6aef6e2602 | ||
|
|
12f6a29dd5 | ||
|
|
600c3959af | ||
|
|
d8d8de91d1 | ||
|
|
ebfd4e5651 | ||
|
|
7c19521fc2 | ||
|
|
17e1bc84e2 | ||
|
|
ebc6b46823 | ||
|
|
38be5b0370 | ||
|
|
b6ea63d81a | ||
|
|
03f6100d4c | ||
|
|
a3c49873f5 | ||
|
|
5a91c4d672 | ||
|
|
068baec179 | ||
|
|
9c042c750e | ||
|
|
8c90da1960 | ||
|
|
be34a20441 | ||
|
|
e7ac87bf7d | ||
|
|
165e402284 | ||
|
|
aca976a49a | ||
|
|
47e71c4753 | ||
|
|
91e3f80707 | ||
|
|
b03f84aecf | ||
|
|
813b598101 | ||
|
|
ed6e6cd1ed | ||
|
|
b07dfb7603 | ||
|
|
4ba32a08ac | ||
|
|
3ea669a1e5 | ||
|
|
81ad52f5bc | ||
|
|
cd7774b733 | ||
|
|
3ce4201d62 | ||
|
|
b994e82c56 | ||
|
|
7d97412d1f | ||
|
|
fc51ceb610 | ||
|
|
4cc6ee4797 | ||
|
|
2ed02607a8 | ||
|
|
1a858237ba | ||
|
|
df9e069452 | ||
|
|
79c8632dbb | ||
|
|
a001aa11e5 | ||
|
|
2ee3c1afda | ||
|
|
cc929c8932 | ||
|
|
b7e5979d0c | ||
|
|
6117a83b0b | ||
|
|
a60c19b305 | ||
|
|
a2749104f4 | ||
|
|
8c28f48ce0 | ||
|
|
f9de76b28c | ||
|
|
296153850b | ||
|
|
1d4d7ef35d | ||
|
|
bfcb8c52d3 | ||
|
|
b8189a1999 | ||
|
|
9462da8b80 | ||
|
|
63023d486d | ||
|
|
1705ecbb9f | ||
|
|
6ee6faa712 | ||
|
|
23a7cee1f5 | ||
|
|
036198b224 | ||
|
|
92cc62bcbd | ||
|
|
15781baeb7 | ||
|
|
cb750d52f6 | ||
|
|
56ca5d2669 | ||
|
|
81f8aa0074 | ||
|
|
5dd43b2226 |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -31,8 +31,6 @@ jobs:
|
||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||
DEBUG: "true"
|
||||
APP_NAME: ResolutionFlow
|
||||
TEST_DB_NAME: resolutionflow_test
|
||||
DB_APP_ROLE_PASSWORD: app_secret_ci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -49,14 +47,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run Alembic migrations
|
||||
run: cd backend && alembic upgrade head
|
||||
|
||||
- name: Check tenant filter enforcement
|
||||
run: cd backend && python scripts/check_tenant_filters.py
|
||||
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.
|
||||
# See: docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md Section 3f
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -233,8 +233,3 @@ package.json
|
||||
package-lock.json
|
||||
.worktrees/
|
||||
.gstack/
|
||||
.gitnexus
|
||||
|
||||
# graphify knowledge graph outputs
|
||||
graphify-out/
|
||||
.graphify_python
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -4,45 +4,37 @@ All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-04] Network Diagram Editor UX Improvements
|
||||
|
||||
### Added
|
||||
- Snap-to-grid (20px) on Network Diagram canvas — nodes align consistently when dragged
|
||||
- NodeResizer on group nodes (subnet/VLAN/site/DMZ) — select a group and drag its handles to resize
|
||||
- Group node dimensions now saved to and restored from the backend on reload
|
||||
|
||||
### Fixed
|
||||
- Connection edges now render as straight lines instead of orthogonal bent paths
|
||||
- ISP device now appears inside the Cloud category in the sidebar instead of a standalone "Internet" section; respects search and item count
|
||||
- Group nodes now restore correctly as `type: 'group'` on diagram load (previously loaded as `type: 'device'`, breaking group display after save)
|
||||
|
||||
---
|
||||
|
||||
### Added
|
||||
- Tree Templates + Import/Export marketplace (#66)
|
||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
|
||||
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
|
||||
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
|
||||
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
162
CLAUDE.md
162
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
> **Last Updated:** April 6, 2026
|
||||
> **Last Updated:** March 27, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
| Context | Name Used |
|
||||
|---------|-----------|
|
||||
| Repository / directory / database | `patherly` (internal name) |
|
||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
||||
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
|
||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
||||
|
||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
||||
@@ -45,7 +44,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, 101 migrations
|
||||
- **Database:** PostgreSQL with Docker, 98 migrations
|
||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
||||
|
||||
### What's In Progress
|
||||
@@ -97,7 +96,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-070 sequential, then hash IDs)
|
||||
│ ├── alembic/ # Database migrations (001-029+)
|
||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
||||
│ └── tests/ # pytest integration tests
|
||||
├── frontend/
|
||||
@@ -189,8 +188,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
|
||||
## Development Commands
|
||||
|
||||
```powershell
|
||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker start resolutionflow_postgres
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate # Linux/Mac
|
||||
@@ -204,19 +203,21 @@ npm run dev
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# First time only: create test database
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
|
||||
|
||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
||||
cd frontend && npm run build
|
||||
|
||||
# Database migrations
|
||||
cd backend && alembic upgrade head
|
||||
alembic revision --autogenerate -m "Description"
|
||||
# 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.
|
||||
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number
|
||||
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069).
|
||||
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1
|
||||
# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py).
|
||||
# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations.
|
||||
|
||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
# Access PostgreSQL
|
||||
docker exec -it patherly_postgres psql -U postgres -d patherly
|
||||
|
||||
# Seed data
|
||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
@@ -291,7 +292,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/.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/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
|
||||
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
|
||||
|
||||
@@ -331,7 +332,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 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.
|
||||
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
|
||||
|
||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
||||
|
||||
@@ -343,7 +344,6 @@ 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 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.
|
||||
**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links).
|
||||
|
||||
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
|
||||
|
||||
@@ -375,12 +375,6 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
|
||||
|
||||
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
|
||||
|
||||
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
|
||||
|
||||
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
||||
@@ -396,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 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
|
||||
- **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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -524,105 +518,3 @@ 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** (16703 symbols, 35922 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 -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
||||
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||
> **Last Updated:** April 12, 2026
|
||||
> **Last Updated:** April 4, 2026 (evening)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
## What's Complete
|
||||
|
||||
### Core Platform
|
||||
- FastAPI project structure with 35+ API endpoints
|
||||
- PostgreSQL database with Docker, 75+ Alembic migrations
|
||||
- FastAPI project structure with 55+ API endpoints
|
||||
- PostgreSQL database with Docker, 100+ Alembic migrations
|
||||
- User authentication (JWT, register, login, refresh, logout, invite codes)
|
||||
- Refresh token rotation with JTI-based revocation
|
||||
- Trees CRUD with full-text search (FTS index)
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
### Frontend Core
|
||||
- React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`)
|
||||
- **Charcoal Design System** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette with sidebar-darkest approach
|
||||
- **Charcoal Design System v6** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette; accent color is electric blue (#60a5fa), replacing ember orange
|
||||
- **Brand fonts:** Bricolage Grotesque (headings), IBM Plex Sans (body), JetBrains Mono (code)
|
||||
- Authentication UI (login, register, email verification)
|
||||
- Tree library/browsing page with grid/list/table views
|
||||
@@ -130,6 +130,36 @@
|
||||
- Enhanced PSA metrics: time entries, hours logged, push success funnel, daily trend chart
|
||||
- 13 new backend tests for coverage and flow quality endpoints
|
||||
|
||||
### Conversational Branching (Complete)
|
||||
|
||||
- SessionBranch, ForkPoint, SessionHandoff, SessionResolutionOutput models + migration (4 tables, 13 columns)
|
||||
- BranchManager service, BranchAwarePromptBuilder, HandoffManager service with integration tests
|
||||
- Branch API endpoints: `session_branches.py`, `session_handoffs.py`, `session_resolutions.py`
|
||||
- Integrated into `unified_chat_service.py` and AI session step creation
|
||||
- Frontend: BranchNode, ForkCard, BranchMap, BranchRevivalCard, BranchTransitionBar, HandoffModal, ResolutionOutputPanel components
|
||||
- Wired into FlowPilotSession and `useFlowPilotSession` hook
|
||||
|
||||
### Script Library Enhancements (Complete)
|
||||
|
||||
- ParameterizeAndSavePanel replaces SaveToLibraryDialog — accepts `script_body` and `parameters_schema` in save flow
|
||||
- "New from Script" button on ScriptLibraryPage for one-click script creation from template
|
||||
- Default tab is "All Scripts" (previously filtered to owned scripts)
|
||||
- Ownership filter state preserved across category and search changes
|
||||
- Backend: `save-to-library` endpoint accepts `script_body` + `parameters_schema`
|
||||
|
||||
### AI Vision Support (Complete)
|
||||
|
||||
- Image uploads (paste/drag-drop) wired into AI assistant chat via `upload_ids`
|
||||
- Server-side image resize before sending to Claude (Pillow, 1568px max, PNG→JPEG)
|
||||
- `storage_service.resize_image_for_vision()` handles vision pipeline
|
||||
- Images are NOT stored in conversation history (text-only history)
|
||||
|
||||
### Mid-Session Status Updates (Complete)
|
||||
|
||||
- AI assistant can generate `status_update` steps (step_type added to CHECK constraint)
|
||||
- Status update generation wired into `unified_chat_service.py`
|
||||
- Frontend renders status update cards in session view
|
||||
|
||||
### Search & Recall + Evidence-Rich Sessions (Complete)
|
||||
|
||||
**Evidence:**
|
||||
@@ -163,14 +193,7 @@
|
||||
- SQL wildcard escaping in tag search
|
||||
- PSA credentials encrypted at rest (Fernet)
|
||||
|
||||
### Tenant Isolation (Phases 1-4 Complete)
|
||||
- PostgreSQL RLS enabled across tenant-scoped tables in phased rollout
|
||||
- `account_id` propagation completed across core content, sessions, analytics, notifications, shares, and remaining Phase 4 tables
|
||||
- Global platform tables correctly excluded from tenant RLS where they have no `account_id` (`script_categories`, `platform_steps`, `template_trees`)
|
||||
- Runtime bootstrap paths updated to use BYPASSRLS/admin sessions where needed (auth/user mutations, startup service account, background jobs, seed scripts)
|
||||
- Preview Railway backend and frontend deployments green for PR 136 after the Phase 4 fixes
|
||||
|
||||
### Copilot-First Dashboard (March 2026)
|
||||
### Copilot-First Dashboard (March–April 2026)
|
||||
|
||||
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)
|
||||
- Chat-style input with paste images, drag-drop files, attach button, paste logs
|
||||
@@ -180,9 +203,33 @@
|
||||
- Unified Command Palette (Cmd+K) — merged QuickLaunch into omnibar
|
||||
- "Solutions Library" rename (from "Step Library") site-wide
|
||||
- Maintenance flows hidden from UI for pilot (backend still supports them)
|
||||
- Landing page copy rewrite: "Resolve tickets faster. Notes write themselves."
|
||||
- Spring bounce hover animation on dashboard cards
|
||||
- Charcoal color palette: sidebar `#10121a`, page `#1a1c23`, cards `#22252e`
|
||||
- Charcoal color palette: sidebar `#0e1016`, page `#16181f`, cards `#1e2028`
|
||||
- **Landing page redesign** — scroll-driven reveal animations, live chat animation, FAQ section, improved trust signals; copy: "Resolve tickets faster. Notes write themselves."
|
||||
- **Session History redesign** — tabbed layout with Load More pagination
|
||||
- **Edit Procedure page** — layout and color system overhaul
|
||||
- **TaskLane UX** improvements in assistant chat; persistence across page reload
|
||||
- TaskLane answers persist in sessionStorage; correct behavior on all three chat paths (send, prefill, resume)
|
||||
- **Action bar consolidation** — Deduplicated actions across FlowPilot/Cockpit headers and chat toolbars; chat toolbar now only has input tools (Attach, Paste Logs, Tasks)
|
||||
- **ViewToggle redesigned** as persistent tab bar with bottom-border active indicator and ARIA attributes (FlowPilot/Cockpit switcher)
|
||||
- **Standardized action naming** across all session pages: Resolve (emerald), Update (blue), Close (rose), Pause (muted)
|
||||
- **ConcludeSessionModal copy refresh** — Forward-facing action verbs, "Close & Generate" CTA, consistent outcome labels
|
||||
- Deleted unused FlowPilotActionBar component (227 lines dead code)
|
||||
|
||||
### Network Diagrams (In Progress)
|
||||
|
||||
- Network diagram editor with React Flow (@xyflow/react v12) canvas
|
||||
- Device node system: 27 device types across 7 categories (network, compute, storage, cloud, endpoint, infrastructure, security)
|
||||
- Custom device type creation via DeviceToolbar
|
||||
- Connection edges with 6 types (ethernet, fiber, wifi, vpn, vlan, wan) — color-coded, dashed for wireless/VPN
|
||||
- Properties panel for editing device and connection details
|
||||
- AI-assisted diagram generation (describe network → auto-layout)
|
||||
- Auto-save every 30 seconds, manual save, JSON export
|
||||
- **React Flow UI Components** — Cherry-picked and Charcoal-restyled: BaseNode (structured header/content/footer slots), BaseHandle (styled connection handles), LabeledHandle (named port labels), NodeStatusIndicator (status border effect: emerald/red/yellow), NodeTooltip (hover details via NodeToolbar), LabeledGroupNode (subnet/VLAN/site/DMZ containers), AnimatedSvgEdge (traffic flow visualization)
|
||||
- Grouping category in toolbar: Subnet, VLAN, Site, DMZ drag-drop to canvas
|
||||
- Traffic flow toggle on edges (switches between static and animated)
|
||||
- Context menu with copy/paste/duplicate/select all shortcuts
|
||||
- Drop position uses `screenToFlowPosition()` for correct placement at any zoom/pan level
|
||||
- **Bug fix:** PropertiesPanel inputs now work — selection uses IDs instead of stale object snapshots
|
||||
|
||||
### Maintenance Flows (Hidden from UI)
|
||||
|
||||
@@ -242,21 +289,22 @@
|
||||
|
||||
### Start Development
|
||||
```bash
|
||||
# Start PostgreSQL (Docker Compose)
|
||||
docker compose up -d
|
||||
# Start PostgreSQL (Docker — container name resolutionflow_postgres, port 5433, DB resolutionflow)
|
||||
docker start resolutionflow_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend (from frontend/)
|
||||
# Frontend (from frontend/, requires Node 20)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### URLs
|
||||
- Frontend: http://192.168.0.9:5173
|
||||
- Backend API: http://192.168.0.9:8000
|
||||
- API Docs: http://192.168.0.9:8000/api/docs
|
||||
- Frontend: http://46.202.92.250:5173 (or https via Traefik reverse proxy)
|
||||
- Backend API: http://46.202.92.250:8000
|
||||
- API Docs: http://46.202.92.250:8000/api/docs
|
||||
- Dev env runs on Hostinger VPS (46.202.92.250) with Traefik + HTTPS; see [DEV-ENV.md](DEV-ENV.md)
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
|
||||
@@ -29,37 +29,13 @@ from app.models.session_branch import SessionBranch # noqa: F401
|
||||
from app.models.fork_point import ForkPoint # noqa: F401
|
||||
from app.models.session_handoff import SessionHandoff # noqa: F401
|
||||
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _alembic_sync_url() -> str:
|
||||
"""Return a psycopg2-compatible sync URL for Alembic.
|
||||
|
||||
Priority order:
|
||||
1. DATABASE_URL_SYNC — in Railway this is set as a reference variable
|
||||
(${{pgvector.DATABASE_URL}}) that resolves to the correct postgres
|
||||
superuser credentials for the current environment (production, PR preview,
|
||||
etc.). This always works even on fresh databases before any custom roles
|
||||
have been created, because it uses the postgres superuser.
|
||||
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS) converted to a sync
|
||||
driver — fallback for local dev where DATABASE_URL_SYNC may not be set.
|
||||
"""
|
||||
if settings.DATABASE_URL_SYNC:
|
||||
return settings.DATABASE_URL_SYNC
|
||||
|
||||
admin_url = settings.ADMIN_DATABASE_URL
|
||||
if admin_url and "+asyncpg" in admin_url:
|
||||
return admin_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
|
||||
return settings.DATABASE_URL_SYNC
|
||||
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url with the sync version for migrations
|
||||
config.set_main_option("sqlalchemy.url", _alembic_sync_url())
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
@@ -110,7 +86,7 @@ def run_migrations_online() -> None:
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
connectable = create_engine(
|
||||
_alembic_sync_url(),
|
||||
settings.DATABASE_URL_SYNC,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Enable RLS on Phase 3 tables.
|
||||
|
||||
Tables covered:
|
||||
- step_ratings (account_id NOT NULL since migration 7167e9374b0c)
|
||||
- step_usage_log (account_id NOT NULL since migration 7167e9374b0c)
|
||||
- target_lists (account_id NOT NULL since migration 2c6aabd89bc6)
|
||||
- session_shares (account_id NOT NULL since session_share model)
|
||||
- audit_logs (account_id NOT NULL since migration 2a9056eddd90)
|
||||
- tree_shares (account_id NOT NULL since migration a05e1a1bea7c)
|
||||
|
||||
All use a standard intra-tenant isolation policy.
|
||||
Token-based access to session_shares and tree_shares goes through
|
||||
endpoints that use get_admin_db (BYPASSRLS), so a strict tenant
|
||||
policy here is correct.
|
||||
|
||||
Revision ID: 04f013768235
|
||||
Revises: a05e1a1bea7c
|
||||
Create Date: 2026-04-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
revision: str = '04f013768235'
|
||||
down_revision: Union[str, None] = 'a05e1a1bea7c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000')::uuid"
|
||||
)
|
||||
|
||||
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
|
||||
|
||||
_PHASE3_TABLES = [
|
||||
"step_ratings",
|
||||
"step_usage_log",
|
||||
"target_lists",
|
||||
"session_shares",
|
||||
"audit_logs",
|
||||
"tree_shares",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _PHASE3_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING ({_STANDARD_USING})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _PHASE3_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add triage fields to ai_sessions for cockpit harness
|
||||
|
||||
Revision ID: 071
|
||||
Revises: 070
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "071"
|
||||
down_revision = "070"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ai_sessions", sa.Column("client_name", sa.String(255), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("asset_name", sa.String(255), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("issue_category", sa.String(100), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("triage_hypothesis", sa.Text(), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("evidence_items", JSONB(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_sessions", "evidence_items")
|
||||
op.drop_column("ai_sessions", "triage_hypothesis")
|
||||
op.drop_column("ai_sessions", "issue_category")
|
||||
op.drop_column("ai_sessions", "asset_name")
|
||||
op.drop_column("ai_sessions", "client_name")
|
||||
61
backend/alembic/versions/072_seed_flowpilot_cockpit_flag.py
Normal file
61
backend/alembic/versions/072_seed_flowpilot_cockpit_flag.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Seed flowpilot_cockpit feature flag with plan defaults.
|
||||
|
||||
Revision ID: 072
|
||||
Revises: 071
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "072"
|
||||
down_revision = "071"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Insert the feature flag
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO feature_flags (id, flag_key, display_name, description) "
|
||||
"VALUES (gen_random_uuid(), 'flowpilot_cockpit', 'FlowPilot Cockpit', "
|
||||
"'Access to the FlowPilot Cockpit triage view') "
|
||||
"ON CONFLICT (flag_key) DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
# Set plan defaults: disabled for free, enabled for pro and team
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'free', id, false FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'pro', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'team', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM plan_feature_defaults WHERE flag_id IN "
|
||||
"(SELECT id FROM feature_flags WHERE flag_key = 'flowpilot_cockpit')"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text("DELETE FROM feature_flags WHERE flag_key = 'flowpilot_cockpit'")
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Add account-scoped device_types table with platform seed data.
|
||||
"""Add device_types table with system seed data.
|
||||
|
||||
Revision ID: 073
|
||||
Revises: b3c7e9f2a1d8
|
||||
Create Date: 2026-04-12
|
||||
Revises: 072
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -11,18 +11,10 @@ import uuid
|
||||
|
||||
|
||||
revision = "073"
|
||||
down_revision = "b3c7e9f2a1d8"
|
||||
down_revision = "072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
SYSTEM_DEVICE_TYPES = [
|
||||
("router", "Router", "network", 0),
|
||||
("switch", "Switch", "network", 1),
|
||||
@@ -63,13 +55,16 @@ def upgrade() -> None:
|
||||
sa.Column("label", sa.String(100), nullable=False),
|
||||
sa.Column("category", sa.String(50), nullable=False),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
|
||||
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
|
||||
op.execute(
|
||||
"ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team "
|
||||
"UNIQUE NULLS NOT DISTINCT (slug, team_id)"
|
||||
)
|
||||
op.create_index("idx_device_types_team", "device_types", ["team_id"])
|
||||
|
||||
device_types_table = sa.table(
|
||||
"device_types",
|
||||
@@ -78,7 +73,7 @@ def upgrade() -> None:
|
||||
sa.column("label", sa.String),
|
||||
sa.column("category", sa.String),
|
||||
sa.column("is_system", sa.Boolean),
|
||||
sa.column("account_id", UUID(as_uuid=True)),
|
||||
sa.column("team_id", UUID(as_uuid=True)),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
)
|
||||
|
||||
@@ -89,44 +84,12 @@ def upgrade() -> None:
|
||||
"label": label,
|
||||
"category": category,
|
||||
"is_system": True,
|
||||
"account_id": uuid.UUID(_PLATFORM_UUID),
|
||||
"team_id": None,
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
|
||||
])
|
||||
|
||||
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_select ON device_types
|
||||
FOR SELECT
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
)
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_insert ON device_types
|
||||
FOR INSERT
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_update ON device_types
|
||||
FOR UPDATE
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_delete ON device_types
|
||||
FOR DELETE
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
|
||||
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("device_types")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Revision ID: 074
|
||||
Revises: 073
|
||||
Create Date: 2026-04-12
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -14,19 +14,12 @@ down_revision = "073"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"network_diagrams",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("client_name", sa.String(255), nullable=True),
|
||||
sa.Column("asset_name", sa.String(255), nullable=True),
|
||||
@@ -40,18 +33,9 @@ def upgrade() -> None:
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
|
||||
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
|
||||
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON network_diagrams
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
op.create_index("idx_network_diagrams_team", "network_diagrams", ["team_id"])
|
||||
op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
|
||||
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("network_diagrams")
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""create_db_roles
|
||||
|
||||
Revision ID: 0b470d9e6cf1
|
||||
Revises: a9f3b2c1d4e5
|
||||
Create Date: 2026-04-10 03:58:10.207919
|
||||
|
||||
"""
|
||||
import os
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0b470d9e6cf1'
|
||||
down_revision: Union[str, None] = 'a9f3b2c1d4e5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Passwords from env vars. For local dev, defaults are sufficient.
|
||||
# For production (Railway), set DB_APP_ROLE_PASSWORD and
|
||||
# DB_ADMIN_ROLE_PASSWORD as environment variables before running migrations.
|
||||
# Passwords must not contain single quotes.
|
||||
app_pw = os.environ.get("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
|
||||
admin_pw = os.environ.get("DB_ADMIN_ROLE_PASSWORD", "admin_secret_change_me")
|
||||
|
||||
# Fetch the current database name dynamically — avoids hardcoding
|
||||
# (the DB is named 'resolutionflow' in dev, potentially different elsewhere).
|
||||
conn = op.get_bind()
|
||||
db_name = conn.execute(text("SELECT current_database()")).scalar()
|
||||
|
||||
# ── Application role ────────────────────────────────────────────────────
|
||||
# Subject to RLS. Used by FastAPI at runtime via DATABASE_URL.
|
||||
op.execute(f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_app') THEN
|
||||
CREATE ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
|
||||
ELSE
|
||||
ALTER ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
|
||||
END IF;
|
||||
END $$
|
||||
""")
|
||||
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_app")
|
||||
op.execute("GRANT USAGE ON SCHEMA public TO resolutionflow_app")
|
||||
op.execute(
|
||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public "
|
||||
"TO resolutionflow_app"
|
||||
)
|
||||
op.execute(
|
||||
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO resolutionflow_app"
|
||||
)
|
||||
# Ensure future tables automatically get the same permissions
|
||||
op.execute(
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
|
||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO resolutionflow_app"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
|
||||
"GRANT USAGE, SELECT ON SEQUENCES TO resolutionflow_app"
|
||||
)
|
||||
|
||||
# ── Admin role ──────────────────────────────────────────────────────────
|
||||
# BYPASSRLS. Used by Alembic (DATABASE_URL_SYNC) and /admin/* endpoints
|
||||
# (ADMIN_DATABASE_URL) after Task 11.
|
||||
op.execute(f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_admin') THEN
|
||||
CREATE ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
|
||||
ELSE
|
||||
ALTER ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
|
||||
END IF;
|
||||
END $$
|
||||
""")
|
||||
op.execute("GRANT resolutionflow_app TO resolutionflow_admin")
|
||||
op.execute("ALTER ROLE resolutionflow_admin BYPASSRLS")
|
||||
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_admin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
db_name = conn.execute(text("SELECT current_database()")).scalar()
|
||||
|
||||
op.execute(
|
||||
"REVOKE ALL ON ALL TABLES IN SCHEMA public FROM resolutionflow_app"
|
||||
)
|
||||
op.execute(
|
||||
"REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM resolutionflow_app"
|
||||
)
|
||||
op.execute(
|
||||
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_app"
|
||||
)
|
||||
op.execute(
|
||||
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_admin"
|
||||
)
|
||||
op.execute("DROP ROLE IF EXISTS resolutionflow_admin")
|
||||
op.execute("DROP ROLE IF EXISTS resolutionflow_app")
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Drop team_id from target_lists.
|
||||
|
||||
account_id (NOT NULL) is now the tenant isolation key; team_id is redundant.
|
||||
All reads/writes use account_id via RLS + application filter.
|
||||
|
||||
Revision ID: 172ad76d7d20
|
||||
Revises: 04f013768235
|
||||
Create Date: 2026-04-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '172ad76d7d20'
|
||||
down_revision: Union[str, None] = '04f013768235'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index('ix_target_lists_team_id', table_name='target_lists', if_exists=True)
|
||||
op.drop_constraint('target_lists_team_id_fkey', 'target_lists', type_='foreignkey')
|
||||
op.drop_column('target_lists', 'team_id')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('target_lists', sa.Column('team_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'target_lists_team_id_fkey', 'target_lists', 'teams',
|
||||
['team_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])
|
||||
@@ -1,86 +0,0 @@
|
||||
"""set NOT NULL on all previously-nullable account_id columns
|
||||
|
||||
Revision ID: 174f442795b7
|
||||
Revises: 3a40fe11b427
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
|
||||
All tables in this migration had account_id set to nullable previously.
|
||||
Task 9 (create_global_content_tables) cleared all NULL rows.
|
||||
This migration enforces the NOT NULL constraint.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '174f442795b7'
|
||||
down_revision: Union[str, None] = '3a40fe11b427'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# tree_embeddings: backfill from trees (must happen before SET NOT NULL)
|
||||
op.execute("""
|
||||
UPDATE tree_embeddings te
|
||||
SET account_id = t.account_id
|
||||
FROM trees t
|
||||
WHERE te.tree_id = t.id
|
||||
AND te.account_id IS NULL
|
||||
""")
|
||||
|
||||
# feedback: backfill from users
|
||||
op.execute("""
|
||||
UPDATE feedback f
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE f.user_id = u.id
|
||||
AND f.account_id IS NULL
|
||||
""")
|
||||
|
||||
# Verify ALL tables before touching any SET NOT NULL
|
||||
tables_with_account_id = [
|
||||
'users', 'trees', 'tree_categories', 'tree_tags',
|
||||
'step_categories', 'step_library', 'tree_embeddings', 'feedback',
|
||||
]
|
||||
for table in tables_with_account_id:
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} NULL account_id rows in {table}. "
|
||||
"Run Task 9 (create_global_content_tables) first, or "
|
||||
"manually backfill/delete orphaned rows."
|
||||
)
|
||||
|
||||
# SET NOT NULL on all
|
||||
for table in tables_with_account_id:
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
|
||||
# Create indexes where they don't already exist
|
||||
new_indexes = [
|
||||
('tree_embeddings', 'ix_tree_embeddings_account_id'),
|
||||
('feedback', 'ix_feedback_account_id'),
|
||||
]
|
||||
for table, index_name in new_indexes:
|
||||
result = op.get_bind().execute(sa.text(
|
||||
f"SELECT 1 FROM pg_indexes WHERE tablename='{table}' AND indexname='{index_name}'"
|
||||
))
|
||||
if not result.fetchone():
|
||||
op.create_index(index_name, table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert to nullable
|
||||
for table in ('users', 'trees', 'tree_categories', 'tree_tags',
|
||||
'step_categories', 'step_library', 'tree_embeddings', 'feedback'):
|
||||
op.alter_column(table, 'account_id', nullable=True)
|
||||
for table, index_name in (
|
||||
('tree_embeddings', 'ix_tree_embeddings_account_id'),
|
||||
('feedback', 'ix_feedback_account_id'),
|
||||
):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Add account_id to audit_logs and backfill via user_id.
|
||||
|
||||
Revision ID: 2a9056eddd90
|
||||
Revises: 70a5dd746e83
|
||||
Create Date: 2026-04-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '2a9056eddd90'
|
||||
down_revision: Union[str, None] = '70a5dd746e83'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('audit_logs', sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_audit_logs_account_id', 'audit_logs', 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Backfill: derive from the acting user's account
|
||||
op.execute("""
|
||||
UPDATE audit_logs al
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE al.user_id = u.id
|
||||
AND u.account_id IS NOT NULL
|
||||
AND al.account_id IS NULL
|
||||
""")
|
||||
|
||||
result = op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM audit_logs WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} audit_logs rows have NULL account_id after backfill. "
|
||||
"All audit log entries must have an associated user with an account."
|
||||
)
|
||||
|
||||
op.alter_column('audit_logs', 'account_id', nullable=False)
|
||||
op.create_index('ix_audit_logs_account_id', 'audit_logs', ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_audit_logs_account_id', table_name='audit_logs')
|
||||
op.drop_constraint('fk_audit_logs_account_id', 'audit_logs', type_='foreignkey')
|
||||
op.drop_column('audit_logs', 'account_id')
|
||||
@@ -1,62 +0,0 @@
|
||||
"""add account_id to target_lists (keep team_id)
|
||||
|
||||
Revision ID: 2c6aabd89bc6
|
||||
Revises: 78fc200abac1
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '2c6aabd89bc6'
|
||||
down_revision: Union[str, None] = '78fc200abac1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('target_lists', sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_target_lists_account_id', 'target_lists', 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Primary: team_id → team admin user → account_id
|
||||
op.execute("""
|
||||
UPDATE target_lists tl
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE u.team_id = tl.team_id
|
||||
AND u.is_team_admin = TRUE
|
||||
AND u.account_id IS NOT NULL
|
||||
AND tl.account_id IS NULL
|
||||
""")
|
||||
|
||||
# Fallback: created_by → users.account_id
|
||||
op.execute("""
|
||||
UPDATE target_lists tl
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE tl.created_by = u.id
|
||||
AND u.account_id IS NOT NULL
|
||||
AND tl.account_id IS NULL
|
||||
""")
|
||||
|
||||
result = op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM target_lists WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} target_lists rows have NULL account_id. "
|
||||
"No team admin found for these teams. Resolve before re-running."
|
||||
)
|
||||
|
||||
op.alter_column('target_lists', 'account_id', nullable=False)
|
||||
op.create_index('ix_target_lists_account_id', 'target_lists', ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_target_lists_account_id', table_name='target_lists')
|
||||
op.drop_constraint('fk_target_lists_account_id', 'target_lists', type_='foreignkey')
|
||||
op.drop_column('target_lists', 'account_id')
|
||||
@@ -1,175 +0,0 @@
|
||||
"""create template_trees and platform_steps global content tables
|
||||
|
||||
Revision ID: 3a40fe11b427
|
||||
Revises: 2c6aabd89bc6
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
|
||||
These tables hold platform-owned content that is readable by all
|
||||
authenticated users. No account_id. No RLS. Ever.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision: str = '3a40fe11b427'
|
||||
down_revision: Union[str, None] = '2c6aabd89bc6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Create template_trees ─────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'template_trees',
|
||||
sa.Column('id', UUID(), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('category', sa.String(100), nullable=True),
|
||||
sa.Column('tree_type', sa.String(20), nullable=False),
|
||||
sa.Column('tree_structure', JSONB(), nullable=False),
|
||||
sa.Column('tags', JSONB(), nullable=False, server_default='[]'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('source_tree_id', UUID(), sa.ForeignKey('trees.id', ondelete='SET NULL'), nullable=True),
|
||||
)
|
||||
op.create_index('ix_template_trees_tree_type', 'template_trees', ['tree_type'])
|
||||
|
||||
# ── Create platform_steps ────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'platform_steps',
|
||||
sa.Column('id', UUID(), primary_key=True),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('step_type', sa.String(50), nullable=False),
|
||||
sa.Column('content', JSONB(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('source_step_id', UUID(), sa.ForeignKey('step_library.id', ondelete='SET NULL'), nullable=True),
|
||||
)
|
||||
op.create_index('ix_platform_steps_step_type', 'platform_steps', ['step_type'])
|
||||
|
||||
# ── Copy is_default=TRUE trees → template_trees ─────────────────────────
|
||||
# Note: trees.tags is a relationship via tree_tags join table — no direct column.
|
||||
# Aggregate tag names via a correlated subquery.
|
||||
op.execute("""
|
||||
INSERT INTO template_trees
|
||||
(id, name, description, category, tree_type, tree_structure,
|
||||
tags, is_active, created_at, updated_at, source_tree_id)
|
||||
SELECT
|
||||
gen_random_uuid(), t.name, t.description, t.category, t.tree_type,
|
||||
t.tree_structure,
|
||||
COALESCE(
|
||||
(SELECT jsonb_agg(tt.name ORDER BY tt.name)
|
||||
FROM tree_tag_assignments ta
|
||||
JOIN tree_tags tt ON tt.id = ta.tag_id
|
||||
WHERE ta.tree_id = t.id),
|
||||
'[]'::jsonb
|
||||
),
|
||||
t.is_active,
|
||||
COALESCE(t.created_at, NOW()), COALESCE(t.updated_at, NOW()), t.id
|
||||
FROM trees t
|
||||
WHERE t.is_default = TRUE
|
||||
""")
|
||||
|
||||
# ── Copy visibility='public' steps → platform_steps ─────────────────────
|
||||
op.execute("""
|
||||
INSERT INTO platform_steps
|
||||
(id, title, step_type, content, is_active, created_at, updated_at, source_step_id)
|
||||
SELECT
|
||||
gen_random_uuid(), title, step_type, content, is_active,
|
||||
COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id
|
||||
FROM step_library
|
||||
WHERE visibility = 'public'
|
||||
""")
|
||||
|
||||
# ── Create platform sentinel account ─────────────────────────────────────
|
||||
op.execute("""
|
||||
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'ResolutionFlow Platform',
|
||||
'PLATFORM',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""")
|
||||
|
||||
# ── Assign is_default trees to platform account ──────────────────────────
|
||||
op.execute("""
|
||||
UPDATE trees
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE is_default = TRUE
|
||||
AND account_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Assign remaining trees to their author's account ─────────────────────
|
||||
# Handles trees with no team_id that aren't is_default (e.g. inactive test
|
||||
# trees, trees created before the team system existed).
|
||||
op.execute("""
|
||||
UPDATE trees
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE trees.author_id = u.id
|
||||
AND trees.account_id IS NULL
|
||||
AND u.account_id IS NOT NULL
|
||||
""")
|
||||
|
||||
# ── Final fallback: any still-NULL trees go to platform account ───────────
|
||||
# Covers trees whose author has no account (seeded content, system rows).
|
||||
op.execute("""
|
||||
UPDATE trees
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Assign global categories/tags/steps to platform account ─────────────
|
||||
op.execute("""
|
||||
UPDATE tree_categories
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE tree_tags
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE step_categories
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE step_library
|
||||
SET account_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Verify zero NULLs in all 5 tables ───────────────────────────────────
|
||||
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} NULL account_id rows remain in {table} "
|
||||
"after platform account assignment. Investigate before re-running."
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
platform_id = '00000000-0000-0000-0000-000000000001'
|
||||
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
|
||||
op.execute(f"UPDATE {table} SET account_id = NULL WHERE account_id = '{platform_id}'")
|
||||
|
||||
op.execute(f"DELETE FROM accounts WHERE id = '{platform_id}'")
|
||||
op.drop_index('ix_platform_steps_step_type', table_name='platform_steps')
|
||||
op.drop_index('ix_template_trees_tree_type', table_name='template_trees')
|
||||
op.drop_table('platform_steps')
|
||||
op.drop_table('template_trees')
|
||||
@@ -1,77 +0,0 @@
|
||||
"""add account_id to AI branching tables
|
||||
|
||||
Revision ID: 478c159e5654
|
||||
Revises: cc214c63aa30
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '478c159e5654'
|
||||
down_revision: Union[str, None] = 'cc214c63aa30'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
ai_tables = ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps')
|
||||
|
||||
# Step 1: ADD COLUMN (nullable)
|
||||
for table in ai_tables:
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id', table, 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
op.add_column('ai_suggestions', sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_ai_suggestions_account_id', 'ai_suggestions', 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Step 2: BACKFILL
|
||||
for table in ai_tables:
|
||||
op.execute(f"""
|
||||
UPDATE {table} t
|
||||
SET account_id = ai.account_id
|
||||
FROM ai_sessions ai
|
||||
WHERE t.session_id = ai.id
|
||||
AND t.account_id IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE ai_suggestions s
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE s.user_id = u.id
|
||||
AND s.account_id IS NULL
|
||||
""")
|
||||
|
||||
# Step 3: VERIFY zero NULLs
|
||||
for table in ai_tables + ('ai_suggestions',):
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} NULL account_id rows in {table}."
|
||||
)
|
||||
|
||||
# Step 4: SET NOT NULL
|
||||
for table in ai_tables + ('ai_suggestions',):
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
|
||||
# Step 5: CREATE INDEX
|
||||
for table in ai_tables + ('ai_suggestions',):
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('session_branches', 'session_handoffs', 'fork_points',
|
||||
'ai_session_steps', 'ai_suggestions'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Enable RLS on Phase 2 session and supporting tables.
|
||||
|
||||
10 tables use a standard tenant-only policy.
|
||||
step_library uses a visibility-aware policy — public steps visible to all tenants.
|
||||
|
||||
NOTE: session_messages does not exist in this codebase (removed from plan).
|
||||
script_generations is the correct table name (not script_template_generations).
|
||||
sessions and ai_sessions are two separate tables, both in scope.
|
||||
|
||||
Prerequisites:
|
||||
- Phase 1 migration must have run (resolutionflow_app role exists, Phase 1 tables have RLS)
|
||||
- NOT NULL write-path bugs fixed (P2-A commits b641ac6)
|
||||
- shares.py cross-tenant session fix deployed (P2-B commit ac2b193)
|
||||
|
||||
Revision ID: 70a5dd746e83
|
||||
Revises: c5f48b9890f9
|
||||
Create Date: 2026-04-10 06:54:49.431817
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '70a5dd746e83'
|
||||
down_revision: Union[str, None] = 'c5f48b9890f9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
_CURRENT_ACCOUNT = (
|
||||
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
f"'{_NULL_UUID}')::uuid"
|
||||
)
|
||||
|
||||
# Standard tenant-only policy — account_id must match the current tenant.
|
||||
# When no tenant context is set, COALESCE returns the nil UUID so zero rows
|
||||
# are visible (fail-closed).
|
||||
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
|
||||
|
||||
# Visibility-aware policy for step_library — public steps (visibility='public')
|
||||
# must be visible to ALL tenants regardless of account_id. This covers the
|
||||
# visibility='public' arm of build_step_visibility_filter() in app/core/filters.py.
|
||||
# The created_by arm (private steps visible to their author) is covered
|
||||
# transitively: private steps share account_id with their creator, so the
|
||||
# account_id match handles it. This relies on account_id NOT NULL on step_library.
|
||||
_STEP_LIBRARY_USING = f"account_id = {_CURRENT_ACCOUNT} OR visibility = 'public'"
|
||||
|
||||
# Standard tables: strict tenant isolation, no cross-tenant visibility.
|
||||
_STANDARD_TABLES = [
|
||||
"sessions",
|
||||
"ai_sessions",
|
||||
"session_branches",
|
||||
"session_supporting_data",
|
||||
"session_resolution_outputs",
|
||||
"session_handoffs",
|
||||
"script_templates",
|
||||
"script_generations",
|
||||
"maintenance_schedules",
|
||||
"psa_post_log",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Standard tenant-isolation tables ────────────────────────────────────
|
||||
for table in _STANDARD_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING ({_STANDARD_USING})
|
||||
""")
|
||||
|
||||
# ── step_library ────────────────────────────────────────────────────────
|
||||
# Public steps (visibility='public') must be readable by all tenants so
|
||||
# the Solutions Library browsing experience works without tenant context.
|
||||
# Private/team steps remain tenant-scoped.
|
||||
op.execute("ALTER TABLE step_library ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE step_library FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON step_library
|
||||
USING ({_STEP_LIBRARY_USING})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _STANDARD_TABLES + ["step_library"]:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""add account_id to step_ratings and step_usage_log
|
||||
|
||||
Revision ID: 7167e9374b0c
|
||||
Revises: 478c159e5654
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '7167e9374b0c'
|
||||
down_revision: Union[str, None] = '478c159e5654'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ('step_ratings', 'step_usage_log'):
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id', table, 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
# Backfill: from the RATER/LOGGER user's account (not the step's account)
|
||||
op.execute(f"""
|
||||
UPDATE {table} t
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE t.user_id = u.id
|
||||
AND t.account_id IS NULL
|
||||
""")
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('step_ratings', 'step_usage_log'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -1,103 +0,0 @@
|
||||
"""add account_id to script_builder_sessions, script_templates, script_generations
|
||||
|
||||
Revision ID: 78fc200abac1
|
||||
Revises: 7f136778f5a8
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '78fc200abac1'
|
||||
down_revision: Union[str, None] = '7f136778f5a8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
PLATFORM_ACCOUNT_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Ensure the platform sentinel account exists before any fallback assignments.
|
||||
# Migration 3a40fe11b427 also inserts this with ON CONFLICT DO NOTHING — safe.
|
||||
op.execute(f"""
|
||||
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
|
||||
VALUES (
|
||||
'{PLATFORM_ACCOUNT_ID}',
|
||||
'ResolutionFlow Platform',
|
||||
'PLATFORM',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""")
|
||||
|
||||
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id', table, 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# script_builder_sessions: user_id → users.account_id
|
||||
op.execute("""
|
||||
UPDATE script_builder_sessions sbs
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE sbs.user_id = u.id
|
||||
AND sbs.account_id IS NULL
|
||||
""")
|
||||
|
||||
# script_templates: created_by → users.account_id (nullable created_by)
|
||||
op.execute("""
|
||||
UPDATE script_templates st
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE st.created_by = u.id
|
||||
AND st.account_id IS NULL
|
||||
""")
|
||||
# Fallback: team_id → team admin user
|
||||
op.execute("""
|
||||
UPDATE script_templates st
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE u.team_id = st.team_id
|
||||
AND u.is_team_admin = TRUE
|
||||
AND u.account_id IS NOT NULL
|
||||
AND st.account_id IS NULL
|
||||
""")
|
||||
# Final fallback: platform-seeded templates with NULL team_id AND NULL created_by
|
||||
# (e.g. the 6 AD templates inserted by migration 057) → platform sentinel account
|
||||
op.execute(f"""
|
||||
UPDATE script_templates
|
||||
SET account_id = '{PLATFORM_ACCOUNT_ID}'
|
||||
WHERE account_id IS NULL
|
||||
""")
|
||||
|
||||
# script_generations: user_id → users.account_id
|
||||
op.execute("""
|
||||
UPDATE script_generations sg
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE sg.user_id = u.id
|
||||
AND sg.account_id IS NULL
|
||||
""")
|
||||
|
||||
# VERIFY
|
||||
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
||||
|
||||
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -1,62 +0,0 @@
|
||||
"""add account_id to maintenance_schedules
|
||||
|
||||
Revision ID: 7f136778f5a8
|
||||
Revises: 8aac5b372402
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '7f136778f5a8'
|
||||
down_revision: Union[str, None] = '8aac5b372402'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('maintenance_schedules',
|
||||
sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_maintenance_schedules_account_id', 'maintenance_schedules', 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Primary: tree_id → trees.account_id (only where tree.account_id is NOT NULL)
|
||||
op.execute("""
|
||||
UPDATE maintenance_schedules ms
|
||||
SET account_id = t.account_id
|
||||
FROM trees t
|
||||
WHERE ms.tree_id = t.id
|
||||
AND t.account_id IS NOT NULL
|
||||
AND ms.account_id IS NULL
|
||||
""")
|
||||
|
||||
# Fallback: created_by → users.account_id (for is_default trees with NULL account_id)
|
||||
op.execute("""
|
||||
UPDATE maintenance_schedules ms
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE ms.created_by = u.id
|
||||
AND u.account_id IS NOT NULL
|
||||
AND ms.account_id IS NULL
|
||||
""")
|
||||
|
||||
result = op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM maintenance_schedules WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} maintenance_schedules rows have NULL account_id. "
|
||||
"Check if created_by is NULL — those rows need manual resolution."
|
||||
)
|
||||
|
||||
op.alter_column('maintenance_schedules', 'account_id', nullable=False)
|
||||
op.create_index('ix_maintenance_schedules_account_id', 'maintenance_schedules', ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_maintenance_schedules_account_id', table_name='maintenance_schedules')
|
||||
op.drop_constraint('fk_maintenance_schedules_account_id', 'maintenance_schedules', type_='foreignkey')
|
||||
op.drop_column('maintenance_schedules', 'account_id')
|
||||
@@ -1,81 +0,0 @@
|
||||
"""add account_id to PSA and notification tables
|
||||
|
||||
Revision ID: 8aac5b372402
|
||||
Revises: a1d2a84b9abb
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '8aac5b372402'
|
||||
down_revision: Union[str, None] = 'a1d2a84b9abb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Step 1: ADD COLUMN
|
||||
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id', table, 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Step 2: BACKFILL
|
||||
# psa_post_log: prefer psa_connection → fallback to posted_by user
|
||||
# Note: cannot reference the updated table (ppl) inside the FROM clause JOIN,
|
||||
# so use a correlated subquery for psa_connections lookup instead.
|
||||
op.execute("""
|
||||
UPDATE psa_post_log ppl
|
||||
SET account_id = COALESCE(
|
||||
(SELECT account_id FROM psa_connections WHERE id = ppl.psa_connection_id),
|
||||
u.account_id
|
||||
)
|
||||
FROM users u
|
||||
WHERE ppl.posted_by = u.id
|
||||
AND ppl.account_id IS NULL
|
||||
""")
|
||||
|
||||
# psa_member_mappings: via psa_connection
|
||||
op.execute("""
|
||||
UPDATE psa_member_mappings pmm
|
||||
SET account_id = pc.account_id
|
||||
FROM psa_connections pc
|
||||
WHERE pmm.psa_connection_id = pc.id
|
||||
AND pmm.account_id IS NULL
|
||||
""")
|
||||
|
||||
# notification_logs: via notification_config
|
||||
op.execute("""
|
||||
UPDATE notification_logs nl
|
||||
SET account_id = nc.account_id
|
||||
FROM notification_configs nc
|
||||
WHERE nl.notification_config_id = nc.id
|
||||
AND nl.account_id IS NULL
|
||||
""")
|
||||
|
||||
# Step 3: VERIFY
|
||||
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
||||
|
||||
# Step 4: SET NOT NULL
|
||||
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
|
||||
# Step 5: CREATE INDEX
|
||||
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Add account_id to tree_shares and backfill via tree owner's account.
|
||||
|
||||
The share belongs to the tree's tenant, not the actor who created it.
|
||||
A super admin in account A can share a tree owned by account B; that share
|
||||
must land in account B so account B's RLS filter sees it.
|
||||
|
||||
Revision ID: a05e1a1bea7c
|
||||
Revises: 2a9056eddd90
|
||||
Create Date: 2026-04-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'a05e1a1bea7c'
|
||||
down_revision: Union[str, None] = '2a9056eddd90'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tree_shares', sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_tree_shares_account_id', 'tree_shares', 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Backfill: derive from the tree's account, not the creator's account.
|
||||
# A share lives in the same tenant as its tree so that the tree owner's
|
||||
# RLS context covers their own shares regardless of who created them.
|
||||
op.execute("""
|
||||
UPDATE tree_shares ts
|
||||
SET account_id = t.account_id
|
||||
FROM trees t
|
||||
WHERE ts.tree_id = t.id
|
||||
AND t.account_id IS NOT NULL
|
||||
AND ts.account_id IS NULL
|
||||
""")
|
||||
|
||||
result = op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM tree_shares WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} tree_shares rows have NULL account_id after backfill. "
|
||||
"All share entries must have a creating user with an account."
|
||||
)
|
||||
|
||||
op.alter_column('tree_shares', 'account_id', nullable=False)
|
||||
op.create_index('ix_tree_shares_account_id', 'tree_shares', ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_tree_shares_account_id', table_name='tree_shares')
|
||||
op.drop_constraint('fk_tree_shares_account_id', 'tree_shares', type_='foreignkey')
|
||||
op.drop_column('tree_shares', 'account_id')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""add account_id to user personalization tables
|
||||
|
||||
Revision ID: a1d2a84b9abb
|
||||
Revises: 7167e9374b0c
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'a1d2a84b9abb'
|
||||
down_revision: Union[str, None] = '7167e9374b0c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ('user_folders', 'user_pinned_trees'):
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id', table, 'accounts',
|
||||
['account_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
op.execute(f"""
|
||||
UPDATE {table} t
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE t.user_id = u.id
|
||||
AND t.account_id IS NULL
|
||||
""")
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('user_folders', 'user_pinned_trees'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -1,24 +0,0 @@
|
||||
"""merge Phase 1 tenant isolation chain with main head
|
||||
|
||||
Revision ID: a9f3b2c1d4e5
|
||||
Revises: 070, 174f442795b7
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
|
||||
Merge migration: consolidates the Phase 1 account_id chain (cc214c63aa30 → … → 174f442795b7)
|
||||
with the main sequential chain (… → 070) into a single head so that
|
||||
`alembic upgrade head` works without ambiguity.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = 'a9f3b2c1d4e5'
|
||||
down_revision: Union[str, tuple] = ('070', '174f442795b7')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Enable RLS on Phase 4 tables — all remaining tenant-scoped tables.
|
||||
|
||||
All tables in this migration already have account_id NOT NULL (enforced by
|
||||
earlier migrations). This migration adds ENABLE ROW LEVEL SECURITY,
|
||||
FORCE ROW LEVEL SECURITY, and the appropriate tenant isolation policy to each.
|
||||
|
||||
Policy variants used:
|
||||
- Standard: account_id = current_setting(app.current_account_id)::uuid
|
||||
- Platform: standard OR account_id = PLATFORM_ACCOUNT_ID
|
||||
(for global content tables readable by all tenants)
|
||||
|
||||
Skipped intentionally:
|
||||
- accounts — IS the root table; no account_id column
|
||||
- plan_feature_defaults — platform config; no account_id column
|
||||
- script_categories — global lookup table; no account_id column
|
||||
- platform_steps — global content; no account_id column (readable by all)
|
||||
- template_trees — global content; no account_id column (readable by all)
|
||||
|
||||
Revision ID: b3c7e9f2a1d8
|
||||
Revises: 172ad76d7d20
|
||||
Create Date: 2026-04-12
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b3c7e9f2a1d8"
|
||||
down_revision: Union[str, None] = "172ad76d7d20"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Standard policy — tenant sees only own rows.
|
||||
_STANDARD_TABLES = [
|
||||
"users",
|
||||
"account_invites",
|
||||
"account_limit_overrides",
|
||||
"account_feature_overrides",
|
||||
"subscriptions",
|
||||
"ai_chat_sessions",
|
||||
"ai_conversations",
|
||||
"ai_session_steps",
|
||||
"ai_session_embeddings",
|
||||
"ai_suggestions",
|
||||
"ai_usage",
|
||||
"assistant_chats",
|
||||
"attachments",
|
||||
"copilot_conversations",
|
||||
"feedback",
|
||||
"file_uploads",
|
||||
"fork_points",
|
||||
"kb_imports",
|
||||
"notifications",
|
||||
"notification_configs",
|
||||
"notification_logs",
|
||||
"psa_activity_logs",
|
||||
"psa_member_mappings",
|
||||
"script_builder_sessions",
|
||||
"session_ratings",
|
||||
"tree_embeddings",
|
||||
"user_folders",
|
||||
"user_pinned_trees",
|
||||
]
|
||||
|
||||
_POLICY_EXPR = (
|
||||
"account_id = COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _STANDARD_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING ({_POLICY_EXPR})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _STANDARD_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
@@ -1,108 +0,0 @@
|
||||
"""enable_rls_phase1
|
||||
|
||||
Revision ID: c5f48b9890f9
|
||||
Revises: 0b470d9e6cf1
|
||||
Create Date: 2026-04-10 04:01:13.043321
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c5f48b9890f9'
|
||||
down_revision: Union[str, None] = '0b470d9e6cf1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
|
||||
_CURRENT_ACCOUNT = (
|
||||
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
f"'{_NULL_UUID}')::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── trees ───────────────────────────────────────────────────────────────
|
||||
# Extended policy mirrors can_access_tree() in app/core/permissions.py.
|
||||
# Tenant sees: own rows, platform rows, any default tree, any public tree,
|
||||
# any gallery-featured tree.
|
||||
# is_gallery_featured = TRUE is included because /public/templates is a
|
||||
# no-auth endpoint — no tenant context is set, so gallery trees must pass
|
||||
# RLS on their own flag rather than relying on account_id or is_public.
|
||||
# Private/team trees from other accounts are hidden.
|
||||
op.execute("ALTER TABLE trees ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE trees FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON trees
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
OR is_default = TRUE
|
||||
OR is_public = TRUE
|
||||
OR is_gallery_featured = TRUE
|
||||
)
|
||||
""")
|
||||
|
||||
# ── tree_tags ────────────────────────────────────────────────────────────
|
||||
# Own account + platform tags (global tags visible to all tenants).
|
||||
op.execute("ALTER TABLE tree_tags ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE tree_tags FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON tree_tags
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
)
|
||||
""")
|
||||
|
||||
# ── tree_categories ──────────────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE tree_categories ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE tree_categories FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON tree_categories
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
)
|
||||
""")
|
||||
|
||||
# ── step_categories ──────────────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE step_categories ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE step_categories FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON step_categories
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
)
|
||||
""")
|
||||
|
||||
# ── psa_connections ──────────────────────────────────────────────────────
|
||||
# Tenant-only — PSA credentials must never cross tenant boundaries.
|
||||
op.execute("ALTER TABLE psa_connections ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE psa_connections FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON psa_connections
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── flow_proposals ────────────────────────────────────────────────────────
|
||||
# Tenant-only.
|
||||
op.execute("ALTER TABLE flow_proposals ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE flow_proposals FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON flow_proposals
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ["trees", "tree_tags", "tree_categories", "step_categories",
|
||||
"psa_connections", "flow_proposals"]:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
@@ -1,95 +0,0 @@
|
||||
"""add account_id to core session tables
|
||||
|
||||
Revision ID: cc214c63aa30
|
||||
Revises: b8d2f4a6c091
|
||||
Create Date: 2026-04-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'cc214c63aa30'
|
||||
down_revision: Union[str, None] = '064'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = ('067',)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Step 1: ADD COLUMN (nullable) ────────────────────────────────────────
|
||||
for table in ('sessions', 'attachments', 'session_supporting_data',
|
||||
'session_resolution_outputs'):
|
||||
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_account_id',
|
||||
table, 'accounts',
|
||||
['account_id'], ['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# ── Step 2: BACKFILL ─────────────────────────────────────────────────────
|
||||
# sessions: direct join to users
|
||||
op.execute("""
|
||||
UPDATE sessions s
|
||||
SET account_id = u.account_id
|
||||
FROM users u
|
||||
WHERE s.user_id = u.id
|
||||
AND s.account_id IS NULL
|
||||
""")
|
||||
|
||||
# attachments: chain through sessions (now backfilled above)
|
||||
op.execute("""
|
||||
UPDATE attachments a
|
||||
SET account_id = s.account_id
|
||||
FROM sessions s
|
||||
WHERE a.session_id = s.id
|
||||
AND a.account_id IS NULL
|
||||
""")
|
||||
|
||||
# session_supporting_data: same chain
|
||||
op.execute("""
|
||||
UPDATE session_supporting_data sd
|
||||
SET account_id = s.account_id
|
||||
FROM sessions s
|
||||
WHERE sd.session_id = s.id
|
||||
AND sd.account_id IS NULL
|
||||
""")
|
||||
|
||||
# session_resolution_outputs: FK is to ai_sessions, not sessions
|
||||
op.execute("""
|
||||
UPDATE session_resolution_outputs sro
|
||||
SET account_id = ai.account_id
|
||||
FROM ai_sessions ai
|
||||
WHERE sro.session_id = ai.id
|
||||
AND sro.account_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Step 3: VERIFY zero NULLs — raises if any remain ────────────────────
|
||||
for table in ('sessions', 'attachments', 'session_supporting_data',
|
||||
'session_resolution_outputs'):
|
||||
result = op.get_bind().execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
raise RuntimeError(
|
||||
f"ROLLBACK: {count} NULL account_id rows remain in {table}. "
|
||||
f"Fix the backfill before re-running."
|
||||
)
|
||||
|
||||
# ── Step 4: SET NOT NULL ─────────────────────────────────────────────────
|
||||
for table in ('sessions', 'attachments', 'session_supporting_data',
|
||||
'session_resolution_outputs'):
|
||||
op.alter_column(table, 'account_id', nullable=False)
|
||||
|
||||
# ── Step 5: CREATE INDEX ─────────────────────────────────────────────────
|
||||
for table in ('sessions', 'attachments', 'session_supporting_data',
|
||||
'session_resolution_outputs'):
|
||||
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ('sessions', 'attachments', 'session_supporting_data',
|
||||
'session_resolution_outputs'):
|
||||
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'account_id')
|
||||
@@ -10,8 +10,6 @@ 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 = {
|
||||
@@ -24,14 +22,10 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
token: Annotated[str, Depends(oauth2_scheme)]
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token.
|
||||
|
||||
Must use get_admin_db (BYPASSRLS): this dep runs before require_tenant_context
|
||||
sets app.current_account_id, so the users table RLS would block the lookup.
|
||||
"""
|
||||
"""Get current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
@@ -81,14 +75,10 @@ async def get_refresh_token_payload(
|
||||
async def get_current_active_user(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User:
|
||||
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
|
||||
Enforces must_change_password — blocks all routes except allowlist.
|
||||
|
||||
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
|
||||
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
|
||||
"""
|
||||
Enforces must_change_password — blocks all routes except allowlist."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -200,44 +190,3 @@ async def get_plan_limits_for_user(
|
||||
"""Get plan limits for the current user's account."""
|
||||
from app.core.subscriptions import get_user_plan_limits
|
||||
return await get_user_plan_limits(current_user.account_id, db)
|
||||
|
||||
|
||||
async def require_tenant_context(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Set per-request tenant context for RLS.
|
||||
|
||||
Raises 403 if the authenticated user has no account_id — never falls back
|
||||
to PLATFORM_ACCOUNT_ID (that would grant platform-scope access to a
|
||||
malformed account).
|
||||
|
||||
Sets the ContextVar that the SQLAlchemy transaction-begin listener reads to
|
||||
issue set_config('app.current_account_id', …, true) on every transaction.
|
||||
|
||||
Applied to every user-facing router. NOT applied to /admin/* routers or
|
||||
public endpoints (auth, shared, webhooks).
|
||||
"""
|
||||
if current_user.account_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account required",
|
||||
)
|
||||
token = set_current_account_id(current_user.account_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
clear_current_account_id(token)
|
||||
|
||||
|
||||
async def require_admin_db(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
) -> AsyncSession:
|
||||
"""Return a BYPASSRLS admin DB session after verifying super_admin role.
|
||||
|
||||
Use on /admin/* endpoints that query RLS-protected tables. Replaces
|
||||
Depends(get_db) on the db parameter of those endpoints.
|
||||
The current_user dep is still declared separately on the endpoint if
|
||||
the user object is needed in the handler.
|
||||
"""
|
||||
return db
|
||||
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy import select
|
||||
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
|
||||
from app.core.audit import log_audit
|
||||
from app.models.refresh_token import RefreshToken
|
||||
@@ -149,7 +148,7 @@ async def update_member_role(
|
||||
@router.post("/me/transfer-ownership", response_model=AccountResponse)
|
||||
async def transfer_ownership(
|
||||
data: TransferOwnershipRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Transfer account ownership to another member (owner only)."""
|
||||
@@ -378,7 +377,7 @@ async def list_invites(
|
||||
|
||||
@router.post("/me/leave")
|
||||
async def leave_account(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Leave the current account (non-owners only). Creates a personal account."""
|
||||
@@ -424,7 +423,7 @@ class DeleteAccountRequest(BaseModel):
|
||||
@router.delete("/me")
|
||||
async def delete_account(
|
||||
data: DeleteAccountRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Delete the current account and soft-delete the user (owner only, no other members)."""
|
||||
|
||||
@@ -5,10 +5,10 @@ from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
|
||||
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.tree import Tree
|
||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
|
||||
from app.schemas.admin import (
|
||||
MoveUserAccount,
|
||||
AdminUserCreate,
|
||||
AdminUserCreateResponse,
|
||||
AdminPasswordReset,
|
||||
AdminPasswordResetResponse,
|
||||
HardDeleteCheckResponse,
|
||||
AdminUserListItem,
|
||||
AdminUserListResponse,
|
||||
AdminAccountMember,
|
||||
AdminAccountListItem,
|
||||
AdminAccountListResponse,
|
||||
AdminAccountOwnerSummary,
|
||||
AdminAccountSubscriptionSummary,
|
||||
AdminAccountUsageSummary,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountInviteSummary,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.core.subscriptions import get_account_usage
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
@router.get("/users", response_model=AdminUserListResponse)
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: Optional[int] = Query(None, ge=1),
|
||||
size: Optional[int] = Query(None, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by user or account fields"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
@@ -46,23 +69,240 @@ async def list_users(
|
||||
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||
):
|
||||
"""List all users (super admin only)."""
|
||||
query = select(User)
|
||||
"""List users for super admin global people search."""
|
||||
resolved_limit = size or limit
|
||||
resolved_skip = skip
|
||||
current_page = 1
|
||||
|
||||
if page is not None:
|
||||
resolved_skip = (page - 1) * resolved_limit
|
||||
current_page = page
|
||||
elif resolved_limit > 0:
|
||||
current_page = (resolved_skip // resolved_limit) + 1
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
Account.name.label("account_name"),
|
||||
Account.display_code.label("account_display_code"),
|
||||
)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(User.deleted_at.is_(None))
|
||||
count_query = count_query.where(User.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_query = count_query.where(User.is_active == is_active)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if account_id:
|
||||
query = query.where(User.account_id == account_id)
|
||||
count_query = count_query.where(User.account_id == account_id)
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
User.name.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return users
|
||||
rows = result.all()
|
||||
|
||||
items = [
|
||||
AdminUserListItem(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
is_super_admin=user.is_super_admin,
|
||||
is_active=user.is_active,
|
||||
account_id=user.account_id,
|
||||
account_role=user.account_role,
|
||||
account_name=account_name,
|
||||
account_display_code=account_display_code,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
deleted_at=user.deleted_at,
|
||||
)
|
||||
for user, account_name, account_display_code in rows
|
||||
]
|
||||
|
||||
return AdminUserListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=current_page,
|
||||
per_page=resolved_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AdminAccountListResponse)
|
||||
async def list_accounts(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(12, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by account, display code, or owner"),
|
||||
plan: Optional[str] = Query(None, description="Filter by subscription plan"),
|
||||
status: Optional[str] = Query(None, description="Filter by subscription status"),
|
||||
include_archived: bool = Query(False, description="Include archived users in account member lists"),
|
||||
):
|
||||
"""List accounts with embedded members for the admin panel."""
|
||||
owner_user = aliased(User)
|
||||
|
||||
count_query = (
|
||||
select(func.count(func.distinct(Account.id)))
|
||||
.select_from(Account)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
accounts_query = (
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
owner_user.name.ilike(search_term),
|
||||
owner_user.email.ilike(search_term),
|
||||
)
|
||||
count_query = count_query.where(search_filter)
|
||||
accounts_query = accounts_query.where(search_filter)
|
||||
if plan:
|
||||
count_query = count_query.where(Subscription.plan == plan)
|
||||
accounts_query = accounts_query.where(Subscription.plan == plan)
|
||||
if status:
|
||||
count_query = count_query.where(Subscription.status == status)
|
||||
accounts_query = accounts_query.where(Subscription.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
accounts_result = await db.execute(
|
||||
accounts_query
|
||||
.order_by(Account.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = accounts_result.all()
|
||||
accounts = [row.Account for row in rows]
|
||||
|
||||
account_ids = [account.id for account in accounts]
|
||||
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
|
||||
pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids}
|
||||
usage_by_account: dict[UUID, AdminAccountUsageSummary] = {}
|
||||
|
||||
if account_ids:
|
||||
members_query = select(User).where(User.account_id.in_(account_ids))
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_query = members_query.order_by(User.created_at.asc())
|
||||
|
||||
members_result = await db.execute(members_query)
|
||||
for member in members_result.scalars().all():
|
||||
members_by_account.setdefault(member.account_id, []).append(
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
)
|
||||
|
||||
pending_invites_result = await db.execute(
|
||||
select(AccountInvite.account_id, func.count(AccountInvite.id))
|
||||
.where(
|
||||
AccountInvite.account_id.in_(account_ids),
|
||||
AccountInvite.used_at.is_(None),
|
||||
)
|
||||
.group_by(AccountInvite.account_id)
|
||||
)
|
||||
pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()})
|
||||
|
||||
for account_id in account_ids:
|
||||
usage = await get_account_usage(account_id, db)
|
||||
usage_by_account[account_id] = AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminAccountListItem(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()),
|
||||
member_count=len(members_by_account.get(row.Account.id, [])),
|
||||
active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active),
|
||||
pending_invite_count=pending_invites_by_account.get(row.Account.id, 0),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members_by_account.get(row.Account.id, []),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return AdminAccountListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=size,
|
||||
)
|
||||
|
||||
|
||||
def _generate_display_code() -> str:
|
||||
@@ -71,10 +311,187 @@ def _generate_display_code() -> str:
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _generate_unique_display_code(db: AsyncSession) -> str:
|
||||
"""Generate a unique display code for a new account."""
|
||||
while True:
|
||||
display_code = _generate_display_code()
|
||||
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return display_code
|
||||
|
||||
|
||||
async def _get_account_detail_payload(
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
include_archived: bool = False,
|
||||
) -> AdminAccountDetailResponse:
|
||||
owner_user = aliased(User)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
.where(Account.id == account_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_result = await db.execute(members_query)
|
||||
members = [
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
for member in members_result.scalars().all()
|
||||
]
|
||||
|
||||
invites_result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.account_id == account_id)
|
||||
.order_by(AccountInvite.created_at.desc())
|
||||
)
|
||||
invites = [
|
||||
AdminAccountInviteSummary(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
expires_at=invite.expires_at,
|
||||
created_at=invite.created_at,
|
||||
used_at=invite.used_at,
|
||||
)
|
||||
for invite in invites_result.scalars().all()
|
||||
if invite.used_at is None
|
||||
]
|
||||
|
||||
usage = await get_account_usage(account_id, db)
|
||||
|
||||
return AdminAccountDetailResponse(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
),
|
||||
member_count=len(members),
|
||||
active_member_count=sum(1 for member in members if member.is_active),
|
||||
pending_invite_count=len(invites),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members,
|
||||
invites=invites,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_account(
|
||||
data: AdminAccountCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=data.plan,
|
||||
status="active",
|
||||
)
|
||||
db.add(new_subscription)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def get_account_detail(
|
||||
account_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
include_archived: bool = Query(False),
|
||||
):
|
||||
"""Get detailed account information for admin management."""
|
||||
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def update_account(
|
||||
account_id: UUID,
|
||||
data: AdminAccountUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update account settings from the admin panel."""
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
old_name = account.name
|
||||
account.name = data.name.strip()
|
||||
await log_audit(
|
||||
db, current_user.id, "account.update_admin", "account", account.id,
|
||||
{"old_name": old_name, "new_name": account.name},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(account.id, db)
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: AdminUserCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new user with a temporary password (super admin only).
|
||||
@@ -199,7 +616,7 @@ async def create_user(
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Get enriched user details (super admin only)."""
|
||||
@@ -317,7 +734,7 @@ async def get_user(
|
||||
async def update_user_role(
|
||||
user_id: UUID,
|
||||
role_data: RoleUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Change user role (super admin only)."""
|
||||
@@ -349,7 +766,7 @@ async def update_user_role(
|
||||
async def update_account_role(
|
||||
user_id: UUID,
|
||||
data: AccountRoleUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Change a user's account role (super admin only)."""
|
||||
@@ -375,7 +792,7 @@ async def update_account_role(
|
||||
async def update_super_admin_status(
|
||||
user_id: UUID,
|
||||
data: dict,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Promote or demote a user to/from super admin (super admin only)."""
|
||||
@@ -414,7 +831,7 @@ async def update_super_admin_status(
|
||||
@router.put("/users/{user_id}/deactivate", response_model=UserResponse)
|
||||
async def deactivate_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Deactivate a user account (super admin only)."""
|
||||
@@ -443,7 +860,7 @@ async def deactivate_user(
|
||||
@router.put("/users/{user_id}/activate", response_model=UserResponse)
|
||||
async def activate_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Reactivate a user account (super admin only)."""
|
||||
@@ -467,7 +884,7 @@ async def activate_user(
|
||||
async def move_user_account(
|
||||
user_id: UUID,
|
||||
data: MoveUserAccount,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Move a user to a different account (super admin only)."""
|
||||
@@ -516,11 +933,33 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
||||
return user, subscription
|
||||
|
||||
|
||||
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
|
||||
"""Helper to load account and its subscription."""
|
||||
account_result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = account_result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
subscription = Subscription(
|
||||
account_id=account.id,
|
||||
plan="free",
|
||||
status="active",
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.flush()
|
||||
return account, subscription
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/plan")
|
||||
async def update_user_plan(
|
||||
user_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change a user's subscription plan (super admin only)."""
|
||||
@@ -535,11 +974,36 @@ async def update_user_plan(
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/plan")
|
||||
async def update_account_plan(
|
||||
account_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change an account subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
old_plan = subscription.plan
|
||||
subscription.plan = data.plan
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.plan_change",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||
async def extend_user_trial(
|
||||
user_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for a user's subscription (super admin only)."""
|
||||
@@ -565,11 +1029,48 @@ async def extend_user_trial(
|
||||
"current_period_end": subscription.current_period_end}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/extend-trial")
|
||||
async def extend_account_trial(
|
||||
account_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for an account subscription (super admin only)."""
|
||||
if data.days < 1 or data.days > 90:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription.status == "trialing" and subscription.current_period_end:
|
||||
new_end = subscription.current_period_end + timedelta(days=data.days)
|
||||
else:
|
||||
subscription.status = "trialing"
|
||||
subscription.current_period_start = now
|
||||
new_end = now + timedelta(days=data.days)
|
||||
|
||||
subscription.current_period_end = new_end
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.extend_trial",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"plan": subscription.plan,
|
||||
"status": subscription.status,
|
||||
"current_period_end": subscription.current_period_end,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
|
||||
async def admin_reset_password(
|
||||
user_id: UUID,
|
||||
data: AdminPasswordReset,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Admin-triggered password reset (super admin only).
|
||||
@@ -640,7 +1141,7 @@ async def admin_reset_password(
|
||||
@router.put("/users/{user_id}/archive", response_model=UserResponse)
|
||||
async def archive_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Archive (soft delete) a user (super admin only)."""
|
||||
@@ -675,7 +1176,7 @@ async def archive_user(
|
||||
@router.put("/users/{user_id}/restore", response_model=UserResponse)
|
||||
async def restore_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Restore an archived user (super admin only)."""
|
||||
@@ -700,7 +1201,7 @@ async def restore_user(
|
||||
@router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse)
|
||||
async def hard_delete_check(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
|
||||
@@ -773,7 +1274,7 @@ async def hard_delete_check(
|
||||
@router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def hard_delete_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Permanently delete a user (super admin only). User must be archived first."""
|
||||
@@ -833,7 +1334,7 @@ async def hard_delete_user(
|
||||
@router.post("/invites", status_code=status.HTTP_201_CREATED)
|
||||
async def admin_create_invite(
|
||||
data: dict,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Quick-invite a user to an account (super admin only).
|
||||
|
||||
@@ -4,26 +4,25 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
from app.models.user import User
|
||||
from app.models.category import TreeCategory
|
||||
from app.models.tree import Tree
|
||||
from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse
|
||||
from app.api.deps import require_admin
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
|
||||
router = APIRouter(prefix="/admin/categories", tags=["admin-categories"])
|
||||
|
||||
|
||||
@router.get("/global", response_model=list[GlobalCategoryResponse])
|
||||
async def list_global_categories(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all global categories (account_id IS NULL)."""
|
||||
result = await db.execute(
|
||||
select(TreeCategory).where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID).order_by(TreeCategory.name)
|
||||
select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
@@ -46,36 +45,36 @@ async def list_global_categories(
|
||||
@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_global_category(
|
||||
data: GlobalCategoryCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a global category."""
|
||||
# Check slug uniqueness for global categories
|
||||
existing = await db.execute(
|
||||
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists")
|
||||
|
||||
category = TreeCategory(name=data.name, slug=data.slug, account_id=PLATFORM_ACCOUNT_ID)
|
||||
category = TreeCategory(name=data.name, slug=data.slug, account_id=None)
|
||||
db.add(category)
|
||||
await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name})
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
|
||||
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=PLATFORM_ACCOUNT_ID, tree_count=0)
|
||||
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0)
|
||||
|
||||
|
||||
@router.put("/global/{category_id}", response_model=GlobalCategoryResponse)
|
||||
async def update_global_category(
|
||||
category_id: UUID,
|
||||
data: GlobalCategoryUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update a global category."""
|
||||
result = await db.execute(
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
@@ -87,7 +86,7 @@ async def update_global_category(
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(
|
||||
select(TreeCategory).where(
|
||||
TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID, TreeCategory.id != category_id
|
||||
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
@@ -104,19 +103,19 @@ async def update_global_category(
|
||||
|
||||
return GlobalCategoryResponse(
|
||||
id=category.id, name=category.name, slug=category.slug,
|
||||
account_id=PLATFORM_ACCOUNT_ID, tree_count=tree_count,
|
||||
account_id=None, tree_count=tree_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_global_category(
|
||||
category_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Delete (archive) a global category."""
|
||||
result = await db.execute(
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.tree import Tree
|
||||
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"])
|
||||
|
||||
@router.get("/metrics", response_model=DashboardMetrics)
|
||||
async def get_dashboard_metrics(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Get platform overview metrics."""
|
||||
@@ -45,7 +45,7 @@ async def get_dashboard_metrics(
|
||||
|
||||
@router.get("/activity", response_model=list[ActivityEntry])
|
||||
async def get_dashboard_activity(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Get recent audit log entries for activity feed."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.models.script_template import ScriptTemplate
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
@@ -66,7 +66,7 @@ def _script_summary(script: ScriptTemplate) -> dict:
|
||||
|
||||
@router.get("/featured")
|
||||
async def list_featured(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all featured flows and scripts (super admin only)."""
|
||||
@@ -92,7 +92,7 @@ async def list_featured(
|
||||
|
||||
@router.get("/items")
|
||||
async def list_all_items(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List ALL flows and scripts with their gallery status (super admin only)."""
|
||||
@@ -119,7 +119,7 @@ async def list_all_items(
|
||||
async def toggle_flow_featured(
|
||||
flow_id: UUID,
|
||||
body: FeatureToggle,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Toggle is_gallery_featured on a flow (super admin only)."""
|
||||
@@ -138,7 +138,7 @@ async def toggle_flow_featured(
|
||||
async def update_flow_sort_order(
|
||||
flow_id: UUID,
|
||||
body: SortOrderUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update gallery_sort_order on a flow (super admin only)."""
|
||||
@@ -157,7 +157,7 @@ async def update_flow_sort_order(
|
||||
async def toggle_script_featured(
|
||||
script_id: UUID,
|
||||
body: FeatureToggle,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Toggle is_gallery_featured on a script (super admin only)."""
|
||||
@@ -176,7 +176,7 @@ async def toggle_script_featured(
|
||||
async def update_script_sort_order(
|
||||
script_id: UUID,
|
||||
body: SortOrderUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update gallery_sort_order on a script (super admin only)."""
|
||||
|
||||
@@ -49,6 +49,8 @@ from app.schemas.ai_session import (
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
SaveTaskLaneRequest,
|
||||
TriagePatchRequest,
|
||||
TriagePatchResponse,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services import unified_chat_service
|
||||
@@ -120,6 +122,11 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
||||
pending_task_lane=session.pending_task_lane,
|
||||
is_branching=getattr(session, 'is_branching', False),
|
||||
active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None,
|
||||
client_name=getattr(session, 'client_name', None),
|
||||
asset_name=getattr(session, 'asset_name', None),
|
||||
issue_category=getattr(session, 'issue_category', None),
|
||||
triage_hypothesis=getattr(session, 'triage_hypothesis', None),
|
||||
evidence_items=getattr(session, 'evidence_items', None),
|
||||
)
|
||||
|
||||
|
||||
@@ -301,7 +308,7 @@ async def send_chat_message(
|
||||
message = f"{message}\n\n[Attached document content]\n{doc_context}"
|
||||
|
||||
try:
|
||||
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message(
|
||||
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data = await unified_chat_service.send_chat_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
@@ -346,6 +353,7 @@ async def send_chat_message(
|
||||
fork=fork_metadata,
|
||||
actions=actions_data,
|
||||
questions=questions_data,
|
||||
triage_update=triage_update_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -442,7 +450,12 @@ async def resolve_session(
|
||||
try:
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
await gen.generate_all(session_id)
|
||||
await gen.generate_all(
|
||||
session_id,
|
||||
root_cause=data.root_cause,
|
||||
steps_taken=data.steps_taken,
|
||||
recommendations=data.recommendations,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
||||
|
||||
@@ -519,15 +532,11 @@ async def save_task_lane(
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Save the current task lane state including user's in-progress responses."""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
payload = {
|
||||
"questions": [q.model_dump() for q in body.questions],
|
||||
@@ -544,6 +553,122 @@ async def save_task_lane(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Triage Metadata ──
|
||||
|
||||
@router.patch("/{session_id}/triage", response_model=TriagePatchResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def update_triage(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
body: TriagePatchRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Update triage metadata on a session (incident header fields)."""
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in patch_data.items():
|
||||
setattr(session, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return TriagePatchResponse(
|
||||
id=session.id,
|
||||
client_name=session.client_name,
|
||||
asset_name=session.asset_name,
|
||||
issue_category=session.issue_category,
|
||||
triage_hypothesis=session.triage_hypothesis,
|
||||
evidence_items=session.evidence_items,
|
||||
)
|
||||
|
||||
|
||||
# ── Handoff Draft ──
|
||||
|
||||
@router.post("/{session_id}/handoff-draft")
|
||||
@limiter.limit("10/minute")
|
||||
async def handoff_draft(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stream a structured handoff draft for the conclude modal."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
# Build context from session data
|
||||
context_parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Client: {session.client_name or 'Unknown'}",
|
||||
f"Asset: {session.asset_name or 'Unknown'}",
|
||||
f"Hypothesis: {session.triage_hypothesis or 'None'}",
|
||||
]
|
||||
|
||||
if session.evidence_items:
|
||||
context_parts.append("\nEvidence collected:")
|
||||
for item in session.evidence_items:
|
||||
status_icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?")
|
||||
context_parts.append(f" {status_icon} {item.get('text', '')}")
|
||||
|
||||
# Include task lane steps if available
|
||||
if session.pending_task_lane:
|
||||
actions = session.pending_task_lane.get("actions", [])
|
||||
if actions:
|
||||
context_parts.append("\nSteps taken:")
|
||||
for a in actions:
|
||||
context_parts.append(f" - {a.get('label', '')}")
|
||||
|
||||
# Include last 20 conversation messages
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
context_parts.append("\nRecent conversation:")
|
||||
for msg in msgs[-20:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:300]
|
||||
context_parts.append(f" [{role}]: {content}")
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
prompt = (
|
||||
"Generate a structured handoff summary for this troubleshooting session.\n"
|
||||
"Return ONLY valid JSON with exactly these four fields:\n"
|
||||
'{"root_cause": "...", "resolution": "...", "steps_taken": ["step1", "step2"], "recommendations": "..."}\n\n'
|
||||
f"Session context:\n{context}"
|
||||
)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
content, _, _ = await _call_ai(
|
||||
system_base="You are a concise technical documentation assistant for MSP teams. Return only JSON.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=prompt,
|
||||
max_tokens=1024,
|
||||
)
|
||||
yield f"data: {content}\n\n"
|
||||
except Exception as e:
|
||||
logger.exception(f"Handoff draft generation failed for session {session_id}")
|
||||
import json
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ── Resume ──
|
||||
|
||||
@router.post("/{session_id}/resume", status_code=204)
|
||||
@@ -766,13 +891,13 @@ async def search_sessions(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
):
|
||||
"""Search AI sessions by content using full-text search. Used by Command Palette."""
|
||||
# Sessions are user-scoped. The list endpoint uses user_id only;
|
||||
# search must be consistent. Cross-user access requires explicit
|
||||
# escalation or session sharing — not ambient account membership.
|
||||
result = await db.execute(
|
||||
select(AISession)
|
||||
.where(
|
||||
AISession.user_id == current_user.id,
|
||||
or_(
|
||||
AISession.user_id == current_user.id,
|
||||
AISession.account_id == current_user.account_id,
|
||||
),
|
||||
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
|
||||
)
|
||||
.params(q=q)
|
||||
@@ -905,7 +1030,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_404_NOT_FOUND, detail="Session not found")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
return _build_session_detail(session)
|
||||
|
||||
@@ -921,18 +1046,6 @@ async def get_documentation(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get auto-generated documentation for a session."""
|
||||
# Verify session ownership — owner only. Documentation endpoints require direct
|
||||
# ownership; escalated_to_id / picked_up_by handlers use get_session (read-only).
|
||||
# This is consistent with stream_documentation which has the same owner-only check.
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
try:
|
||||
return await flowpilot_engine.get_session_documentation(
|
||||
session_id=session_id,
|
||||
@@ -958,14 +1071,13 @@ async def stream_documentation(
|
||||
|
||||
# Verify session ownership
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
@@ -1060,19 +1172,6 @@ async def retry_psa_push_endpoint(
|
||||
"""Manually retry a failed PSA documentation push."""
|
||||
from app.models.psa_post_log import PsaPostLog
|
||||
|
||||
# Verify the session belongs to the current user
|
||||
session_result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not session_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
# Find the latest failed push log for this session
|
||||
result = await db.execute(
|
||||
select(PsaPostLog)
|
||||
|
||||
@@ -43,7 +43,6 @@ async def create_suggestion(
|
||||
suggestion = AISuggestion(
|
||||
tree_id=data.tree_id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
session_id=data.session_id,
|
||||
action_type=data.action_type,
|
||||
target_node_id=data.target_node_id,
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.filters import tenant_filter
|
||||
from app.models import User, Session, Tree, SessionRating
|
||||
from app.schemas.analytics import (
|
||||
TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse,
|
||||
@@ -291,13 +290,8 @@ async def get_flow_analytics(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Analytics for a specific flow."""
|
||||
# Verify tree exists and belongs to the requesting user's account.
|
||||
result = await db.execute(
|
||||
select(Tree).where(
|
||||
Tree.id == tree_id,
|
||||
tenant_filter(Tree, current_user.account_id),
|
||||
)
|
||||
)
|
||||
# Verify tree exists
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import secrets
|
||||
import string
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from app.core.config import settings
|
||||
from app.core.settings_manager import SettingsManager
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.core.rate_limit import limiter
|
||||
from app.core.security import (
|
||||
verify_password,
|
||||
@@ -26,6 +27,7 @@ from app.models.refresh_token import RefreshToken
|
||||
from app.models.account import Account
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
from app.schemas.user import UserCreate, UserResponse, UserLogin, UserUpdate
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.auth_password import (
|
||||
@@ -67,7 +69,7 @@ def _generate_display_code() -> str:
|
||||
async def register(
|
||||
request: Request,
|
||||
user_data: UserCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Register a new user.
|
||||
|
||||
@@ -232,7 +234,7 @@ async def register(
|
||||
async def login(
|
||||
request: Request,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Login and get access token."""
|
||||
# Find user by email
|
||||
@@ -270,7 +272,7 @@ async def login(
|
||||
async def login_json(
|
||||
request: Request,
|
||||
credentials: UserLogin,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Login with JSON body (alternative to form data)."""
|
||||
result = await db.execute(select(User).where(User.email == credentials.email))
|
||||
@@ -304,7 +306,7 @@ async def login_json(
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Refresh access token using refresh token (rotation: old token is revoked)."""
|
||||
user_id = payload.get("sub")
|
||||
@@ -368,7 +370,7 @@ async def get_me(
|
||||
async def update_me(
|
||||
data: UserUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Update current user's profile (name, email)."""
|
||||
update_fields = data.model_fields_set - {"current_password"}
|
||||
@@ -415,7 +417,7 @@ async def update_me(
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Logout user by revoking the refresh token."""
|
||||
jti = payload.get("jti")
|
||||
@@ -438,7 +440,7 @@ async def change_password(
|
||||
request: Request,
|
||||
data: ChangePasswordRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Change the current user's password."""
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
@@ -478,7 +480,7 @@ async def change_password(
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
data: ForgotPasswordRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Request a password reset email. Always returns success (anti-enumeration)."""
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
@@ -513,7 +515,7 @@ async def forgot_password(
|
||||
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
|
||||
async def verify_reset_token(
|
||||
data: VerifyResetTokenRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Verify a password reset token is valid."""
|
||||
payload = decode_token(data.token)
|
||||
@@ -544,7 +546,7 @@ async def verify_reset_token(
|
||||
async def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Reset password using a valid reset token."""
|
||||
payload = decode_token(data.token)
|
||||
@@ -611,7 +613,7 @@ async def reset_password(
|
||||
|
||||
@router.get("/email/verification-status")
|
||||
async def get_verification_status(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Check if email verification is enabled on the platform."""
|
||||
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
||||
@@ -623,7 +625,7 @@ async def get_verification_status(
|
||||
async def send_verification_email(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Send an email verification link to the current user."""
|
||||
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
||||
@@ -662,7 +664,7 @@ async def send_verification_email(
|
||||
@router.post("/email/verify")
|
||||
async def verify_email(
|
||||
data: dict,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Verify an email using a token. Public endpoint."""
|
||||
token = data.get("token")
|
||||
@@ -718,3 +720,59 @@ async def verify_email(
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Email verified successfully"}
|
||||
|
||||
|
||||
@router.get("/me/feature-flags", response_model=dict[str, bool])
|
||||
async def get_my_feature_flags(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> dict[str, bool]:
|
||||
"""Resolve feature flags for the current user's account and plan."""
|
||||
plan = "free"
|
||||
if current_user.account_id:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.account_id == current_user.account_id,
|
||||
Subscription.status.in_(["active", "trialing"]),
|
||||
)
|
||||
)
|
||||
sub = sub_result.scalar_one_or_none()
|
||||
if sub:
|
||||
plan = sub.plan
|
||||
|
||||
flags_result = await db.execute(select(FeatureFlag))
|
||||
flags = flags_result.scalars().all()
|
||||
|
||||
if not flags:
|
||||
return {}
|
||||
|
||||
flag_ids = [f.id for f in flags]
|
||||
|
||||
defaults_result = await db.execute(
|
||||
select(PlanFeatureDefault).where(
|
||||
PlanFeatureDefault.flag_id.in_(flag_ids),
|
||||
PlanFeatureDefault.plan == plan,
|
||||
)
|
||||
)
|
||||
plan_defaults = {d.flag_id: d.enabled for d in defaults_result.scalars().all()}
|
||||
|
||||
overrides: dict[uuid.UUID, bool] = {}
|
||||
if current_user.account_id:
|
||||
overrides_result = await db.execute(
|
||||
select(AccountFeatureOverride).where(
|
||||
AccountFeatureOverride.flag_id.in_(flag_ids),
|
||||
AccountFeatureOverride.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
overrides = {o.flag_id: o.enabled for o in overrides_result.scalars().all()}
|
||||
|
||||
resolved = {}
|
||||
for flag in flags:
|
||||
if flag.id in overrides:
|
||||
resolved[flag.flag_key] = overrides[flag.id]
|
||||
elif flag.id in plan_defaults:
|
||||
resolved[flag.flag_key] = plan_defaults[flag.id]
|
||||
else:
|
||||
resolved[flag.flag_key] = False
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -12,8 +12,6 @@ from app.models.user import User
|
||||
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.permissions import can_manage_category, can_create_category
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
from app.core.filters import tenant_filter
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
@@ -49,13 +47,13 @@ async def list_categories(
|
||||
elif current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
|
||||
TreeCategory.account_id.is_(None), # Global
|
||||
TreeCategory.account_id == current_user.account_id # User's account
|
||||
)
|
||||
)
|
||||
else:
|
||||
# User has no account, only show global categories
|
||||
query = query.where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
|
||||
query = query.where(TreeCategory.account_id.is_(None))
|
||||
|
||||
query = query.order_by(TreeCategory.display_order, TreeCategory.name)
|
||||
|
||||
@@ -110,12 +108,10 @@ async def get_category(
|
||||
detail="You don't have access to this category"
|
||||
)
|
||||
|
||||
# Get tree count — scoped to the requesting account so cross-account
|
||||
# trees in shared categories are not counted.
|
||||
# Get tree count
|
||||
count_query = select(func.count(Tree.id)).where(
|
||||
Tree.category_id == category.id,
|
||||
Tree.is_active == True,
|
||||
tenant_filter(Tree, current_user.account_id),
|
||||
Tree.is_active == True
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
tree_count = count_result.scalar() or 0
|
||||
@@ -177,7 +173,7 @@ async def create_category(
|
||||
name=category_data.name,
|
||||
slug=slug,
|
||||
description=category_data.description,
|
||||
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
account_id=category_data.account_id,
|
||||
display_order=max_order + 1,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.schemas.device_type import (
|
||||
DeviceTypeUpdate,
|
||||
DeviceTypeResponse,
|
||||
)
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
|
||||
router = APIRouter(prefix="/device-types", tags=["device-types"])
|
||||
|
||||
@@ -29,8 +28,8 @@ async def list_device_types(
|
||||
select(DeviceType)
|
||||
.where(
|
||||
or_(
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
DeviceType.is_system.is_(True),
|
||||
DeviceType.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
|
||||
@@ -49,16 +48,16 @@ async def create_device_type(
|
||||
existing = await db.execute(
|
||||
select(DeviceType).where(
|
||||
DeviceType.slug == data.slug,
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
DeviceType.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
|
||||
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team")
|
||||
|
||||
system_existing = await db.execute(
|
||||
select(DeviceType).where(
|
||||
DeviceType.slug == data.slug,
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
DeviceType.is_system.is_(True),
|
||||
)
|
||||
)
|
||||
if system_existing.scalar_one_or_none():
|
||||
@@ -69,7 +68,7 @@ async def create_device_type(
|
||||
label=data.label,
|
||||
category=data.category,
|
||||
is_system=False,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(device_type)
|
||||
@@ -90,7 +89,7 @@ async def update_device_type(
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
if device_type.is_system:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify system device types")
|
||||
if device_type.account_id != current_user.account_id:
|
||||
if device_type.team_id != current_user.team_id:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
@@ -113,7 +112,7 @@ async def delete_device_type(
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
if device_type.is_system:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete system device types")
|
||||
if device_type.account_id != current_user.account_id:
|
||||
if device_type.team_id != current_user.team_id:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
|
||||
await db.delete(device_type)
|
||||
|
||||
@@ -29,8 +29,8 @@ def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
|
||||
return cron.get_next(datetime).astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _get_tree_or_404(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
|
||||
"""Fetch tree and verify the current user's team owns it. Raises 404 if not found or access denied."""
|
||||
async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
|
||||
"""Fetch tree and verify the current user's team owns it."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
@@ -38,7 +38,7 @@ async def _get_tree_or_404(tree_id: UUID, current_user: User, db: AsyncSession)
|
||||
# Super admins can access any tree; regular users must be on the same team
|
||||
if not getattr(current_user, 'is_super_admin', False):
|
||||
if tree.team_id != current_user.team_id:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return tree
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def create_schedule(
|
||||
):
|
||||
"""Create a cron schedule for a maintenance flow. One per flow."""
|
||||
# Verify user's team owns the tree
|
||||
tree = await _get_tree_or_404(data.tree_id, current_user, db)
|
||||
tree = await _get_tree_or_403(data.tree_id, current_user, db)
|
||||
if tree.tree_type != "maintenance":
|
||||
raise HTTPException(status_code=400, detail="Schedules are only supported for maintenance flows")
|
||||
|
||||
@@ -69,7 +69,6 @@ async def create_schedule(
|
||||
|
||||
schedule = MaintenanceSchedule(
|
||||
tree_id=data.tree_id,
|
||||
account_id=current_user.account_id,
|
||||
created_by=current_user.id,
|
||||
cron_expression=data.cron_expression,
|
||||
timezone=data.timezone,
|
||||
@@ -95,7 +94,7 @@ async def get_schedule_for_tree(
|
||||
):
|
||||
"""Get the schedule for a specific maintenance flow."""
|
||||
# Verify user's team owns the tree before returning schedule data
|
||||
await _get_tree_or_404(tree_id, current_user, db)
|
||||
await _get_tree_or_403(tree_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
|
||||
@@ -123,7 +122,7 @@ async def update_schedule(
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
|
||||
# Verify user's team owns the tree this schedule belongs to
|
||||
await _get_tree_or_404(schedule.tree_id, current_user, db)
|
||||
await _get_tree_or_403(schedule.tree_id, current_user, db)
|
||||
|
||||
update_fields = data.model_fields_set
|
||||
was_active = schedule.is_active
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.models.device_type import DeviceType
|
||||
from app.models.network_diagram import NetworkDiagram
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
from app.schemas.network_diagram import (
|
||||
NetworkDiagramCreate,
|
||||
NetworkDiagramUpdate,
|
||||
@@ -50,11 +49,11 @@ router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
||||
|
||||
async def _get_diagram_or_404(
|
||||
diagram_id: UUID,
|
||||
account_id: UUID,
|
||||
team_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> NetworkDiagram:
|
||||
diagram = await db.get(NetworkDiagram, diagram_id)
|
||||
if not diagram or diagram.account_id != account_id or diagram.is_archived:
|
||||
if not diagram or diagram.team_id != team_id or diagram.is_archived:
|
||||
raise HTTPException(status_code=404, detail="Diagram not found")
|
||||
return diagram
|
||||
|
||||
@@ -89,12 +88,9 @@ def _diagram_to_list_item(
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
|
||||
async def _get_available_slugs(team_id: UUID, db: AsyncSession) -> set[str]:
|
||||
stmt = select(DeviceType.slug).where(
|
||||
or_(
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
DeviceType.account_id == account_id,
|
||||
)
|
||||
or_(DeviceType.is_system.is_(True), DeviceType.team_id == team_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return {row[0] for row in result.all()}
|
||||
@@ -108,7 +104,7 @@ async def list_client_names(
|
||||
stmt = (
|
||||
select(NetworkDiagram.client_name)
|
||||
.where(
|
||||
NetworkDiagram.account_id == current_user.account_id,
|
||||
NetworkDiagram.team_id == current_user.team_id,
|
||||
NetworkDiagram.is_archived.is_(False),
|
||||
NetworkDiagram.client_name.isnot(None),
|
||||
NetworkDiagram.client_name != "",
|
||||
@@ -130,7 +126,7 @@ async def list_diagrams(
|
||||
stmt = (
|
||||
select(NetworkDiagram)
|
||||
.where(
|
||||
NetworkDiagram.account_id == current_user.account_id,
|
||||
NetworkDiagram.team_id == current_user.team_id,
|
||||
NetworkDiagram.is_archived.is_(False),
|
||||
)
|
||||
.order_by(NetworkDiagram.updated_at.desc())
|
||||
@@ -152,7 +148,7 @@ async def list_diagrams(
|
||||
# Single query for custom device types so category_counts is accurate
|
||||
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
|
||||
DeviceType.is_system.is_(False),
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
DeviceType.team_id == current_user.team_id,
|
||||
)
|
||||
dt_result = await db.execute(dt_stmt)
|
||||
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
|
||||
@@ -168,8 +164,13 @@ async def create_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
if current_user.team_id is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Network Diagrams require a team account. Assign your account to a team first.",
|
||||
)
|
||||
diagram = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
name=data.name,
|
||||
client_name=data.client_name,
|
||||
asset_name=data.asset_name,
|
||||
@@ -190,7 +191,7 @@ async def get_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
|
||||
return _diagram_to_response(diagram)
|
||||
|
||||
|
||||
@@ -201,7 +202,7 @@ async def update_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "nodes" in update_data and update_data["nodes"] is not None:
|
||||
@@ -224,7 +225,7 @@ async def archive_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> None:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
|
||||
diagram.is_archived = True
|
||||
diagram.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -236,9 +237,9 @@ async def duplicate_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
source = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
|
||||
copy = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
name=f"Copy of {source.name}",
|
||||
client_name=source.client_name,
|
||||
asset_name=source.asset_name,
|
||||
@@ -259,7 +260,7 @@ async def export_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DiagramExportResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
|
||||
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
|
||||
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
|
||||
return DiagramExportResponse(
|
||||
@@ -279,7 +280,7 @@ async def import_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DiagramImportResponse:
|
||||
available_slugs = await _get_available_slugs(current_user.account_id, db)
|
||||
available_slugs = await _get_available_slugs(current_user.team_id, db)
|
||||
|
||||
warnings: list[str] = []
|
||||
for node in data.nodes:
|
||||
@@ -287,7 +288,7 @@ async def import_diagram(
|
||||
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
|
||||
|
||||
diagram = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
name=data.name,
|
||||
client_name=data.client_name,
|
||||
description=data.description,
|
||||
@@ -311,7 +312,7 @@ async def ai_generate_diagram(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> AIGenerateResponse:
|
||||
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
|
||||
available_slugs_set = await _get_available_slugs(current_user.team_id, db)
|
||||
available_slugs = list(available_slugs_set)
|
||||
|
||||
existing_node_ids: list[str] | None = None
|
||||
|
||||
@@ -8,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
@@ -99,7 +98,7 @@ async def get_onboarding_status(
|
||||
|
||||
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
|
||||
async def dismiss_onboarding(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStatus:
|
||||
"""Dismiss the onboarding checklist for the current user."""
|
||||
|
||||
@@ -91,7 +91,6 @@ async def submit_step_feedback(
|
||||
new_rating = StepRating(
|
||||
step_id=step_id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
session_id=session_uuid,
|
||||
was_helpful=data.was_helpful,
|
||||
# rating is nullable now — thumbs-only mode
|
||||
|
||||
@@ -85,7 +85,6 @@ async def create_session(
|
||||
session = await script_builder_service.create_session(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
)
|
||||
|
||||
@@ -197,7 +197,6 @@ 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,
|
||||
@@ -365,7 +364,6 @@ 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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
return session
|
||||
@@ -196,7 +196,6 @@ async def start_session(
|
||||
new_session = Session(
|
||||
tree_id=tree.id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
tree_snapshot=tree_snapshot,
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
@@ -235,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -282,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -320,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
session.scratchpad = data.scratchpad
|
||||
@@ -349,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
@@ -388,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
# PDF export — separate path with binary response
|
||||
@@ -694,7 +693,6 @@ async def prepare_session(
|
||||
new_session = Session(
|
||||
tree_id=tree.id,
|
||||
user_id=data.assigned_to_id or current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
tree_snapshot=tree_snapshot,
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
@@ -772,7 +770,6 @@ async def batch_launch_sessions(
|
||||
session = Session(
|
||||
tree_id=tree.id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
tree_snapshot=tree_snapshot,
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
@@ -833,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_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session",
|
||||
)
|
||||
|
||||
# Unlink
|
||||
@@ -1105,7 +1102,6 @@ async def psa_post_to_ticket(
|
||||
# Log to audit trail
|
||||
log_entry = PsaPostLog(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
psa_connection_id=psa_connection.id if psa_connection else None,
|
||||
ticket_id=session.psa_ticket_id,
|
||||
note_type=data.note_type,
|
||||
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.session import Session
|
||||
from app.models.session_share import SessionShare, SessionShareView
|
||||
from app.models.user import User
|
||||
@@ -73,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_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the session owner can create share links"
|
||||
)
|
||||
|
||||
# Require account_id for account-scoped shares
|
||||
@@ -171,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_404_NOT_FOUND,
|
||||
detail="Share not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the share creator can revoke it"
|
||||
)
|
||||
|
||||
share.is_active = False
|
||||
@@ -211,7 +210,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
||||
async def access_share(
|
||||
share_token: str,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Access a shared session via share token.
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"])
|
||||
|
||||
@@ -45,13 +44,13 @@ async def list_step_categories(
|
||||
elif current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
StepCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
|
||||
StepCategory.account_id.is_(None), # 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 == PLATFORM_ACCOUNT_ID)
|
||||
query = query.where(StepCategory.account_id.is_(None))
|
||||
|
||||
query = query.order_by(StepCategory.display_order, StepCategory.name)
|
||||
|
||||
@@ -95,8 +94,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_404_NOT_FOUND,
|
||||
detail="Step category not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this step category"
|
||||
)
|
||||
|
||||
return StepCategoryResponse(
|
||||
@@ -156,7 +155,7 @@ async def create_step_category(
|
||||
name=category_data.name,
|
||||
slug=slug,
|
||||
description=category_data.description,
|
||||
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
account_id=category_data.account_id,
|
||||
display_order=max_order + 1,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
@@ -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=404, detail="Step not found")
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this step")
|
||||
|
||||
if check_edit and not can_edit_step(current_user, step):
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
|
||||
|
||||
return step
|
||||
|
||||
@@ -460,7 +460,6 @@ async def rate_step(
|
||||
rating = StepRating(
|
||||
step_id=step_id,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
rating=rating_data.rating,
|
||||
was_helpful=rating_data.was_helpful,
|
||||
review_text=rating_data.review_text,
|
||||
|
||||
@@ -103,7 +103,6 @@ async def create_supporting_data(
|
||||
|
||||
item = SessionSupportingData(
|
||||
session_id=session_id,
|
||||
account_id=session.account_id,
|
||||
label=data.label,
|
||||
data_type=data.data_type,
|
||||
content=data.content,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"])
|
||||
|
||||
@@ -34,13 +33,13 @@ async def list_tags(
|
||||
if include_account and current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global
|
||||
TreeTag.account_id.is_(None), # Global
|
||||
TreeTag.account_id == current_user.account_id # User's account
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Only show global tags
|
||||
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
|
||||
query = query.where(TreeTag.account_id.is_(None))
|
||||
|
||||
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name)
|
||||
|
||||
@@ -72,12 +71,12 @@ async def search_tags(
|
||||
if include_account and current_user.account_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
|
||||
TreeTag.account_id.is_(None),
|
||||
TreeTag.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
|
||||
query = query.where(TreeTag.account_id.is_(None))
|
||||
|
||||
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name).limit(limit)
|
||||
|
||||
@@ -106,8 +105,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_404_NOT_FOUND,
|
||||
detail="Tag not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this tag"
|
||||
)
|
||||
|
||||
return TagResponse.model_validate(tag)
|
||||
@@ -148,7 +147,7 @@ async def create_tag(
|
||||
new_tag = TreeTag(
|
||||
name=tag_data.name,
|
||||
slug=slug,
|
||||
account_id=tag_data.account_id if tag_data.account_id is not None else PLATFORM_ACCOUNT_ID,
|
||||
account_id=tag_data.account_id,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(new_tag)
|
||||
@@ -207,7 +206,7 @@ async def add_tags_to_tree(
|
||||
tag_query = select(TreeTag).where(
|
||||
TreeTag.slug == slug,
|
||||
or_(
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global tag
|
||||
TreeTag.account_id.is_(None), # Global tag
|
||||
TreeTag.account_id == tag_account_id # Account tag
|
||||
)
|
||||
)
|
||||
@@ -341,7 +340,7 @@ async def replace_tree_tags(
|
||||
tag_query = select(TreeTag).where(
|
||||
TreeTag.slug == slug,
|
||||
or_(
|
||||
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
|
||||
TreeTag.account_id.is_(None),
|
||||
TreeTag.account_id == tag_account_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,10 +18,12 @@ async def list_target_lists(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List all target lists for the current user's account."""
|
||||
"""List all target lists for the current user's team."""
|
||||
if not current_user.team_id:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(TargetList)
|
||||
.where(TargetList.account_id == current_user.account_id)
|
||||
.where(TargetList.team_id == current_user.team_id)
|
||||
.order_by(TargetList.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -34,9 +36,11 @@ async def create_target_list(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Create a new target list for the current account."""
|
||||
"""Create a new target list for the current team."""
|
||||
if not current_user.team_id:
|
||||
raise HTTPException(status_code=400, detail="User must belong to a team")
|
||||
target_list = TargetList(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
created_by=current_user.id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
@@ -57,7 +61,7 @@ async def get_target_list(
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.account_id == current_user.account_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
@@ -77,7 +81,7 @@ async def update_target_list(
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.account_id == current_user.account_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
@@ -87,7 +91,7 @@ async def update_target_list(
|
||||
if "name" in update_fields and data.name is not None:
|
||||
target_list.name = data.name
|
||||
if "description" in update_fields:
|
||||
target_list.description = data.description
|
||||
target_list.description = data.description # allow setting to None
|
||||
if "targets" in update_fields and data.targets is not None:
|
||||
target_list.targets = [t.model_dump() for t in data.targets]
|
||||
await db.commit()
|
||||
@@ -105,7 +109,7 @@ async def delete_target_list(
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.account_id == current_user.account_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
|
||||
@@ -29,7 +29,6 @@ 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
|
||||
|
||||
@@ -392,10 +391,9 @@ 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_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this tree"
|
||||
)
|
||||
|
||||
return build_full_tree_response(tree)
|
||||
@@ -472,7 +470,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=PLATFORM_ACCOUNT_ID if is_default else current_user.account_id,
|
||||
account_id=None 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
|
||||
@@ -612,17 +610,9 @@ 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_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only edit your own trees"
|
||||
)
|
||||
|
||||
# Extract tags for separate handling
|
||||
@@ -1048,7 +1038,6 @@ async def create_tree_share(
|
||||
# Create share
|
||||
tree_share = TreeShare(
|
||||
tree_id=tree.id,
|
||||
account_id=tree.account_id, # share belongs to the tree's tenant, not the actor
|
||||
share_token=share_token,
|
||||
created_by=current_user.id,
|
||||
allow_forking=share_data.allow_forking,
|
||||
@@ -1155,17 +1144,9 @@ 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_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only edit your own trees"
|
||||
)
|
||||
|
||||
# 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 — 404 to avoid revealing existence
|
||||
# Verify the upload belongs to the user's account
|
||||
if upload.account_id != current_user.account_id and not current_user.is_super_admin:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
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 — 404 to avoid revealing existence
|
||||
# Verify ownership
|
||||
if upload.uploaded_by != current_user.id and not current_user.is_super_admin:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Delete from S3
|
||||
await storage_service.delete_file(upload.storage_key)
|
||||
|
||||
@@ -1,91 +1,53 @@
|
||||
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,
|
||||
device_types,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
folders,
|
||||
integrations,
|
||||
invite,
|
||||
kb_accelerator,
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
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,
|
||||
)
|
||||
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 app.api.endpoints import device_types
|
||||
from app.api.endpoints import network_diagrams
|
||||
|
||||
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(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(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(admin.router)
|
||||
api_router.include_router(admin_dashboard.router)
|
||||
api_router.include_router(admin_audit.router)
|
||||
@@ -93,55 +55,44 @@ 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(network_diagrams.router) # Must be before ai_sessions to avoid /{diagram_id} conflict
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
api_router.include_router(network_diagrams.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)
|
||||
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||
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)
|
||||
api_router.include_router(device_types.router)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# backend/app/core/admin_database.py
|
||||
"""
|
||||
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
|
||||
|
||||
Use ONLY where explicit application-level access control makes database-layer
|
||||
tenant filtering unnecessary: /admin/* endpoints, internal tooling, and public
|
||||
endpoints that enforce their own authorization before returning data (e.g.
|
||||
share access via opaque token + visibility check).
|
||||
"""
|
||||
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). See module docstring for approved use cases."""
|
||||
async with _admin_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -12,19 +12,10 @@ async def log_audit(
|
||||
resource_type: str,
|
||||
resource_id: Optional[UUID] = None,
|
||||
details: Optional[dict] = None,
|
||||
account_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
|
||||
if account_id is None:
|
||||
# Derive from the acting user's account as a fallback (one extra query).
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
result = await db.execute(select(User.account_id).where(User.id == user_id))
|
||||
account_id = result.scalar_one()
|
||||
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
|
||||
@@ -23,33 +23,10 @@ class Settings(BaseSettings):
|
||||
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return v
|
||||
|
||||
# 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
|
||||
@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)
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = _DEFAULT_SECRET_KEY
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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(
|
||||
@@ -17,11 +16,6 @@ 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,12 +1,10 @@
|
||||
"""
|
||||
Centralized query filters for ResolutionFlow.
|
||||
|
||||
Provides reusable SQLAlchemy filter builders for tree access control,
|
||||
step visibility, and the canonical tenant_filter used by all queries
|
||||
on tenant-scoped tables.
|
||||
Provides reusable SQLAlchemy filter builders for tree access control
|
||||
and step visibility, used across multiple endpoint modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import or_, and_, true as sa_true
|
||||
@@ -15,18 +13,6 @@ 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.
|
||||
|
||||
@@ -50,11 +36,10 @@ 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',
|
||||
tenant_filter(Tree, current_user.account_id),
|
||||
Tree.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
return or_(*conditions)
|
||||
@@ -73,14 +58,11 @@ def build_step_visibility_filter(current_user: User):
|
||||
if current_user.account_id:
|
||||
return or_(
|
||||
StepLibrary.visibility == 'public',
|
||||
and_(
|
||||
StepLibrary.visibility == 'team',
|
||||
tenant_filter(StepLibrary, current_user.account_id),
|
||||
),
|
||||
StepLibrary.created_by == current_user.id,
|
||||
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id),
|
||||
StepLibrary.created_by == current_user.id # Own private steps
|
||||
)
|
||||
else:
|
||||
return or_(
|
||||
StepLibrary.visibility == 'public',
|
||||
StepLibrary.created_by == current_user.id,
|
||||
StepLibrary.created_by == current_user.id
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
||||
"""Create batch sessions for a scheduled maintenance run."""
|
||||
# Import all models first to ensure SQLAlchemy mapper relationships resolve
|
||||
import app.models # noqa: F401
|
||||
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.maintenance_schedule import MaintenanceSchedule
|
||||
from app.models.session import Session
|
||||
from app.models.target_list import TargetList
|
||||
@@ -118,7 +118,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
||||
async def _cleanup_expired_ai_conversations() -> None:
|
||||
"""Delete expired AI wizard conversations."""
|
||||
import app.models # noqa: F401
|
||||
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
async with async_session_maker() as db:
|
||||
|
||||
@@ -14,16 +14,10 @@ import logging
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import _admin_session_factory
|
||||
|
||||
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"
|
||||
|
||||
@@ -54,45 +48,40 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID:
|
||||
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
|
||||
"""Ensure the ResolutionFlow service account exists and return its ID.
|
||||
|
||||
Idempotent — safe to call on every startup. This lookup must bypass RLS
|
||||
because startup runs before any request-scoped tenant context exists and
|
||||
the users table is tenant-isolated in Phase 4. The service account is
|
||||
normally created by Alembic migration 1490781700bc; the runtime create path
|
||||
remains as a self-healing fallback for environments that predate that seed.
|
||||
Idempotent — safe to call on every startup. Creates the account if it
|
||||
does not exist. The account has no usable password and is_service_account=True
|
||||
so it can never log in via normal auth flows.
|
||||
"""
|
||||
_ = db # Retained for call-site compatibility in app lifespan startup.
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
async with _admin_session_factory() as admin_db:
|
||||
result = await admin_db.execute(
|
||||
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is not None:
|
||||
if not user.is_service_account:
|
||||
user.is_service_account = True
|
||||
await admin_db.commit()
|
||||
return user.id
|
||||
if user is not None:
|
||||
if not user.is_service_account:
|
||||
user.is_service_account = True
|
||||
await db.commit()
|
||||
return user.id
|
||||
|
||||
account_id = await _ensure_system_account(admin_db)
|
||||
account_id = await _ensure_system_account(db)
|
||||
|
||||
new_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email=SERVICE_ACCOUNT_EMAIL,
|
||||
name=SERVICE_ACCOUNT_NAME,
|
||||
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
||||
role="engineer",
|
||||
is_super_admin=False,
|
||||
is_team_admin=False,
|
||||
is_active=True,
|
||||
is_service_account=True,
|
||||
must_change_password=False,
|
||||
account_id=account_id,
|
||||
account_role="engineer",
|
||||
)
|
||||
admin_db.add(new_user)
|
||||
await admin_db.commit()
|
||||
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
||||
return new_user.id
|
||||
new_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email=SERVICE_ACCOUNT_EMAIL,
|
||||
name=SERVICE_ACCOUNT_NAME,
|
||||
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
||||
role="engineer",
|
||||
is_super_admin=False,
|
||||
is_team_admin=False,
|
||||
is_active=True,
|
||||
is_service_account=True,
|
||||
must_change_password=False,
|
||||
account_id=account_id,
|
||||
account_role="engineer",
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
||||
return new_user.id
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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,
|
||||
)
|
||||
@@ -25,8 +25,7 @@ if settings.SENTRY_DSN:
|
||||
),
|
||||
)
|
||||
|
||||
from app.core.database import init_db
|
||||
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||
from app.core.database import init_db, async_session_maker
|
||||
from app.core.logging_config import setup_logging
|
||||
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
||||
from app.core.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
@@ -54,8 +54,6 @@ 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
|
||||
from .device_type import DeviceType
|
||||
from .network_diagram import NetworkDiagram
|
||||
|
||||
@@ -126,8 +124,6 @@ __all__ = [
|
||||
"ForkPoint",
|
||||
"SessionHandoff",
|
||||
"SessionResolutionOutput",
|
||||
"TemplateTree",
|
||||
"PlatformStep",
|
||||
"DeviceType",
|
||||
"NetworkDiagram",
|
||||
]
|
||||
|
||||
@@ -137,6 +137,28 @@ class AISession(Base):
|
||||
comment="Snapshot of PSA ticket data at session start",
|
||||
)
|
||||
|
||||
# ── Triage / Cockpit Header ──
|
||||
client_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
comment="MSP client name for incident header (AI-inferred or manual)",
|
||||
)
|
||||
asset_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
comment="Device, asset, or user being worked on",
|
||||
)
|
||||
issue_category: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="Human-readable category (e.g. DNS / Networking)",
|
||||
)
|
||||
triage_hypothesis: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Current working hypothesis — AI-updated + engineer-editable",
|
||||
)
|
||||
evidence_items: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment='What We Know list: [{"text": str, "status": "confirmed"|"ruled_out"|"pending"}]',
|
||||
)
|
||||
|
||||
# ── Resolution / Escalation ──
|
||||
resolution_summary: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
|
||||
@@ -50,13 +50,6 @@ 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,12 +28,6 @@ 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,12 +20,6 @@ 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)
|
||||
|
||||
@@ -21,12 +21,6 @@ class AuditLog(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
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
|
||||
@@ -39,10 +39,10 @@ class TreeCategory(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.core.database import Base
|
||||
|
||||
|
||||
class DeviceType(Base):
|
||||
"""A device type for network diagram nodes (platform or account-custom)."""
|
||||
"""A device type for network diagram nodes (system or team-custom)."""
|
||||
__tablename__ = "device_types"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -32,11 +32,11 @@ class DeviceType(Base):
|
||||
Boolean, nullable=False, default=False,
|
||||
comment="True for built-in types that cannot be deleted",
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
team_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Platform account for system types, tenant account for custom types",
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
comment="NULL for system types, set for team-custom types",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -10,7 +11,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[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=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,12 +46,6 @@ 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,12 +23,6 @@ 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,12 +23,6 @@ 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(
|
||||
|
||||
@@ -14,15 +14,15 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class NetworkDiagram(Base):
|
||||
"""A network topology diagram scoped to one account."""
|
||||
"""A network topology diagram, team-scoped."""
|
||||
__tablename__ = "network_diagrams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
team_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
@@ -31,12 +31,6 @@ 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")
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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,12 +25,6 @@ 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,12 +35,6 @@ 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,12 +29,6 @@ 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,12 +44,6 @@ 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
|
||||
)
|
||||
@@ -103,12 +97,6 @@ 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,12 +31,6 @@ 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,12 +35,6 @@ 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,12 +27,6 @@ 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,12 +23,6 @@ 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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
@@ -143,13 +143,6 @@ 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)
|
||||
@@ -194,13 +187,6 @@ 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,12 +14,6 @@ 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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.team import Team
|
||||
|
||||
|
||||
class TargetList(Base):
|
||||
@@ -17,11 +17,9 @@ class TargetList(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
team_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("teams.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
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
)
|
||||
chunk_type: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
|
||||
@@ -25,12 +25,6 @@ class TreeShare(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
|
||||
)
|
||||
share_token: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
|
||||
@@ -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[uuid.UUID] = mapped_column(
|
||||
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
|
||||
@@ -24,12 +24,6 @@ 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"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user