Compare commits
121 Commits
docs/updat
...
bea34229d6
| Author | SHA1 | Date | |
|---|---|---|---|
| bea34229d6 | |||
| 294b309faa | |||
| fb7690485b | |||
| 6044d5a88b | |||
| 00cd8b7c55 | |||
| fded959b5e | |||
| 5f5b9e5b23 | |||
| b2ee1a2150 | |||
| 08909aa884 | |||
| 070d2383bc | |||
| d7b1fe6645 | |||
| a3f8bb3427 | |||
| f050afc2f7 | |||
| 849e1c16e2 | |||
| 5310cd3fff | |||
| d2689afa53 | |||
| 9d88c8456c | |||
| 506aac609d | |||
| 7fa81f69a6 | |||
| 6e0188d0b4 | |||
| 24ab1908a6 | |||
| e2cdfac1c3 | |||
| a5e9615666 | |||
| 66cca70588 | |||
| e714088a2b | |||
| ff0ec143e2 | |||
| 8d964e64e4 | |||
| 44634b1145 | |||
| 001438008b | |||
| c8b68ad26d | |||
| 2b3d52ad77 | |||
| 52b369680b | |||
| f0ccf313a4 | |||
| 0d9babb986 | |||
| 567985402f | |||
| 08a4c6600d | |||
| 29fa48e71b | |||
| 908a867986 | |||
| 346576a730 | |||
| b18072e24b | |||
| e0f44e2985 | |||
| adfbb39297 | |||
| 6bae205a8c | |||
| ee2b2c2399 | |||
| 37bc47b75b | |||
| c8bdd0014e | |||
| 2a2b770405 | |||
| d6d0e9f3c1 | |||
| ab4bf3b32f | |||
|
|
d3c93cd006 | ||
|
|
4037a5213e | ||
|
|
0ed5977fee | ||
|
|
c5b8229ef6 | ||
|
|
eba50e1f95 | ||
|
|
8eb814283d | ||
|
|
b433b232dc | ||
|
|
015df1fe5f | ||
|
|
cf9c258f9e | ||
|
|
c063952f12 | ||
|
|
36721eb5af | ||
|
|
3cd4084f78 | ||
|
|
ed763d1cea | ||
|
|
c37e216e0b | ||
|
|
91cc9a4170 | ||
|
|
2a4220b496 | ||
|
|
c8f571db39 | ||
|
|
7efa22454d | ||
|
|
05421fc65c | ||
|
|
dfcad531e2 | ||
|
|
684fb07e47 | ||
|
|
4a12c9b37d | ||
|
|
e41d7bd960 | ||
|
|
f2c3bd7a9b | ||
|
|
9786c6b1fb | ||
|
|
4529955f7d | ||
|
|
b7b0d41f92 | ||
|
|
a4512dcf90 | ||
|
|
764db79060 | ||
|
|
f90e2c956f | ||
|
|
bdaea68dd3 | ||
|
|
02c19a7580 | ||
|
|
a392d24101 | ||
|
|
b9c9bb548d | ||
|
|
662df2907d | ||
|
|
b9547e6ce1 | ||
|
|
760e0f77f8 | ||
|
|
a71f082e25 | ||
|
|
abd79bc763 | ||
|
|
af5ceea7f9 | ||
|
|
f54d7ecd78 | ||
|
|
46593ba8ca | ||
|
|
52553d62d2 | ||
|
|
a48660700a | ||
|
|
3ff886363c | ||
|
|
501442e5f0 | ||
|
|
6f53ec06f5 | ||
|
|
ec322f7cdf | ||
|
|
f9248aeaa8 | ||
|
|
c6da4ebee5 | ||
|
|
64f004a62c | ||
|
|
ba36e37dab | ||
|
|
9e6965512b | ||
|
|
893b8a5008 | ||
|
|
e05472615b | ||
|
|
00fdd663bc | ||
|
|
8cf58add22 | ||
|
|
6c231ef1c6 | ||
|
|
758cd61621 | ||
|
|
b9fcdd5d73 | ||
|
|
4273ed0e5c | ||
|
|
0107d2d896 | ||
|
|
79ae34108a | ||
|
|
bd29f590a2 | ||
|
|
ce4cfc3240 | ||
|
|
82ee177d9b | ||
|
|
ed8de92c52 | ||
|
|
5bd331ca92 | ||
|
|
87fac02e9b | ||
|
|
4f4bc435da | ||
|
|
ac2b193909 | ||
|
|
b641ac6c55 |
154
.gitea/workflows/ci.yml
Normal file
154
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: resolutionflow_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
|
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
|
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||||
|
DEBUG: "true"
|
||||||
|
APP_NAME: ResolutionFlow
|
||||||
|
TEST_DB_NAME: resolutionflow_test
|
||||||
|
DB_APP_ROLE_PASSWORD: app_secret_ci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run Alembic migrations
|
||||||
|
run: cd backend && alembic upgrade head
|
||||||
|
|
||||||
|
- name: Check tenant filter enforcement
|
||||||
|
run: cd backend && python scripts/check_tenant_filters.py
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||||
|
|
||||||
|
- name: Display coverage summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python -c "
|
||||||
|
import json
|
||||||
|
with open('coverage.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
total = data['totals']['percent_covered_display']
|
||||||
|
print(f'Total coverage: {total}%')
|
||||||
|
print()
|
||||||
|
print('Module coverage:')
|
||||||
|
for fname, fdata in sorted(data['files'].items()):
|
||||||
|
pct = fdata['summary']['percent_covered_display']
|
||||||
|
if float(pct) < 80:
|
||||||
|
print(f' WARNING {fname}: {pct}%')
|
||||||
|
else:
|
||||||
|
print(f' OK {fname}: {pct}%')
|
||||||
|
"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: cd frontend && npm run lint
|
||||||
|
|
||||||
|
- name: Test with coverage
|
||||||
|
run: cd frontend && npm run test:coverage
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: frontend/dist
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: [frontend]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: resolutionflow_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
|
PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
|
PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000
|
||||||
|
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
|
||||||
|
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
|
||||||
|
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
|
||||||
|
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Download frontend build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: frontend/dist
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: cd frontend && npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Playwright smoke tests
|
||||||
|
run: cd frontend && npm run test:e2e
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: |
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/test-results
|
||||||
|
if-no-files-found: ignore
|
||||||
19
.gitea/workflows/mirror-to-github.yml
Normal file
19
.gitea/workflows/mirror-to-github.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Push to GitHub
|
||||||
|
run: |
|
||||||
|
cd /tmp
|
||||||
|
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
|
||||||
|
cd repo
|
||||||
|
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
|
||||||
|
git push github --all --force
|
||||||
|
git push github --tags --force
|
||||||
43
.gitea/workflows/runner-probe.yml
Normal file
43
.gitea/workflows/runner-probe.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Runner Probe
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
probe:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Runner labels and OS
|
||||||
|
run: |
|
||||||
|
echo "=== OS ==="
|
||||||
|
uname -a
|
||||||
|
cat /etc/os-release 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Python versions
|
||||||
|
run: |
|
||||||
|
echo "=== Python ==="
|
||||||
|
which python3 && python3 --version || echo "python3 not found"
|
||||||
|
which python && python --version || echo "python not found"
|
||||||
|
ls /usr/bin/python* 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Node versions
|
||||||
|
run: |
|
||||||
|
echo "=== Node ==="
|
||||||
|
which node && node --version || echo "node not found"
|
||||||
|
which npm && npm --version || echo "npm not found"
|
||||||
|
ls /usr/bin/node* 2>/dev/null || true
|
||||||
|
ls ~/.nvm/versions/node/ 2>/dev/null || echo "no nvm versions"
|
||||||
|
|
||||||
|
- name: Docker
|
||||||
|
run: |
|
||||||
|
echo "=== Docker ==="
|
||||||
|
which docker && docker --version || echo "docker not found"
|
||||||
|
docker info 2>/dev/null | grep -E "Server Version|Operating System" || true
|
||||||
|
|
||||||
|
- name: User and home
|
||||||
|
run: |
|
||||||
|
echo "=== User ==="
|
||||||
|
whoami
|
||||||
|
echo "HOME=$HOME"
|
||||||
|
echo "PATH=$PATH"
|
||||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -31,6 +31,8 @@ jobs:
|
|||||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||||
DEBUG: "true"
|
DEBUG: "true"
|
||||||
APP_NAME: ResolutionFlow
|
APP_NAME: ResolutionFlow
|
||||||
|
TEST_DB_NAME: resolutionflow_test
|
||||||
|
DB_APP_ROLE_PASSWORD: app_secret_ci
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -47,6 +49,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
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
|
- name: Check tenant filter enforcement
|
||||||
run: cd backend && python scripts/check_tenant_filters.py
|
run: cd backend && python scripts/check_tenant_filters.py
|
||||||
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.
|
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.
|
||||||
|
|||||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to ResolutionFlow are documented here.
|
All notable changes to ResolutionFlow are documented here.
|
||||||
|
|
||||||
|
## [0.1.0.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
|
||||||
|
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
|
||||||
|
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
|
||||||
|
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
|
||||||
|
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
|
||||||
|
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
|
||||||
|
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
|
||||||
|
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
|
||||||
|
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
|
||||||
|
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
|
||||||
|
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
|
||||||
|
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
|
||||||
|
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
|
||||||
|
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
|
||||||
|
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
|
||||||
|
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
|
||||||
|
- Assignment search and "Load More" removed from resource manager in favor of direct member list
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -9,6 +33,9 @@ All notable changes to ResolutionFlow are documented here.
|
|||||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||||
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
|
- 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 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
|
||||||
|
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
|
||||||
|
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
|
||||||
|
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
|
||||||
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
|
- **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
|
- **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
|
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
|
||||||
@@ -31,6 +58,7 @@ All notable changes to ResolutionFlow are documented here.
|
|||||||
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped 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.
|
- **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.
|
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
|
||||||
|
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
|
||||||
- Dark text rendering on blue accent step-number badges across all flow types
|
- 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
|
- Script Library tab ownership filter now preserved across category and search changes
|
||||||
- Race conditions in script builder session creation and slug generation
|
- Race conditions in script builder session creation and slug generation
|
||||||
|
|||||||
507
CLAUDE.md
507
CLAUDE.md
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||||
|
|
||||||
> **Last Updated:** April 6, 2026
|
> **Last Updated:** April 16, 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,18 +20,12 @@
|
|||||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
||||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
||||||
|
|
||||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
|
||||||
- **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned.
|
|
||||||
- **Accent color:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9).
|
|
||||||
- **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts
|
|
||||||
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
|
|
||||||
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
|
||||||
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB.
|
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
|
||||||
|
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar.
|
||||||
|
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. `tree_type` column values unchanged in DB.
|
||||||
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
|
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
|
||||||
|
|
||||||
**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows.
|
|
||||||
|
|
||||||
## Implementation Principles
|
## Implementation Principles
|
||||||
|
|
||||||
- Prefer correct architecture over minimal diff
|
- Prefer correct architecture over minimal diff
|
||||||
@@ -59,22 +53,10 @@
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
Python FastAPI, PostgreSQL 16 (async SQLAlchemy 2.0 + asyncpg), Alembic, JWT (python-jose) + bcrypt, Pydantic v2, APScheduler 3.x
|
||||||
- **Framework:** Python FastAPI
|
|
||||||
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
|
||||||
- **Migrations:** Alembic
|
|
||||||
- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based)
|
|
||||||
- **Validation:** Pydantic v2
|
|
||||||
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
|
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
React 19 + Vite + TypeScript, Tailwind CSS v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios, Lucide React
|
||||||
- **Framework:** React 19 + Vite + TypeScript
|
|
||||||
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
|
|
||||||
- **State:** Zustand (with immer + zundo for undo/redo)
|
|
||||||
- **Routing:** React Router v7
|
|
||||||
- **API Client:** Axios with token refresh interceptor
|
|
||||||
- **Icons:** Lucide React
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,37 +64,23 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
patherly/
|
patherly/
|
||||||
├── backend/
|
├── backend/app/
|
||||||
│ ├── app/
|
│ ├── main.py # FastAPI entry point
|
||||||
│ │ ├── main.py # FastAPI entry point
|
│ ├── api/endpoints/ # Route handlers
|
||||||
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
|
│ ├── api/deps.py # Auth dependencies
|
||||||
│ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
|
│ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||||
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
|
│ ├── models/ # SQLAlchemy models
|
||||||
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
|
│ ├── schemas/ # Pydantic schemas
|
||||||
│ │ ├── api/router.py # Route registration
|
│ └── services/psa/ # PSA provider abstraction (connectwise/, autotask/, halopsa/)
|
||||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
├── backend/alembic/ # Migrations (001-070 sequential, then hash IDs)
|
||||||
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
|
├── backend/tests/ # pytest integration tests
|
||||||
│ │ ├── schemas/ # Pydantic schemas
|
├── frontend/src/
|
||||||
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
│ ├── api/ # Axios client + endpoint modules
|
||||||
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
|
│ ├── components/ # UI components
|
||||||
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
|
│ ├── hooks/ # usePermissions, useSessionTimer, etc.
|
||||||
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
|
│ ├── pages/ # Page components
|
||||||
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
|
│ ├── store/ # Zustand stores
|
||||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
│ └── types/ # TypeScript interfaces
|
||||||
│ └── tests/ # pytest integration tests
|
|
||||||
├── frontend/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # Axios client + endpoint modules
|
|
||||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
|
||||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
|
||||||
│ │ ├── pages/ # All page components
|
|
||||||
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
|
||||||
│ │ └── types/ # TypeScript interfaces
|
|
||||||
│ └── (Tailwind v4: CSS-only config in src/index.css)
|
|
||||||
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
|
|
||||||
├── CLAUDE.md # This file
|
|
||||||
├── CURRENT-STATE.md # Detailed feature status
|
|
||||||
├── LESSONS-LEARNED.md # (Deprecated — consolidated into CLAUDE.md)
|
|
||||||
└── docs/plans/ # Design docs & implementation plans
|
└── docs/plans/ # Design docs & implementation plans
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,243 +111,163 @@ VITE_API_URL=http://localhost:8000
|
|||||||
|
|
||||||
## ConnectWise PSA Integration
|
## ConnectWise PSA Integration
|
||||||
|
|
||||||
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
|
All reference materials in `docs/connectwise/`. See [CONNECTWISE-API-REFERENCE.md](docs/connectwise/CONNECTWISE-API-REFERENCE.md) first.
|
||||||
|
|
||||||
### Best Practices Documentation
|
### Best Practices Documentation
|
||||||
|
|
||||||
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
|
Read `docs/connectwise/best-practices/` BEFORE implementing any CW API integration code:
|
||||||
|
|
||||||
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
|
- `PSA-API-Requests.md` — HTTP methods, condition syntax, PATCH format. READ FIRST.
|
||||||
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
|
- `PSA-Callbacks.md` — Callback matrix, HMAC verification.
|
||||||
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
|
- `PSA-Pagination.md` — Forward-Only vs Navigable, Link headers.
|
||||||
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
|
- `PSA-Service-Tickets.md` — Ticket field mappings.
|
||||||
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
|
- `PSA-Versioning.md` — Pin `application/vnd.connectwise.com+json; version=2025.16`.
|
||||||
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
|
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL via `/login/companyinfo/{companyId}`.
|
||||||
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
|
- `Bundled-Requests.md` — Batch via `/system/bundles`.
|
||||||
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
|
- `PSA-Markdown.md` — Notes support markdown.
|
||||||
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
|
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type.
|
||||||
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
|
- `PSA-Data-Protection.md` — Request minimal permissions (MY not ALL).
|
||||||
|
|
||||||
### Reference Files (read in this order)
|
### Reference Files (read in this order)
|
||||||
|
|
||||||
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
|
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Auth patterns, endpoint map, field mappings.
|
||||||
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
|
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (670 endpoints, 342 schemas).
|
||||||
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
|
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Full spec (1838 endpoints). Only if you need something outside the subset.
|
||||||
|
|
||||||
### Integration Architecture
|
|
||||||
|
|
||||||
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
|
|
||||||
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
|
|
||||||
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
|
|
||||||
|
|
||||||
### Key Implementation Rules
|
### Key Implementation Rules
|
||||||
|
|
||||||
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
|
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
|
||||||
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
|
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow app, NOT per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`
|
||||||
- All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
|
- All PSA code in `services/psa/` — `PSAProvider` abstract base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch
|
||||||
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
|
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
|
||||||
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
|
- Credentials encrypted via `services/psa/encryption.py` (Fernet); stored per-team, never per-user
|
||||||
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
|
|
||||||
- Design for the Autotask integration following the same service layer pattern (future PSA)
|
|
||||||
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
|
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
|
||||||
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
|
- Integration flows: Session → Ticket Notes via `POST /service/tickets/{id}/notes`; Ticket Context → FlowPilot via ticket details/company/configs; Callbacks via `/system/callbacks`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
# PostgreSQL (run from VPS SSH — docker not available in code-server, see Lesson 103)
|
||||||
docker start resolutionflow_postgres
|
docker start resolutionflow_postgres
|
||||||
|
|
||||||
# Backend (from backend/)
|
# Backend (from backend/)
|
||||||
source venv/bin/activate # Linux/Mac
|
source venv/bin/activate
|
||||||
# .\venv\Scripts\Activate # Windows
|
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
# Frontend (from frontend/)
|
# Frontend (from frontend/) — requires Node 20 (use nvm: nvm use 20)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Run tests (from backend/)
|
# Tests (from backend/)
|
||||||
pytest --override-ini="addopts="
|
pytest --override-ini="addopts="
|
||||||
|
|
||||||
# First time only: create test database
|
# TypeScript check (use in code-server — avoids EACCES on dist/, see Lesson 105)
|
||||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
npx tsc -b
|
||||||
|
|
||||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
# Frontend build — stricter than tsc, always use as final check before push
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
# Database migrations
|
# Migrations
|
||||||
cd backend && alembic upgrade head
|
cd backend && alembic upgrade head
|
||||||
alembic revision --autogenerate -m "Description"
|
alembic revision --autogenerate -m "Description" # do NOT pass --rev-id; Alembic generates hash IDs
|
||||||
# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
|
|
||||||
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
|
|
||||||
|
|
||||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
# Access PostgreSQL (VPS SSH)
|
||||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||||
|
|
||||||
# Seed data
|
# CI runs on Gitea (NOT GitHub Actions): https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
|
||||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
|
||||||
|
|
||||||
# CI/CD debugging
|
|
||||||
gh run list --limit 5 # Recent CI runs
|
|
||||||
gh run view <id> --log-failed # Failed job logs
|
|
||||||
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
|
|
||||||
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### URLs
|
### URLs & Test Users
|
||||||
|
|
||||||
- Frontend: <http://localhost:5173>
|
- Frontend: `http://localhost:5173` | Backend: `http://localhost:8000` | API Docs: `http://localhost:8000/api/docs`
|
||||||
- Backend API: <http://localhost:8000>
|
- Test password: `TestPass123!` — users: `admin@`, `teamadmin@`, `engineer@`, `pro@` (all `@resolutionflow.example.com`)
|
||||||
- API Docs: <http://localhost:8000/api/docs>
|
|
||||||
|
|
||||||
### Test Users (seeded via `scripts/seed_test_users.py`)
|
|
||||||
|
|
||||||
- All share password: `TestPass123!`
|
|
||||||
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical Lessons Learned
|
## Critical Lessons Learned
|
||||||
|
|
||||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
|
> Lessons 1-70 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase.
|
||||||
|
|
||||||
### Active Lessons (41+)
|
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — requires `modified_flow_data` via "Edit & Publish". Only `new_flow` proposals support direct approve.
|
||||||
|
|
||||||
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). Verify length when adding new status values.
|
||||||
|
|
||||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
**73. `get_db` rolls back on exception:** Prevents `InFailedSQLTransaction` cascade. Never remove the `await session.rollback()` in the dependency.
|
||||||
|
|
||||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
**74. FlowPilot action bar height chain:** `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, walk `getBoundingClientRect()` from `app-shell` down.
|
||||||
|
|
||||||
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
**75. Dashboard prefill auto-submits:** `StartSessionInput` passes `{ state: { prefill } }`. Both `FlowPilotSessionPage` and `AssistantChatPage` auto-submit via `useEffect` + `prefillHandledRef` guard.
|
||||||
|
|
||||||
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` to intercept navigation. "Pause & Leave" auto-pauses before proceeding.
|
||||||
|
|
||||||
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
**77. Prefer manual Alembic migrations for targeted changes:** `--autogenerate` picks up all table drift. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
|
||||||
|
|
||||||
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Actions route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers.
|
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Appears on login, register, and `<title>`. Not "Decision Tree Platform".
|
||||||
|
|
||||||
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` + `max-w-full sm:max-w-lg`. See `Modal.tsx` and `PrepareSessionModal.tsx`.
|
||||||
|
|
||||||
**49. Full-stack features — verify both ends:** Check the full data flow: schema → endpoint → API client → hook → store → UI.
|
**80. TopBar search collapses to icon on mobile:** Full bar (`hidden sm:block`) + icon fallback (`sm:hidden`). Both open `CommandPalette`.
|
||||||
|
|
||||||
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
**81. Never use `transition: all` in landing.css:** Specify exact properties. `transition: all` animates layout and causes jank.
|
||||||
|
|
||||||
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: use alias form (`claude-sonnet-4-6`).
|
**82. `bun` requires PATH setup:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. Chromium deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||||
|
|
||||||
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` with optional `reason`. Frontend: `aiSessionsApi.abandonSession()` → `useFlowPilotSession().abandonSession()`.
|
||||||
|
|
||||||
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
**85. Date range filter end dates must use end-of-day:** Set `toDate.setHours(23, 59, 59, 999)`. For string inputs append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
||||||
|
|
||||||
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
**86. Script Builder:** `/script-builder` — `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + sessionStorage context.
|
||||||
|
|
||||||
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
**87. FlowPilot must ask GUI vs script preference:** Ask BEFORE suggesting either approach. See `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
|
||||||
|
|
||||||
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
**88. Charcoal palette:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. All via CSS variables in `index.css` `@theme`. Accent is electric blue (#60a5fa).
|
||||||
|
|
||||||
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
**92. `tsc -b` in Dockerfile enforces `noUnusedLocals`/`noUnusedParameters` as hard errors.** After refactors, trace every import and destructured prop. Check IDE yellow squiggles before pushing.
|
||||||
|
|
||||||
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
**93. FlowPilot actions live in the page header, not a bottom bar:** Resolve/Escalate/Share Update in header. Desktop: inline + `⋯` overflow (Pause/Close). Mobile: single `⋯`. Bottom = message input only.
|
||||||
|
|
||||||
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
**94. Frontend chat uses `unified_chat_service`, not `assistant_chat_service`:** `AssistantChatPage` → `/ai-sessions/{id}/chat` → `unified_chat_service.py`. Never wire chat into `assistant_chat.py`.
|
||||||
|
|
||||||
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` or `VITE_PUBLIC_*` env var must be added as `ARG` + `ENV` in `frontend/Dockerfile` for Railway deploys. Railway env vars are runtime-only unless explicitly passed through as Docker build args. Without this, `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
**95. Image upload → AI vision:** `uploadsApi.upload()` → `upload_ids` in message → backend fetches S3 → `storage_service.resize_image_for_vision()` (Pillow, 1568px, PNG→JPEG) → base64 → Claude multimodal. Max 3 images/message. Images NOT stored in history.
|
||||||
|
|
||||||
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — there is no intake form screen or "Start" button. Variables are filled inline during execution. Troubleshooting flows DO have a start screen with ticket/client fields. Don't write tests or UI that assume a Start button on procedural flows.
|
**96. `bg-accent` is electric blue — never use for code/kbd.** Use `bg-code` for code blocks, `bg-white/[0.12]` for inline code/badges, `bg-white/[0.08]` for kbd.
|
||||||
|
|
||||||
**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.
|
**97. Railway S3 provisioned:** Bucket `resolutionflow-uploads`. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION`. boto3 in `storage_service.py`.
|
||||||
|
|
||||||
**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"`.
|
**98. `lazyWithRetry` for lazy routes:** Use instead of `React.lazy` — auto-reloads on chunk failures with 10s sessionStorage debounce.
|
||||||
|
|
||||||
**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.
|
**99. `text-secondary` renders invisible on dark backgrounds:** Maps to `--color-secondary` (dark surface). Use `text-muted-foreground` (`#848b9b`) for readable secondary text. Never use `text-muted` for body text.
|
||||||
|
|
||||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`.
|
**100. Hover pop-out card pattern:** `pointer-events-none` on scrim (`z-40`), `z-50` expanded card with own `onClick`, dismiss via `onMouseLeave`. Never put handlers on scrim.
|
||||||
|
|
||||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
|
**101. AI marker format compliance:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]` parsed by `unified_chat_service.py`. History stores `display_content` (stripped). Each user message gets `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`.
|
||||||
|
|
||||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
|
**102. TaskLane activation must happen in ALL chat response paths:** Three paths in `AssistantChatPage.tsx` — `handleSend`, `sendPrefill`, `handleResumeNew`. All must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`.
|
||||||
|
|
||||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
|
**103. Docker not available in code-server:** Use VPS SSH: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python also not available in container.
|
||||||
|
|
||||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
|
**104. `landing.css` uses `--lp-*` variables:** Never use `var(--color-*)` tokens in `landing.css`. Extend the `--lp-*` palette for new landing page colors.
|
||||||
|
|
||||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
|
**105. `npm run build` fails with `EACCES` on `dist/` in code-server:** Use `npx tsc -b` to verify TypeScript without writing to `dist/`.
|
||||||
|
|
||||||
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
|
**106. Guard async "select item → load data → apply state" flows:** Use `currentSelectionRef = useRef(id)` — update on every switch, bail after each `await` if ref no longer matches. See `AssistantChatPage.tsx` `currentChatRef`.
|
||||||
|
|
||||||
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
|
**107. Startup routines use `_admin_session_factory()`:** RLS is enabled; `get_db()` at startup has no `app.current_account_id`, so queries return 0 rows. Affects lifespan, `ensure_service_account`, seed scripts.
|
||||||
|
|
||||||
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
|
**108. Tables with no `account_id` (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level, not file level — one `.py` file can have multiple classes with different columns.
|
||||||
|
|
||||||
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
|
**109. `tree_shares.account_id` must equal `tree.account_id`:** Use tree owner's tenant, not the actor's. Cross-tenant admin shares become invisible after RLS enforcement.
|
||||||
|
|
||||||
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
|
**110. Backfill `account_id` migrations require service-code audit:** Grep all `ModelClass(` sites, verify `account_id=` is passed. SQLAlchemy accepts `None` silently; RLS WITH CHECK surfaces it at runtime as `InsufficientPrivilegeError`.
|
||||||
|
|
||||||
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
|
**111. Global Axios interceptor fires before component `.catch()`:** Fix optional-data endpoints at the source — return `[]`/`{}` on provider failure instead of raising 502. See `list_boards` in `integrations.py`.
|
||||||
|
|
||||||
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
|
|
||||||
|
|
||||||
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Not "Decision Tree Platform". This tagline appears on login, register, and the HTML `<title>`. The old "Decision Tree Platform" was internal jargon misaligned with user-facing branding.
|
|
||||||
|
|
||||||
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` (bottom-sheet on mobile, centered on desktop) and `max-w-full sm:max-w-lg` (full-width on mobile). The shared `Modal.tsx` does this correctly — custom modal implementations must follow the same pattern. See `PrepareSessionModal.tsx` for the fix pattern.
|
|
||||||
|
|
||||||
**80. TopBar search collapses to icon on mobile:** Full search bar (`hidden sm:block`) shows on desktop; magnifying glass icon button (`sm:hidden`) shows on mobile (<640px). Both open the same CommandPalette. Don't add `w-full` search bar without the mobile icon fallback.
|
|
||||||
|
|
||||||
**81. Never use `transition: all` in landing.css:** Specify exact properties: `transition: background 0.3s, border-color 0.3s, box-shadow 0.3s, transform 0.3s, opacity 0.3s`. `transition: all` animates layout properties and causes jank.
|
|
||||||
|
|
||||||
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
|
||||||
|
|
||||||
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
|
|
||||||
|
|
||||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
|
||||||
|
|
||||||
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
|
||||||
|
|
||||||
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
|
|
||||||
|
|
||||||
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
|
|
||||||
|
|
||||||
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
|
|
||||||
|
|
||||||
*(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.
|
|
||||||
|
|
||||||
**93. FlowPilot actions live in the page header, not a bottom bar:** `FlowPilotSessionPage` renders Resolve/Escalate/Share Update in the header bar. Desktop: inline buttons + `⋯` overflow (Pause/Close). Mobile: single `⋯` menu. The bottom only has the message input. `FlowPilotActionBar` component still exists but is no longer used in the main session flow.
|
|
||||||
|
|
||||||
**94. Frontend chat uses unified_chat_service, not assistant_chat_service:** `AssistantChatPage` calls `/ai-sessions/{id}/chat` → `unified_chat_service.py`. The old `assistant_chat_service` endpoints were removed (only retention settings remain at `/assistant/retention`). When tracing chat features, start from `aiSessionsApi.sendChatMessage` → `ai_sessions.py` → `unified_chat_service.py`. Never wire chat features into `assistant_chat.py`.
|
|
||||||
|
|
||||||
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
|
|
||||||
|
|
||||||
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
|
|
||||||
|
|
||||||
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
|
|
||||||
|
|
||||||
**98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`.
|
|
||||||
|
|
||||||
**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components.
|
|
||||||
|
|
||||||
**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements.
|
|
||||||
|
|
||||||
**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active.
|
|
||||||
|
|
||||||
**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message.
|
|
||||||
|
|
||||||
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
|
|
||||||
|
|
||||||
**104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
|
|
||||||
|
|
||||||
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
|
|
||||||
|
|
||||||
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
|
|
||||||
|
|
||||||
## RBAC & Permissions
|
## RBAC & Permissions
|
||||||
|
|
||||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
||||||
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
|
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
|
||||||
- **Backend deps:** `get_current_active_user(user, db)` (any active + auto-downgrades expired trials), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
|
- **Backend deps:** `get_current_active_user`, `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
|
||||||
- **Never use** `role == "admin"` — use `is_super_admin` instead
|
- **Never use** `role == "admin"` — use `is_super_admin` instead
|
||||||
- **Frontend:** `usePermissions()` hook for all permission checks
|
- **Frontend:** `usePermissions()` hook for all permission checks
|
||||||
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
|
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
|
||||||
@@ -388,18 +276,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
|||||||
|
|
||||||
## Design System
|
## Design System
|
||||||
|
|
||||||
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
|
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read before visual/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).
|
- **Theme:** Flat, high-contrast dark (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no gradients on surfaces. Fonts: IBM Plex Sans (body), Bricolage Grotesque (headings), JetBrains Mono (code).
|
||||||
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
|
- **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`)
|
- **Cards:** `bg-card` + 1px `border-default` (`#2a2e3a`), 8px radius. Hover: `border-hover` (`#3d4252`)
|
||||||
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
- **Buttons:** Primary: solid `accent` (#60a5fa / #2563eb), white text, 5px radius. Ghost: transparent + 1px border.
|
||||||
- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
|
- **Inputs:** `bg-input` (`#252830`) + 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.
|
- **Text:** `text-heading` → `text-primary` → `text-muted-foreground` (`#848b9b`). **NEVER `text-secondary`** — maps to a dark surface color.
|
||||||
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
|
- **Functional colors:** `#34d399` success, `#fbbf24` warning, `#f87171` danger, `#67e8f9` info — each has `-dim` at 10% opacity
|
||||||
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
|
- **Deprecated:** No `glass-card`, `backdrop-filter: blur()`, ambient orbs, ember orange (`#f97316`), or cyan as accent
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -407,23 +293,21 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
|||||||
|
|
||||||
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
|
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
|
||||||
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
|
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
|
||||||
- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
|
- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
|
||||||
- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
|
- **Session sharing:** `ShareSessionModal` + `SharedSessionPage`. Utils in `lib/sessionShare.ts`. Share URLs: `/shared/sessions/:token`.
|
||||||
- **Session sharing:** `ShareSessionModal` manages share links, `SharedSessionPage` renders public/account views. Helper utils in `lib/sessionShare.ts`. Share URLs use `/shared/sessions/:token`.
|
|
||||||
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
|
|
||||||
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
|
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
|
||||||
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
|
- **Account section:** `AccountLayout` has NO sidebar nav. New account pages: route under `account` children in `router.tsx` + link card in `AccountSettingsPage`.
|
||||||
- **Dashboard cockpit:** `QuickStartPage` is the copilot-first launchpad. Greeting + "What are you troubleshooting?" + ChatGPT-style `StartSessionInput` (auto-growing textarea, paste images, drag-drop files, attach button, paste logs, suggestion chips). Below: `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible "Dashboard" section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
|
- **Dashboard cockpit:** `QuickStartPage` — `StartSessionInput` + `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
|
||||||
- **Sidebar sections:** Amber "New Session" button → Home → RESOLVE (History) → KNOWLEDGE (Flows with Solutions Library sub-item, Scripts) → INSIGHTS (Data). Footer: Account, Pin/Unpin. No help/guides/feedback in sidebar — accessible via TopBar.
|
- **Sidebar:** Amber "New Session" → Home → RESOLVE → KNOWLEDGE (Flows, Scripts) → INSIGHTS. Footer: Account, Pin/Unpin.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
||||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
- **New page:** Create in `pages/` → route in `router.tsx` → nav link in `AppLayout.tsx`
|
||||||
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
- **New public route:** Add at top level in `router.tsx` (alongside `/login`) — NOT inside `ProtectedRoute`/`AppLayout`
|
||||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head`
|
- **Schema change:** Update model → `alembic revision -m "desc"` (no `--rev-id`) → review → `alembic upgrade head`
|
||||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -431,79 +315,41 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
|||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
Type hints everywhere, async/await for DB, Pydantic validation, `DateTime(timezone=True)` always.
|
||||||
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
Interfaces for all data, `const` over `let`, functional components + hooks.
|
||||||
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
||||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
- Create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||||
- Large features: commit per phase with `npm run build` validation
|
- **Remote is Gitea:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
|
||||||
|
|
||||||
### After Completing Work
|
### After Completing Work
|
||||||
|
1. Update `CURRENT-STATE.md`
|
||||||
When a feature, fix, or significant piece of work is finished and merged/committed:
|
2. Update `03-DEVELOPMENT-ROADMAP.md`
|
||||||
|
3. Close related GitHub Issues: `gh issue close #N`
|
||||||
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
|
4. Update `CLAUDE.md` if new patterns or lessons emerged
|
||||||
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
|
|
||||||
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
|
|
||||||
4. **Update `CLAUDE.md`** if the work introduced new patterns, lessons learned, or changed project structure
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## gstack (Browser & Workflow Skills)
|
## gstack (Browser & Workflow Skills)
|
||||||
|
|
||||||
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
|
**Web browsing:** Always use `/browse`. Never use `mcp__claude-in-chrome__*` tools.
|
||||||
|
|
||||||
**Available skills:**
|
**Skills:** `/office-hours` · `/plan-ceo-review` · `/plan-eng-review` · `/plan-design-review` · `/design-consultation` · `/review` (PR review) · `/ship` · `/browse` (headless QA) · `/qa` (QA + fix) · `/qa-only` · `/design-review` (visual QA) · `/setup-browser-cookies` · `/retro` · `/investigate` · `/document-release` · `/codex` · `/careful` · `/freeze` · `/unfreeze` · `/guard` · `/gstack-upgrade`
|
||||||
|
|
||||||
| Skill | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
|
|
||||||
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
|
|
||||||
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
|
|
||||||
| `/plan-design-review` | Design plan review (UI/UX critique) |
|
|
||||||
| `/design-consultation` | Create a design system / DESIGN.md |
|
|
||||||
| `/review` | Pre-landing PR code review |
|
|
||||||
| `/ship` | Ship workflow (tests, review, PR creation) |
|
|
||||||
| `/browse` | Headless browser for QA testing and site dogfooding |
|
|
||||||
| `/qa` | Systematic QA testing + auto-fix bugs found |
|
|
||||||
| `/qa-only` | QA report only (no fixes) |
|
|
||||||
| `/design-review` | Visual QA — find and fix design inconsistencies |
|
|
||||||
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
|
|
||||||
| `/retro` | Weekly engineering retrospective |
|
|
||||||
| `/investigate` | Systematic debugging with root cause analysis |
|
|
||||||
| `/document-release` | Post-ship documentation updates |
|
|
||||||
| `/codex` | Second opinion via OpenAI Codex CLI |
|
|
||||||
| `/careful` | Safety guardrails for destructive commands |
|
|
||||||
| `/freeze` | Restrict edits to a specific directory |
|
|
||||||
| `/guard` | Full safety mode (careful + freeze) |
|
|
||||||
| `/unfreeze` | Remove edit restrictions |
|
|
||||||
| `/gstack-upgrade` | Upgrade gstack to latest version |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment (Railway)
|
## Deployment (Railway)
|
||||||
|
|
||||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
||||||
- Auto-deploys on push to `main`
|
- Deploy pipeline: push to Gitea → mirrors to GitHub → Railway watches `main`
|
||||||
- PR environments auto-created (need manual domain generation in Railway dashboard)
|
- PR envs: need manual domain generation + `VITE_API_URL` with `https://` prefix
|
||||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
|
||||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
||||||
- Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
|
- Shared Variables auto-propagate to all PR envs — use for `ANTHROPIC_API_KEY` etc.
|
||||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`
|
- Super admin: `backend/make_superadmin_simple.py list|<email>`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Roadmap
|
|
||||||
|
|
||||||
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
|
|
||||||
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -511,112 +357,49 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
|||||||
|
|
||||||
| What | Where |
|
| What | Where |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| API Docs | <http://localhost:8000/api/docs> |
|
| API Docs | http://localhost:8000/api/docs |
|
||||||
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||||
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||||
| GitHub Issues | `gh issue list --state open` |
|
| GitHub Issues | `gh issue list --state open` |
|
||||||
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
|
|
||||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — VPS setup, Docker, CORS, networking |
|
||||||
|
|
||||||
|
|
||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **resolutionflow**. Use it selectively — for routine additive work (new endpoints, new components, isolated fixes) just read the files directly. GitNexus earns its cost when you're about to touch something genuinely central with many callers.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
## Always Do
|
## When to Use It
|
||||||
|
|
||||||
- **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.
|
**Use GitNexus when:**
|
||||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
- Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks
|
||||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
- Renaming anything used across multiple files
|
||||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
- Tracing an unfamiliar bug through a call chain you haven't read
|
||||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
- Assessing whether a refactor is safe before starting
|
||||||
|
|
||||||
## When Debugging
|
**Skip GitNexus when:**
|
||||||
|
- Adding a new endpoint, component, or isolated feature
|
||||||
|
- Fixing a bug in a self-contained file
|
||||||
|
- Making changes you can already see the full scope of by reading the file
|
||||||
|
|
||||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
## Useful Tools
|
||||||
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 |
|
| Tool | When to use | Command |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
| `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` |
|
||||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
| `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` |
|
||||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
| `impact` | Blast radius check before editing a shared symbol | `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})` |
|
| `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
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
A PostToolUse hook re-indexes automatically after `git commit`. To manually refresh:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx gitnexus analyze
|
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 -->
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
> **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.
|
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||||
> **Last Updated:** March 23, 2026
|
> **Last Updated:** April 12, 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,6 +163,13 @@
|
|||||||
- SQL wildcard escaping in tag search
|
- SQL wildcard escaping in tag search
|
||||||
- PSA credentials encrypted at rest (Fernet)
|
- 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 2026)
|
||||||
|
|
||||||
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)
|
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)
|
||||||
|
|||||||
@@ -29,13 +29,37 @@ from app.models.session_branch import SessionBranch # noqa: F401
|
|||||||
from app.models.fork_point import ForkPoint # noqa: F401
|
from app.models.fork_point import ForkPoint # noqa: F401
|
||||||
from app.models.session_handoff import SessionHandoff # noqa: F401
|
from app.models.session_handoff import SessionHandoff # noqa: F401
|
||||||
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
|
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
|
||||||
|
|
||||||
from app.core.config import settings
|
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
|
# this is the Alembic Config object
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# Override sqlalchemy.url with the sync version for migrations
|
# Override sqlalchemy.url with the sync version for migrations
|
||||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
config.set_main_option("sqlalchemy.url", _alembic_sync_url())
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -86,7 +110,7 @@ def run_migrations_online() -> None:
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
connectable = create_engine(
|
connectable = create_engine(
|
||||||
settings.DATABASE_URL_SYNC,
|
_alembic_sync_url(),
|
||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
59
backend/alembic/versions/04f013768235_enable_rls_phase3.py
Normal file
59
backend/alembic/versions/04f013768235_enable_rls_phase3.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""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")
|
||||||
132
backend/alembic/versions/073_add_device_types_table.py
Normal file
132
backend/alembic/versions/073_add_device_types_table.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Add account-scoped device_types table with platform seed data.
|
||||||
|
|
||||||
|
Revision ID: 073
|
||||||
|
Revises: b3c7e9f2a1d8
|
||||||
|
Create Date: 2026-04-12
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
revision = "073"
|
||||||
|
down_revision = "b3c7e9f2a1d8"
|
||||||
|
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),
|
||||||
|
("firewall", "Firewall", "network", 2),
|
||||||
|
("access-point", "Access Point", "network", 3),
|
||||||
|
("load-balancer", "Load Balancer", "network", 4),
|
||||||
|
("server", "Server", "compute", 0),
|
||||||
|
("workstation", "Workstation", "compute", 1),
|
||||||
|
("vm", "Virtual Machine", "compute", 2),
|
||||||
|
("container", "Container", "compute", 3),
|
||||||
|
("nas", "NAS", "storage", 0),
|
||||||
|
("san", "SAN", "storage", 1),
|
||||||
|
("cloud-storage", "Cloud Storage", "storage", 2),
|
||||||
|
("cloud", "Cloud", "cloud", 0),
|
||||||
|
("aws", "AWS", "cloud", 1),
|
||||||
|
("azure", "Azure", "cloud", 2),
|
||||||
|
("gcp", "Google Cloud", "cloud", 3),
|
||||||
|
("printer", "Printer", "endpoint", 0),
|
||||||
|
("phone", "Phone", "endpoint", 1),
|
||||||
|
("iot", "IoT Device", "endpoint", 2),
|
||||||
|
("camera", "Camera", "endpoint", 3),
|
||||||
|
("tablet", "Tablet", "endpoint", 4),
|
||||||
|
("laptop", "Laptop", "endpoint", 5),
|
||||||
|
("ups", "UPS", "infrastructure", 0),
|
||||||
|
("pdu", "PDU", "infrastructure", 1),
|
||||||
|
("rack", "Rack", "infrastructure", 2),
|
||||||
|
("patch-panel", "Patch Panel", "infrastructure", 3),
|
||||||
|
("nvr", "NVR", "security", 0),
|
||||||
|
("badge-reader", "Badge Reader", "security", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"device_types",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("slug", sa.String(50), nullable=False),
|
||||||
|
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("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"])
|
||||||
|
|
||||||
|
device_types_table = sa.table(
|
||||||
|
"device_types",
|
||||||
|
sa.column("id", UUID(as_uuid=True)),
|
||||||
|
sa.column("slug", sa.String),
|
||||||
|
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("sort_order", sa.Integer),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(device_types_table, [
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"slug": slug,
|
||||||
|
"label": label,
|
||||||
|
"category": category,
|
||||||
|
"is_system": True,
|
||||||
|
"account_id": uuid.UUID(_PLATFORM_UUID),
|
||||||
|
"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")
|
||||||
57
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
57
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add network_diagrams table.
|
||||||
|
|
||||||
|
Revision ID: 074
|
||||||
|
Revises: 073
|
||||||
|
Create Date: 2026-04-12
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
|
||||||
|
revision = "074"
|
||||||
|
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("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("client_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("asset_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("thumbnail_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
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})
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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'])
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""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')
|
||||||
90
backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py
Normal file
90
backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""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')
|
||||||
85
backend/alembic/versions/b3c7e9f2a1d8_enable_rls_phase4.py
Normal file
85
backend/alembic/versions/b3c7e9f2a1d8_enable_rls_phase4.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""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")
|
||||||
@@ -24,10 +24,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
token: Annotated[str, Depends(oauth2_scheme)]
|
token: Annotated[str, Depends(oauth2_scheme)]
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get current authenticated user from JWT token."""
|
"""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.
|
||||||
|
"""
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
@@ -77,10 +81,14 @@ async def get_refresh_token_payload(
|
|||||||
async def get_current_active_user(
|
async def get_current_active_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
|
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
|
||||||
Enforces must_change_password — blocks all routes except allowlist."""
|
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.
|
||||||
|
"""
|
||||||
if not current_user.is_active:
|
if not current_user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
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.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.models.refresh_token import RefreshToken
|
from app.models.refresh_token import RefreshToken
|
||||||
@@ -148,7 +149,7 @@ async def update_member_role(
|
|||||||
@router.post("/me/transfer-ownership", response_model=AccountResponse)
|
@router.post("/me/transfer-ownership", response_model=AccountResponse)
|
||||||
async def transfer_ownership(
|
async def transfer_ownership(
|
||||||
data: TransferOwnershipRequest,
|
data: TransferOwnershipRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(require_account_owner)]
|
current_user: Annotated[User, Depends(require_account_owner)]
|
||||||
):
|
):
|
||||||
"""Transfer account ownership to another member (owner only)."""
|
"""Transfer account ownership to another member (owner only)."""
|
||||||
@@ -377,7 +378,7 @@ async def list_invites(
|
|||||||
|
|
||||||
@router.post("/me/leave")
|
@router.post("/me/leave")
|
||||||
async def leave_account(
|
async def leave_account(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
"""Leave the current account (non-owners only). Creates a personal account."""
|
"""Leave the current account (non-owners only). Creates a personal account."""
|
||||||
@@ -423,7 +424,7 @@ class DeleteAccountRequest(BaseModel):
|
|||||||
@router.delete("/me")
|
@router.delete("/me")
|
||||||
async def delete_account(
|
async def delete_account(
|
||||||
data: DeleteAccountRequest,
|
data: DeleteAccountRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(require_account_owner)]
|
current_user: Annotated[User, Depends(require_account_owner)]
|
||||||
):
|
):
|
||||||
"""Delete the current account and soft-delete the user (owner only, no other members)."""
|
"""Delete the current account and soft-delete the user (owner only, no other members)."""
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from typing import Annotated, Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload, aliased
|
||||||
|
|
||||||
from app.core.admin_database import get_admin_db
|
from app.core.admin_database import get_admin_db
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
|
|||||||
from app.models.account_invite import AccountInvite
|
from app.models.account_invite import AccountInvite
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
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.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||||
from app.schemas.user_detail import (
|
from app.schemas.user_detail import (
|
||||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||||
)
|
)
|
||||||
from app.api.deps import require_admin
|
from app.api.deps import require_admin
|
||||||
|
from app.core.subscriptions import get_account_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[UserResponse])
|
@router.get("/users", response_model=AdminUserListResponse)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(require_admin)],
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=100),
|
limit: int = Query(100, ge=1, le=100),
|
||||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
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"),
|
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||||
):
|
):
|
||||||
"""List all users (super admin only)."""
|
"""List users for super admin global people search."""
|
||||||
query = select(User)
|
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:
|
if not include_archived:
|
||||||
query = query.where(User.deleted_at.is_(None))
|
query = query.where(User.deleted_at.is_(None))
|
||||||
|
count_query = count_query.where(User.deleted_at.is_(None))
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
query = query.where(User.is_active == is_active)
|
query = query.where(User.is_active == is_active)
|
||||||
|
count_query = count_query.where(User.is_active == is_active)
|
||||||
if role:
|
if role:
|
||||||
query = query.where(User.role == role)
|
query = query.where(User.role == role)
|
||||||
|
count_query = count_query.where(User.role == role)
|
||||||
if account_id:
|
if account_id:
|
||||||
query = query.where(User.account_id == 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)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
rows = result.all()
|
||||||
return users
|
|
||||||
|
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_admin_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:
|
def _generate_display_code() -> str:
|
||||||
@@ -71,6 +311,192 @@ def _generate_display_code() -> str:
|
|||||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
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_admin_db)],
|
||||||
|
current_user: Annotated[User, Depends(require_admin)],
|
||||||
|
):
|
||||||
|
"""Create a new account without requiring an initial user."""
|
||||||
|
owner_id = None
|
||||||
|
if data.owner_email:
|
||||||
|
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
|
||||||
|
owner = result.scalar_one_or_none()
|
||||||
|
if not owner:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
|
||||||
|
owner_id = owner.id
|
||||||
|
|
||||||
|
display_code = await _generate_unique_display_code(db)
|
||||||
|
new_account = Account(
|
||||||
|
name=data.name.strip(),
|
||||||
|
display_code=display_code,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
db.add(new_account)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
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, "owner_email": data.owner_email},
|
||||||
|
)
|
||||||
|
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_admin_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_admin_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)
|
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
data: AdminUserCreate,
|
data: AdminUserCreate,
|
||||||
@@ -516,6 +942,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
|||||||
return user, subscription
|
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")
|
@router.put("/users/{user_id}/subscription/plan")
|
||||||
async def update_user_plan(
|
async def update_user_plan(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
@@ -535,6 +983,31 @@ async def update_user_plan(
|
|||||||
return {"plan": subscription.plan, "status": subscription.status}
|
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_admin_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")
|
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||||
async def extend_user_trial(
|
async def extend_user_trial(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
@@ -565,6 +1038,43 @@ async def extend_user_trial(
|
|||||||
"current_period_end": subscription.current_period_end}
|
"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_admin_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)
|
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
|
||||||
async def admin_reset_password(
|
async def admin_reset_password(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ async def create_suggestion(
|
|||||||
suggestion = AISuggestion(
|
suggestion = AISuggestion(
|
||||||
tree_id=data.tree_id,
|
tree_id=data.tree_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
action_type=data.action_type,
|
action_type=data.action_type,
|
||||||
target_node_id=data.target_node_id,
|
target_node_id=data.target_node_id,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, update as sa_update
|
from sqlalchemy import select, update as sa_update
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.settings_manager import SettingsManager
|
from app.core.settings_manager import SettingsManager
|
||||||
from app.core.database import get_db
|
from app.core.admin_database import get_admin_db
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
verify_password,
|
verify_password,
|
||||||
@@ -67,7 +67,7 @@ def _generate_display_code() -> str:
|
|||||||
async def register(
|
async def register(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Register a new user.
|
"""Register a new user.
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ async def register(
|
|||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Login and get access token."""
|
"""Login and get access token."""
|
||||||
# Find user by email
|
# Find user by email
|
||||||
@@ -270,7 +270,7 @@ async def login(
|
|||||||
async def login_json(
|
async def login_json(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: UserLogin,
|
credentials: UserLogin,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Login with JSON body (alternative to form data)."""
|
"""Login with JSON body (alternative to form data)."""
|
||||||
result = await db.execute(select(User).where(User.email == credentials.email))
|
result = await db.execute(select(User).where(User.email == credentials.email))
|
||||||
@@ -304,7 +304,7 @@ async def login_json(
|
|||||||
async def refresh_token(
|
async def refresh_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Refresh access token using refresh token (rotation: old token is revoked)."""
|
"""Refresh access token using refresh token (rotation: old token is revoked)."""
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
@@ -368,7 +368,7 @@ async def get_me(
|
|||||||
async def update_me(
|
async def update_me(
|
||||||
data: UserUpdate,
|
data: UserUpdate,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Update current user's profile (name, email)."""
|
"""Update current user's profile (name, email)."""
|
||||||
update_fields = data.model_fields_set - {"current_password"}
|
update_fields = data.model_fields_set - {"current_password"}
|
||||||
@@ -415,7 +415,7 @@ async def update_me(
|
|||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Logout user by revoking the refresh token."""
|
"""Logout user by revoking the refresh token."""
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
@@ -438,7 +438,7 @@ async def change_password(
|
|||||||
request: Request,
|
request: Request,
|
||||||
data: ChangePasswordRequest,
|
data: ChangePasswordRequest,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Change the current user's password."""
|
"""Change the current user's password."""
|
||||||
if not verify_password(data.current_password, current_user.password_hash):
|
if not verify_password(data.current_password, current_user.password_hash):
|
||||||
@@ -478,7 +478,7 @@ async def change_password(
|
|||||||
async def forgot_password(
|
async def forgot_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: ForgotPasswordRequest,
|
data: ForgotPasswordRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Request a password reset email. Always returns success (anti-enumeration)."""
|
"""Request a password reset email. Always returns success (anti-enumeration)."""
|
||||||
result = await db.execute(select(User).where(User.email == data.email))
|
result = await db.execute(select(User).where(User.email == data.email))
|
||||||
@@ -513,7 +513,7 @@ async def forgot_password(
|
|||||||
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
|
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
|
||||||
async def verify_reset_token(
|
async def verify_reset_token(
|
||||||
data: VerifyResetTokenRequest,
|
data: VerifyResetTokenRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Verify a password reset token is valid."""
|
"""Verify a password reset token is valid."""
|
||||||
payload = decode_token(data.token)
|
payload = decode_token(data.token)
|
||||||
@@ -544,7 +544,7 @@ async def verify_reset_token(
|
|||||||
async def reset_password(
|
async def reset_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: ResetPasswordRequest,
|
data: ResetPasswordRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Reset password using a valid reset token."""
|
"""Reset password using a valid reset token."""
|
||||||
payload = decode_token(data.token)
|
payload = decode_token(data.token)
|
||||||
@@ -611,7 +611,7 @@ async def reset_password(
|
|||||||
|
|
||||||
@router.get("/email/verification-status")
|
@router.get("/email/verification-status")
|
||||||
async def get_verification_status(
|
async def get_verification_status(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Check if email verification is enabled on the platform."""
|
"""Check if email verification is enabled on the platform."""
|
||||||
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
||||||
@@ -623,7 +623,7 @@ async def get_verification_status(
|
|||||||
async def send_verification_email(
|
async def send_verification_email(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Send an email verification link to the current user."""
|
"""Send an email verification link to the current user."""
|
||||||
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
|
||||||
@@ -662,7 +662,7 @@ async def send_verification_email(
|
|||||||
@router.post("/email/verify")
|
@router.post("/email/verify")
|
||||||
async def verify_email(
|
async def verify_email(
|
||||||
data: dict,
|
data: dict,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||||
):
|
):
|
||||||
"""Verify an email using a token. Public endpoint."""
|
"""Verify an email using a token. Public endpoint."""
|
||||||
token = data.get("token")
|
token = data.get("token")
|
||||||
|
|||||||
120
backend/app/api/endpoints/device_types.py
Normal file
120
backend/app/api/endpoints/device_types.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Device types API endpoints."""
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_active_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.device_type import DeviceType
|
||||||
|
from app.schemas.device_type import (
|
||||||
|
DeviceTypeCreate,
|
||||||
|
DeviceTypeUpdate,
|
||||||
|
DeviceTypeResponse,
|
||||||
|
)
|
||||||
|
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/device-types", tags=["device-types"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[DeviceTypeResponse])
|
||||||
|
async def list_device_types(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> list[DeviceTypeResponse]:
|
||||||
|
stmt = (
|
||||||
|
select(DeviceType)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
DeviceType.account_id == current_user.account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
return [DeviceTypeResponse.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
|
||||||
|
async def create_device_type(
|
||||||
|
data: DeviceTypeCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> DeviceTypeResponse:
|
||||||
|
existing = await db.execute(
|
||||||
|
select(DeviceType).where(
|
||||||
|
DeviceType.slug == data.slug,
|
||||||
|
DeviceType.account_id == current_user.account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
|
||||||
|
|
||||||
|
system_existing = await db.execute(
|
||||||
|
select(DeviceType).where(
|
||||||
|
DeviceType.slug == data.slug,
|
||||||
|
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if system_existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
|
||||||
|
|
||||||
|
device_type = DeviceType(
|
||||||
|
slug=data.slug,
|
||||||
|
label=data.label,
|
||||||
|
category=data.category,
|
||||||
|
is_system=False,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
sort_order=data.sort_order,
|
||||||
|
)
|
||||||
|
db.add(device_type)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device_type)
|
||||||
|
return DeviceTypeResponse.model_validate(device_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
|
||||||
|
async def update_device_type(
|
||||||
|
device_type_id: UUID,
|
||||||
|
data: DeviceTypeUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> DeviceTypeResponse:
|
||||||
|
device_type = await db.get(DeviceType, device_type_id)
|
||||||
|
if not 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:
|
||||||
|
raise HTTPException(status_code=404, detail="Device type not found")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(device_type, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device_type)
|
||||||
|
return DeviceTypeResponse.model_validate(device_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{device_type_id}", status_code=204)
|
||||||
|
async def delete_device_type(
|
||||||
|
device_type_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> None:
|
||||||
|
device_type = await db.get(DeviceType, device_type_id)
|
||||||
|
if not 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:
|
||||||
|
raise HTTPException(status_code=404, detail="Device type not found")
|
||||||
|
|
||||||
|
await db.delete(device_type)
|
||||||
|
await db.commit()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""PSA integration endpoints — connection CRUD and test."""
|
"""PSA integration endpoints — connection CRUD and test."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
@@ -27,8 +30,20 @@ from app.schemas.psa_connection import (
|
|||||||
PsaMemberMappingSaveRequest,
|
PsaMemberMappingSaveRequest,
|
||||||
PsaMemberResponse,
|
PsaMemberResponse,
|
||||||
AutoMatchResult,
|
AutoMatchResult,
|
||||||
|
PSABoardResponse,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.schemas.psa_tickets import (
|
||||||
|
PSAResourceSchema,
|
||||||
|
PSATicketCreatedSchema,
|
||||||
|
PSATicketStatusUpdateSchema,
|
||||||
|
TicketCreatePayloadSchema,
|
||||||
|
PSAPrioritySchema,
|
||||||
|
TicketListResponseSchema,
|
||||||
|
AiParseRequestSchema,
|
||||||
|
AiParseResponseSchema,
|
||||||
|
)
|
||||||
|
import app.services.ticket_service as ticket_svc
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
decrypt_credentials,
|
decrypt_credentials,
|
||||||
encrypt_credentials,
|
encrypt_credentials,
|
||||||
@@ -345,16 +360,12 @@ async def update_flowpilot_settings(
|
|||||||
# ── ticket / status / company endpoints ──────────────────────────
|
# ── ticket / status / company endpoints ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
@router.get("/boards", response_model=list[PSABoardResponse])
|
||||||
async def search_tickets(
|
async def list_boards(
|
||||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
query: str = "",
|
|
||||||
board_id: int | None = None,
|
|
||||||
status_id: int | None = None,
|
|
||||||
include_closed: bool = False,
|
|
||||||
):
|
):
|
||||||
"""Search ConnectWise tickets."""
|
"""List PSA service boards."""
|
||||||
if not current_user.account_id:
|
if not current_user.account_id:
|
||||||
raise HTTPException(status_code=400, detail="User has no account")
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
@@ -363,25 +374,319 @@ async def search_tickets(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
provider = await get_provider_for_account(current_user.account_id, db)
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
tickets = await provider.search_tickets(
|
boards = await provider.list_boards()
|
||||||
query, board_id=board_id, status_id=status_id, include_closed=include_closed
|
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||||
|
except PSAError as e:
|
||||||
|
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||||
|
logger.warning("list_boards failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/search", response_model=TicketListResponseSchema)
|
||||||
|
async def search_tickets(
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
query: str = "",
|
||||||
|
board_id: int | None = None,
|
||||||
|
status_id: int | None = None,
|
||||||
|
include_closed: bool = False,
|
||||||
|
assigned_to_me: bool = False,
|
||||||
|
unassigned: bool = False,
|
||||||
|
board_ids: str = "",
|
||||||
|
priority: str | None = None,
|
||||||
|
company_id: int | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
):
|
||||||
|
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
|
||||||
|
member_identifier: str | None = None
|
||||||
|
if assigned_to_me:
|
||||||
|
conn_result = await db.execute(
|
||||||
|
select(PsaConnection).where(
|
||||||
|
PsaConnection.account_id == current_user.account_id,
|
||||||
|
PsaConnection.is_active.is_(True),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return [
|
conn = conn_result.scalar_one_or_none()
|
||||||
|
if conn:
|
||||||
|
mapping_result = await db.execute(
|
||||||
|
select(PsaMemberMapping).where(
|
||||||
|
PsaMemberMapping.psa_connection_id == conn.id,
|
||||||
|
PsaMemberMapping.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mapping = mapping_result.scalar_one_or_none()
|
||||||
|
if not mapping:
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
try:
|
||||||
|
_provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
cw_members = await _provider.list_members()
|
||||||
|
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
||||||
|
if matched:
|
||||||
|
member_identifier = matched.identifier
|
||||||
|
else:
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
except PSAError:
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
parsed_board_ids: list[int] = []
|
||||||
|
if board_ids:
|
||||||
|
try:
|
||||||
|
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
result = await provider.search_tickets(
|
||||||
|
query,
|
||||||
|
board_id=board_id,
|
||||||
|
status_id=status_id,
|
||||||
|
include_closed=include_closed,
|
||||||
|
member_identifier=member_identifier,
|
||||||
|
unassigned=unassigned,
|
||||||
|
board_ids=parsed_board_ids,
|
||||||
|
company_id=company_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
items = [
|
||||||
PSATicketSearchResult(
|
PSATicketSearchResult(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
summary=t.summary,
|
summary=t.summary,
|
||||||
company_name=t.company_name,
|
company_name=t.company_name,
|
||||||
|
company_id=t.company_id,
|
||||||
board_name=t.board_name,
|
board_name=t.board_name,
|
||||||
|
board_id=t.board_id,
|
||||||
status_name=t.status_name,
|
status_name=t.status_name,
|
||||||
|
status_id=t.status_id,
|
||||||
priority_name=t.priority_name,
|
priority_name=t.priority_name,
|
||||||
|
priority_id=t.priority_id,
|
||||||
closed=t.closed,
|
closed=t.closed,
|
||||||
)
|
)
|
||||||
for t in tickets
|
for t in result.items
|
||||||
]
|
]
|
||||||
|
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
|
||||||
|
async def create_ticket(
|
||||||
|
data: TicketCreatePayloadSchema,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Create a new PSA ticket."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
from app.services.psa.types import TicketCreatePayload
|
||||||
|
try:
|
||||||
|
return await ticket_svc.create_ticket(
|
||||||
|
current_user.account_id,
|
||||||
|
TicketCreatePayload(**data.model_dump()),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
|
||||||
|
async def ai_parse_ticket(
|
||||||
|
data: AiParseRequestSchema,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Parse natural language into a ticket pre-fill payload using Claude."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
import anthropic
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Fetch boards + members for context (both cached)
|
||||||
|
boards = []
|
||||||
|
members = []
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
boards = await provider.list_boards()
|
||||||
|
members = await provider.list_members()
|
||||||
|
except PSAError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
boards_list = [{"id": b.id, "name": b.name} for b in boards]
|
||||||
|
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
|
||||||
|
|
||||||
|
system_prompt = """You are a ticket triage assistant for an MSP help desk.
|
||||||
|
Extract structured ticket information from the engineer's natural language description.
|
||||||
|
Return ONLY valid JSON matching this exact schema — no other text:
|
||||||
|
{
|
||||||
|
"summary": "short one-line ticket title or null",
|
||||||
|
"board_id": "integer matching one of the provided boards or null",
|
||||||
|
"priority_name": "one of: Critical, High, Medium, Low, or null",
|
||||||
|
"description": "expanded description or null",
|
||||||
|
"assignee_identifier": "member identifier string from the provided members list or null",
|
||||||
|
"warnings": ["list of strings explaining what could not be resolved"]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
user_msg = f"""Available boards: {json.dumps(boards_list)}
|
||||||
|
Available members: {json.dumps(members_list[:50])}
|
||||||
|
|
||||||
|
Engineer's description: {data.prompt}"""
|
||||||
|
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
response_data = AiParseResponseSchema()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = anthropic.AsyncAnthropic(
|
||||||
|
api_key=settings.ANTHROPIC_API_KEY,
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
msg = await client.messages.create(
|
||||||
|
model=settings.get_model_for_action("default"),
|
||||||
|
max_tokens=512,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=[{"role": "user", "content": user_msg}],
|
||||||
|
)
|
||||||
|
raw = msg.content[0].text.strip()
|
||||||
|
# Strip markdown fences if present
|
||||||
|
if raw.startswith("```"):
|
||||||
|
import re
|
||||||
|
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||||
|
raw = re.sub(r'\s*```$', '', raw.strip())
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
|
||||||
|
response_data.summary = parsed.get("summary")
|
||||||
|
response_data.description = parsed.get("description")
|
||||||
|
warnings = parsed.get("warnings", [])
|
||||||
|
|
||||||
|
# Resolve board_id
|
||||||
|
if parsed.get("board_id"):
|
||||||
|
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
|
||||||
|
if board_match:
|
||||||
|
response_data.board_id = board_match.id
|
||||||
|
else:
|
||||||
|
missing_fields.append("board_id")
|
||||||
|
warnings.append(f"Board ID {parsed['board_id']} not found")
|
||||||
|
else:
|
||||||
|
missing_fields.append("board_id")
|
||||||
|
|
||||||
|
# Resolve assignee
|
||||||
|
if parsed.get("assignee_identifier"):
|
||||||
|
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
|
||||||
|
if member:
|
||||||
|
response_data.assigned_member_id = int(member.id)
|
||||||
|
else:
|
||||||
|
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
|
||||||
|
|
||||||
|
# Priority/status/company always need manual selection
|
||||||
|
missing_fields.extend(["status_id", "priority_id", "company_id"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("AI parse failed: %s", e)
|
||||||
|
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
|
||||||
|
warnings = ["AI parsing failed — please fill in manually"]
|
||||||
|
|
||||||
|
response_data.missing_fields = missing_fields
|
||||||
|
response_data.warnings = warnings
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
|
||||||
|
async def update_ticket_status_endpoint(
|
||||||
|
ticket_id: int,
|
||||||
|
status_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Update a ticket's status."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
|
||||||
|
async def list_ticket_resources(
|
||||||
|
ticket_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
# Resources are optional display data — degrade gracefully rather than surfacing a toast
|
||||||
|
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
|
||||||
|
async def add_ticket_resource(
|
||||||
|
ticket_id: int,
|
||||||
|
member_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
|
||||||
|
async def remove_ticket_resource(
|
||||||
|
ticket_id: int,
|
||||||
|
member_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/priorities", response_model=list[PSAPrioritySchema])
|
||||||
|
async def list_priorities(
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""List PSA priority levels for ticket creation form."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
raw = await provider.list_priorities()
|
||||||
|
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
|
||||||
|
except PSAError as e:
|
||||||
|
logger.warning("list_priorities failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tickets/{ticket_id}/context")
|
@router.get("/tickets/{ticket_id}/context")
|
||||||
async def get_ticket_context(
|
async def get_ticket_context(
|
||||||
ticket_id: int,
|
ticket_id: int,
|
||||||
@@ -483,7 +788,30 @@ async def get_ticket_statuses(
|
|||||||
except PSANotFoundError:
|
except PSANotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
|
||||||
|
async def get_board_statuses(
|
||||||
|
board_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Get available statuses for a service board directly (no ticket lookup required)."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
statuses = await provider.get_ticket_statuses(board_id)
|
||||||
|
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
|
||||||
|
except PSAError as e:
|
||||||
|
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── member mapping endpoints ─────────────────────────────────────────
|
# ── member mapping endpoints ─────────────────────────────────────────
|
||||||
@@ -491,7 +819,7 @@ async def get_ticket_statuses(
|
|||||||
|
|
||||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||||
async def list_members(
|
async def list_members(
|
||||||
current_user: Annotated[User, Depends(require_account_owner)],
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
"""List CW members (from CW API)."""
|
"""List CW members (from CW API)."""
|
||||||
@@ -509,7 +837,9 @@ async def list_members(
|
|||||||
for m in members
|
for m in members
|
||||||
]
|
]
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
# Members are optional display data — degrade gracefully
|
||||||
|
logger.warning("list_members failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||||
@@ -517,31 +847,37 @@ async def get_member_mappings(
|
|||||||
current_user: Annotated[User, Depends(require_account_owner)],
|
current_user: Annotated[User, Depends(require_account_owner)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
"""Get all member mappings for the account."""
|
"""Get all account users with their PSA member mappings (unmapped users included)."""
|
||||||
conn = await _get_account_connection(current_user.account_id, db)
|
conn = await _get_account_connection(current_user.account_id, db)
|
||||||
if not conn:
|
if not conn:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
result = await db.execute(
|
# Fetch all active account users
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
|
||||||
|
)
|
||||||
|
users = users_result.scalars().all()
|
||||||
|
|
||||||
|
# Fetch all existing mappings keyed by user_id for O(1) lookup
|
||||||
|
mappings_result = await db.execute(
|
||||||
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||||
)
|
)
|
||||||
mappings = result.scalars().all()
|
mapping_by_user: dict[str, PsaMemberMapping] = {
|
||||||
|
str(m.user_id): m for m in mappings_result.scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
response = []
|
return [
|
||||||
for m in mappings:
|
PsaMemberMappingResponse(
|
||||||
user_result = await db.execute(select(User).where(User.id == m.user_id))
|
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
|
||||||
user = user_result.scalar_one_or_none()
|
user_id=str(user.id),
|
||||||
if user:
|
user_email=user.email,
|
||||||
response.append(PsaMemberMappingResponse(
|
user_name=user.name,
|
||||||
id=str(m.id),
|
external_member_id=m.external_member_id if m else None,
|
||||||
user_id=str(m.user_id),
|
external_member_name=m.external_member_name if m else None,
|
||||||
user_email=user.email,
|
matched_by=m.matched_by if m else None,
|
||||||
user_name=user.name,
|
)
|
||||||
external_member_id=m.external_member_id,
|
for user in users
|
||||||
external_member_name=m.external_member_name,
|
]
|
||||||
matched_by=m.matched_by,
|
|
||||||
))
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||||
@@ -564,6 +900,7 @@ async def save_member_mappings(
|
|||||||
for m in mappings:
|
for m in mappings:
|
||||||
mapping = PsaMemberMapping(
|
mapping = PsaMemberMapping(
|
||||||
psa_connection_id=conn.id,
|
psa_connection_id=conn.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
user_id=UUID(m.user_id),
|
user_id=UUID(m.user_id),
|
||||||
external_member_id=m.external_member_id,
|
external_member_id=m.external_member_id,
|
||||||
external_member_name=m.external_member_name,
|
external_member_name=m.external_member_name,
|
||||||
@@ -624,6 +961,7 @@ async def auto_match_members(
|
|||||||
if not existing.scalar_one_or_none():
|
if not existing.scalar_one_or_none():
|
||||||
mapping = PsaMemberMapping(
|
mapping = PsaMemberMapping(
|
||||||
psa_connection_id=conn.id,
|
psa_connection_id=conn.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
external_member_id=cw_member.id,
|
external_member_id=cw_member.id,
|
||||||
external_member_name=cw_member.name,
|
external_member_name=cw_member.name,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async def create_schedule(
|
|||||||
|
|
||||||
schedule = MaintenanceSchedule(
|
schedule = MaintenanceSchedule(
|
||||||
tree_id=data.tree_id,
|
tree_id=data.tree_id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
cron_expression=data.cron_expression,
|
cron_expression=data.cron_expression,
|
||||||
timezone=data.timezone,
|
timezone=data.timezone,
|
||||||
|
|||||||
362
backend/app/api/endpoints/network_diagrams.py
Normal file
362
backend/app/api/endpoints/network_diagrams.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""Network diagrams API endpoints."""
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
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,
|
||||||
|
NetworkDiagramResponse,
|
||||||
|
NetworkDiagramListItem,
|
||||||
|
AIGenerateRequest,
|
||||||
|
AIGenerateResponse,
|
||||||
|
DiagramImportRequest,
|
||||||
|
DiagramImportResponse,
|
||||||
|
DiagramExportResponse,
|
||||||
|
DiagramNode,
|
||||||
|
DiagramEdge,
|
||||||
|
)
|
||||||
|
from app.services import network_diagram_ai_service, storage_service
|
||||||
|
|
||||||
|
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||||
|
_SLUG_CATEGORY: dict[str, str] = {
|
||||||
|
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
|
||||||
|
"firewall": "security", "badge-reader": "security",
|
||||||
|
"server": "compute", "vm": "compute", "container": "compute",
|
||||||
|
"nas": "storage", "san": "storage", "cloud-storage": "storage",
|
||||||
|
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
|
||||||
|
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
|
||||||
|
"phone": "endpoint", "printer": "endpoint",
|
||||||
|
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
|
||||||
|
"patch-panel": "infrastructure", "camera": "infrastructure",
|
||||||
|
"nvr": "infrastructure", "iot": "infrastructure",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_diagram_or_404(
|
||||||
|
diagram_id: UUID,
|
||||||
|
account_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:
|
||||||
|
raise HTTPException(status_code=404, detail="Diagram not found")
|
||||||
|
return diagram
|
||||||
|
|
||||||
|
|
||||||
|
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
|
||||||
|
return NetworkDiagramResponse.model_validate(diagram)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagram_to_list_item(
|
||||||
|
diagram: NetworkDiagram,
|
||||||
|
custom_slug_category: dict[str, str] | None = None,
|
||||||
|
) -> NetworkDiagramListItem:
|
||||||
|
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
|
||||||
|
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
|
||||||
|
|
||||||
|
category_counts: dict[str, int] = {}
|
||||||
|
for node in nodes:
|
||||||
|
slug = node.get("type", "") if isinstance(node, dict) else ""
|
||||||
|
cat = slug_to_cat.get(slug, "other")
|
||||||
|
category_counts[cat] = category_counts.get(cat, 0) + 1
|
||||||
|
|
||||||
|
return NetworkDiagramListItem(
|
||||||
|
id=diagram.id,
|
||||||
|
name=diagram.name,
|
||||||
|
client_name=diagram.client_name,
|
||||||
|
description=diagram.description,
|
||||||
|
node_count=len(nodes),
|
||||||
|
category_counts=category_counts,
|
||||||
|
thumbnail_url=diagram.thumbnail_url,
|
||||||
|
created_by=diagram.created_by,
|
||||||
|
created_at=diagram.created_at,
|
||||||
|
updated_at=diagram.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
|
||||||
|
stmt = select(DeviceType.slug).where(
|
||||||
|
or_(
|
||||||
|
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
DeviceType.account_id == account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients", response_model=list[str])
|
||||||
|
async def list_client_names(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> list[str]:
|
||||||
|
stmt = (
|
||||||
|
select(NetworkDiagram.client_name)
|
||||||
|
.where(
|
||||||
|
NetworkDiagram.account_id == current_user.account_id,
|
||||||
|
NetworkDiagram.is_archived.is_(False),
|
||||||
|
NetworkDiagram.client_name.isnot(None),
|
||||||
|
NetworkDiagram.client_name != "",
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by(NetworkDiagram.client_name)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[NetworkDiagramListItem])
|
||||||
|
async def list_diagrams(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
client_name: str | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
) -> list[NetworkDiagramListItem]:
|
||||||
|
stmt = (
|
||||||
|
select(NetworkDiagram)
|
||||||
|
.where(
|
||||||
|
NetworkDiagram.account_id == current_user.account_id,
|
||||||
|
NetworkDiagram.is_archived.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(NetworkDiagram.updated_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_name:
|
||||||
|
stmt = stmt.where(NetworkDiagram.client_name == client_name)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
search_filter = f"%{escaped}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
NetworkDiagram.name.ilike(search_filter),
|
||||||
|
NetworkDiagram.client_name.ilike(search_filter),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
dt_result = await db.execute(dt_stmt)
|
||||||
|
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
return [_diagram_to_list_item(r, custom_slug_category) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
|
||||||
|
async def create_diagram(
|
||||||
|
data: NetworkDiagramCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> NetworkDiagramResponse:
|
||||||
|
diagram = NetworkDiagram(
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
name=data.name,
|
||||||
|
client_name=data.client_name,
|
||||||
|
asset_name=data.asset_name,
|
||||||
|
description=data.description,
|
||||||
|
nodes=[n.model_dump() for n in data.nodes],
|
||||||
|
edges=[e.model_dump() for e in data.edges],
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(diagram)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(diagram)
|
||||||
|
return _diagram_to_response(diagram)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
|
||||||
|
async def get_diagram(
|
||||||
|
diagram_id: UUID,
|
||||||
|
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)
|
||||||
|
return _diagram_to_response(diagram)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
|
||||||
|
async def update_diagram(
|
||||||
|
diagram_id: UUID,
|
||||||
|
data: NetworkDiagramUpdate,
|
||||||
|
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)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if "nodes" in update_data and update_data["nodes"] is not None:
|
||||||
|
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
|
||||||
|
if "edges" in update_data and update_data["edges"] is not None:
|
||||||
|
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(diagram, field, value)
|
||||||
|
|
||||||
|
diagram.updated_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(diagram)
|
||||||
|
return _diagram_to_response(diagram)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{diagram_id}", status_code=204)
|
||||||
|
async def archive_diagram(
|
||||||
|
diagram_id: UUID,
|
||||||
|
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.is_archived = True
|
||||||
|
diagram.updated_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
|
||||||
|
async def duplicate_diagram(
|
||||||
|
diagram_id: UUID,
|
||||||
|
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)
|
||||||
|
copy = NetworkDiagram(
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
name=f"Copy of {source.name}",
|
||||||
|
client_name=source.client_name,
|
||||||
|
asset_name=source.asset_name,
|
||||||
|
description=source.description,
|
||||||
|
nodes=source.nodes,
|
||||||
|
edges=source.edges,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(copy)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(copy)
|
||||||
|
return _diagram_to_response(copy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
|
||||||
|
async def export_diagram(
|
||||||
|
diagram_id: UUID,
|
||||||
|
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)
|
||||||
|
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
|
||||||
|
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
|
||||||
|
return DiagramExportResponse(
|
||||||
|
schemaVersion=1,
|
||||||
|
name=diagram.name,
|
||||||
|
client_name=diagram.client_name,
|
||||||
|
description=diagram.description,
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
exportedAt=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
|
||||||
|
async def import_diagram(
|
||||||
|
data: DiagramImportRequest,
|
||||||
|
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)
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
for node in data.nodes:
|
||||||
|
if node.type not in available_slugs:
|
||||||
|
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
|
||||||
|
|
||||||
|
diagram = NetworkDiagram(
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
name=data.name,
|
||||||
|
client_name=data.client_name,
|
||||||
|
description=data.description,
|
||||||
|
nodes=[n.model_dump() for n in data.nodes],
|
||||||
|
edges=[e.model_dump() for e in data.edges],
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(diagram)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(diagram)
|
||||||
|
|
||||||
|
return DiagramImportResponse(
|
||||||
|
diagram=_diagram_to_response(diagram),
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailUploadRequest(BaseModel):
|
||||||
|
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{diagram_id}/thumbnail", status_code=204)
|
||||||
|
async def upload_thumbnail(
|
||||||
|
diagram_id: UUID,
|
||||||
|
body: ThumbnailUploadRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> None:
|
||||||
|
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||||
|
try:
|
||||||
|
header, encoded = body.data_url.split(",", 1)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=422, detail="Invalid data URL format")
|
||||||
|
image_bytes = base64.b64decode(encoded)
|
||||||
|
storage_key = await storage_service.upload_file(
|
||||||
|
file_data=image_bytes,
|
||||||
|
filename=f"thumbnail-{diagram_id}.png",
|
||||||
|
content_type="image/png",
|
||||||
|
account_id=str(current_user.account_id),
|
||||||
|
)
|
||||||
|
presigned_url = storage_service.get_presigned_url(storage_key)
|
||||||
|
diagram.thumbnail_url = presigned_url
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai-generate", response_model=AIGenerateResponse)
|
||||||
|
async def ai_generate_diagram(
|
||||||
|
data: AIGenerateRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> AIGenerateResponse:
|
||||||
|
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
|
||||||
|
available_slugs = list(available_slugs_set)
|
||||||
|
|
||||||
|
existing_node_ids: list[str] | None = None
|
||||||
|
if data.mode == "merge" and data.existingBounds:
|
||||||
|
existing_node_ids = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await network_diagram_ai_service.generate_diagram(
|
||||||
|
request=data,
|
||||||
|
available_slugs=available_slugs,
|
||||||
|
existing_node_ids=existing_node_ids,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AI diagram generation failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Diagram generation failed")
|
||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
from app.core.database import get_db
|
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.assistant_chat import AssistantChat
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
@@ -98,7 +99,7 @@ async def get_onboarding_status(
|
|||||||
|
|
||||||
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
|
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
|
||||||
async def dismiss_onboarding(
|
async def dismiss_onboarding(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
) -> OnboardingStatus:
|
) -> OnboardingStatus:
|
||||||
"""Dismiss the onboarding checklist for the current user."""
|
"""Dismiss the onboarding checklist for the current user."""
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ async def submit_step_feedback(
|
|||||||
new_rating = StepRating(
|
new_rating = StepRating(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
session_id=session_uuid,
|
session_id=session_uuid,
|
||||||
was_helpful=data.was_helpful,
|
was_helpful=data.was_helpful,
|
||||||
# rating is nullable now — thumbs-only mode
|
# rating is nullable now — thumbs-only mode
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async def create_session(
|
|||||||
session = await script_builder_service.create_session(
|
session = await script_builder_service.create_session(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
team_id=current_user.team_id,
|
team_id=current_user.team_id,
|
||||||
language=data.language,
|
language=data.language,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async def start_session(
|
|||||||
new_session = Session(
|
new_session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
tree_snapshot=tree_snapshot,
|
tree_snapshot=tree_snapshot,
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -693,6 +694,7 @@ async def prepare_session(
|
|||||||
new_session = Session(
|
new_session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=data.assigned_to_id or current_user.id,
|
user_id=data.assigned_to_id or current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
tree_snapshot=tree_snapshot,
|
tree_snapshot=tree_snapshot,
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -770,6 +772,7 @@ async def batch_launch_sessions(
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
tree_snapshot=tree_snapshot,
|
tree_snapshot=tree_snapshot,
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -1102,6 +1105,7 @@ async def psa_post_to_ticket(
|
|||||||
# Log to audit trail
|
# Log to audit trail
|
||||||
log_entry = PsaPostLog(
|
log_entry = PsaPostLog(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
psa_connection_id=psa_connection.id if psa_connection else None,
|
psa_connection_id=psa_connection.id if psa_connection else None,
|
||||||
ticket_id=session.psa_ticket_id,
|
ticket_id=session.psa_ticket_id,
|
||||||
note_type=data.note_type,
|
note_type=data.note_type,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import joinedload
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.core.database import get_db
|
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 import Session
|
||||||
from app.models.session_share import SessionShare, SessionShareView
|
from app.models.session_share import SessionShare, SessionShareView
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -210,7 +211,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
|||||||
async def access_share(
|
async def access_share(
|
||||||
share_token: str,
|
share_token: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
):
|
):
|
||||||
"""Access a shared session via share token.
|
"""Access a shared session via share token.
|
||||||
|
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ async def rate_step(
|
|||||||
rating = StepRating(
|
rating = StepRating(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
rating=rating_data.rating,
|
rating=rating_data.rating,
|
||||||
was_helpful=rating_data.was_helpful,
|
was_helpful=rating_data.was_helpful,
|
||||||
review_text=rating_data.review_text,
|
review_text=rating_data.review_text,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async def create_supporting_data(
|
|||||||
|
|
||||||
item = SessionSupportingData(
|
item = SessionSupportingData(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=session.account_id,
|
||||||
label=data.label,
|
label=data.label,
|
||||||
data_type=data.data_type,
|
data_type=data.data_type,
|
||||||
content=data.content,
|
content=data.content,
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ async def list_target_lists(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
"""List all target lists for the current user's team."""
|
"""List all target lists for the current user's account."""
|
||||||
if not current_user.team_id:
|
|
||||||
return []
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TargetList)
|
select(TargetList)
|
||||||
.where(TargetList.team_id == current_user.team_id)
|
.where(TargetList.account_id == current_user.account_id)
|
||||||
.order_by(TargetList.name)
|
.order_by(TargetList.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@@ -36,11 +34,9 @@ async def create_target_list(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
_: None = Depends(require_engineer_or_admin),
|
_: None = Depends(require_engineer_or_admin),
|
||||||
):
|
):
|
||||||
"""Create a new target list for the current team."""
|
"""Create a new target list for the current account."""
|
||||||
if not current_user.team_id:
|
|
||||||
raise HTTPException(status_code=400, detail="User must belong to a team")
|
|
||||||
target_list = TargetList(
|
target_list = TargetList(
|
||||||
team_id=current_user.team_id,
|
account_id=current_user.account_id,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -61,7 +57,7 @@ async def get_target_list(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TargetList).where(
|
select(TargetList).where(
|
||||||
TargetList.id == list_id,
|
TargetList.id == list_id,
|
||||||
TargetList.team_id == current_user.team_id,
|
TargetList.account_id == current_user.account_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
target_list = result.scalar_one_or_none()
|
target_list = result.scalar_one_or_none()
|
||||||
@@ -81,7 +77,7 @@ async def update_target_list(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TargetList).where(
|
select(TargetList).where(
|
||||||
TargetList.id == list_id,
|
TargetList.id == list_id,
|
||||||
TargetList.team_id == current_user.team_id,
|
TargetList.account_id == current_user.account_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
target_list = result.scalar_one_or_none()
|
target_list = result.scalar_one_or_none()
|
||||||
@@ -91,7 +87,7 @@ async def update_target_list(
|
|||||||
if "name" in update_fields and data.name is not None:
|
if "name" in update_fields and data.name is not None:
|
||||||
target_list.name = data.name
|
target_list.name = data.name
|
||||||
if "description" in update_fields:
|
if "description" in update_fields:
|
||||||
target_list.description = data.description # allow setting to None
|
target_list.description = data.description
|
||||||
if "targets" in update_fields and data.targets is not None:
|
if "targets" in update_fields and data.targets is not None:
|
||||||
target_list.targets = [t.model_dump() for t in data.targets]
|
target_list.targets = [t.model_dump() for t in data.targets]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -109,7 +105,7 @@ async def delete_target_list(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TargetList).where(
|
select(TargetList).where(
|
||||||
TargetList.id == list_id,
|
TargetList.id == list_id,
|
||||||
TargetList.team_id == current_user.team_id,
|
TargetList.account_id == current_user.account_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
target_list = result.scalar_one_or_none()
|
target_list = result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@ async def create_tree_share(
|
|||||||
# Create share
|
# Create share
|
||||||
tree_share = TreeShare(
|
tree_share = TreeShare(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
|
account_id=tree.account_id, # share belongs to the tree's tenant, not the actor
|
||||||
share_token=share_token,
|
share_token=share_token,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
allow_forking=share_data.allow_forking,
|
allow_forking=share_data.allow_forking,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.api.endpoints import (
|
|||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
copilot,
|
copilot,
|
||||||
|
device_types,
|
||||||
feedback,
|
feedback,
|
||||||
flow_proposals,
|
flow_proposals,
|
||||||
flowpilot_analytics,
|
flowpilot_analytics,
|
||||||
@@ -32,6 +33,7 @@ from app.api.endpoints import (
|
|||||||
invite,
|
invite,
|
||||||
kb_accelerator,
|
kb_accelerator,
|
||||||
maintenance_schedules,
|
maintenance_schedules,
|
||||||
|
network_diagrams,
|
||||||
notifications,
|
notifications,
|
||||||
onboarding,
|
onboarding,
|
||||||
public_templates,
|
public_templates,
|
||||||
@@ -93,7 +95,6 @@ api_router.include_router(admin_settings.router)
|
|||||||
api_router.include_router(admin_categories.router)
|
api_router.include_router(admin_categories.router)
|
||||||
api_router.include_router(admin_survey.router)
|
api_router.include_router(admin_survey.router)
|
||||||
api_router.include_router(admin_gallery.router)
|
api_router.include_router(admin_gallery.router)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# User-facing endpoints — tenant context required
|
# User-facing endpoints — tenant context required
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -130,6 +131,7 @@ api_router.include_router(integrations.router, dependencies=_tenant_deps)
|
|||||||
api_router.include_router(onboarding.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(branding.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(supporting_data.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
|
# 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_handoffs.queue_router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||||
@@ -142,3 +144,4 @@ api_router.include_router(script_builder.router, dependencies=_tenant_deps)
|
|||||||
api_router.include_router(beta_feedback.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_branches.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
||||||
|
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
"""
|
"""
|
||||||
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
|
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
|
||||||
|
|
||||||
Use ONLY for /admin/* endpoints and internal tooling.
|
Use ONLY where explicit application-level access control makes database-layer
|
||||||
Never use this engine from user-facing endpoints.
|
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 collections.abc import AsyncGenerator
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ _admin_session_factory = async_sessionmaker(
|
|||||||
|
|
||||||
|
|
||||||
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""Yield an admin DB session (BYPASSRLS). Use only on /admin/* endpoints."""
|
"""Yield an admin DB session (BYPASSRLS). See module docstring for approved use cases."""
|
||||||
async with _admin_session_factory() as session:
|
async with _admin_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -12,10 +12,19 @@ async def log_audit(
|
|||||||
resource_type: str,
|
resource_type: str,
|
||||||
resource_id: Optional[UUID] = None,
|
resource_id: Optional[UUID] = None,
|
||||||
details: Optional[dict] = None,
|
details: Optional[dict] = None,
|
||||||
|
account_id: Optional[UUID] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
|
"""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(
|
entry = AuditLog(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
action=action,
|
action=action,
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class Settings(BaseSettings):
|
|||||||
"variable_inference": "fast",
|
"variable_inference": "fast",
|
||||||
"kb_convert": "standard",
|
"kb_convert": "standard",
|
||||||
"script_build": "standard",
|
"script_build": "standard",
|
||||||
|
"network_diagram_generate": "standard",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_for_action(self, action_type: str) -> str:
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
|||||||
"""Create batch sessions for a scheduled maintenance run."""
|
"""Create batch sessions for a scheduled maintenance run."""
|
||||||
# Import all models first to ensure SQLAlchemy mapper relationships resolve
|
# Import all models first to ensure SQLAlchemy mapper relationships resolve
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
from app.core.database import async_session_maker
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.models.maintenance_schedule import MaintenanceSchedule
|
from app.models.maintenance_schedule import MaintenanceSchedule
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.target_list import TargetList
|
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:
|
async def _cleanup_expired_ai_conversations() -> None:
|
||||||
"""Delete expired AI wizard conversations."""
|
"""Delete expired AI wizard conversations."""
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
from app.core.database import async_session_maker
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.models.ai_conversation import AIConversation
|
from app.models.ai_conversation import AIConversation
|
||||||
|
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import logging
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.admin_database import _admin_session_factory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
|
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
|
||||||
@@ -52,40 +54,45 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID:
|
|||||||
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
|
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
|
||||||
"""Ensure the ResolutionFlow service account exists and return its ID.
|
"""Ensure the ResolutionFlow service account exists and return its ID.
|
||||||
|
|
||||||
Idempotent — safe to call on every startup. Creates the account if it
|
Idempotent — safe to call on every startup. This lookup must bypass RLS
|
||||||
does not exist. The account has no usable password and is_service_account=True
|
because startup runs before any request-scoped tenant context exists and
|
||||||
so it can never log in via normal auth flows.
|
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.
|
||||||
"""
|
"""
|
||||||
|
_ = db # Retained for call-site compatibility in app lifespan startup.
|
||||||
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
result = await db.execute(
|
async with _admin_session_factory() as admin_db:
|
||||||
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
result = await admin_db.execute(
|
||||||
)
|
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
||||||
user = result.scalar_one_or_none()
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if not user.is_service_account:
|
if not user.is_service_account:
|
||||||
user.is_service_account = True
|
user.is_service_account = True
|
||||||
await db.commit()
|
await admin_db.commit()
|
||||||
return user.id
|
return user.id
|
||||||
|
|
||||||
account_id = await _ensure_system_account(db)
|
account_id = await _ensure_system_account(admin_db)
|
||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
email=SERVICE_ACCOUNT_EMAIL,
|
email=SERVICE_ACCOUNT_EMAIL,
|
||||||
name=SERVICE_ACCOUNT_NAME,
|
name=SERVICE_ACCOUNT_NAME,
|
||||||
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
||||||
role="engineer",
|
role="engineer",
|
||||||
is_super_admin=False,
|
is_super_admin=False,
|
||||||
is_team_admin=False,
|
is_team_admin=False,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_service_account=True,
|
is_service_account=True,
|
||||||
must_change_password=False,
|
must_change_password=False,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
account_role="engineer",
|
account_role="engineer",
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
admin_db.add(new_user)
|
||||||
await db.commit()
|
await admin_db.commit()
|
||||||
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
||||||
return new_user.id
|
return new_user.id
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ if settings.SENTRY_DSN:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.core.database import init_db, async_session_maker
|
from app.core.database import init_db
|
||||||
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.core.logging_config import setup_logging
|
from app.core.logging_config import setup_logging
|
||||||
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
||||||
from app.core.security_headers import SecurityHeadersMiddleware
|
from app.core.security_headers import SecurityHeadersMiddleware
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ from .session_handoff import SessionHandoff
|
|||||||
from .session_resolution_output import SessionResolutionOutput
|
from .session_resolution_output import SessionResolutionOutput
|
||||||
from .template_tree import TemplateTree
|
from .template_tree import TemplateTree
|
||||||
from .platform_step import PlatformStep
|
from .platform_step import PlatformStep
|
||||||
|
from .device_type import DeviceType
|
||||||
|
from .network_diagram import NetworkDiagram
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -126,4 +128,6 @@ __all__ = [
|
|||||||
"SessionResolutionOutput",
|
"SessionResolutionOutput",
|
||||||
"TemplateTree",
|
"TemplateTree",
|
||||||
"PlatformStep",
|
"PlatformStep",
|
||||||
|
"DeviceType",
|
||||||
|
"NetworkDiagram",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class AuditLog(Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
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)
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
resource_type: 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(
|
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
|||||||
47
backend/app/models/device_type.py
Normal file
47
backend/app/models/device_type.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Device type model for network diagrams."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceType(Base):
|
||||||
|
"""A device type for network diagram nodes (platform or account-custom)."""
|
||||||
|
__tablename__ = "device_types"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
slug: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False,
|
||||||
|
comment="Unique identifier used in diagram node data",
|
||||||
|
)
|
||||||
|
label: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False,
|
||||||
|
comment="Display name",
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False,
|
||||||
|
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
|
||||||
|
)
|
||||||
|
is_system: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False,
|
||||||
|
comment="True for built-in types that cannot be deleted",
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="Platform account for system types, tenant account for custom types",
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0,
|
||||||
|
comment="Display order within category",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
53
backend/app/models/network_diagram.py
Normal file
53
backend/app/models/network_diagram.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Network diagram model."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDiagram(Base):
|
||||||
|
"""A network topology diagram scoped to one account."""
|
||||||
|
__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(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||||
|
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False,
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
creator: Mapped["User | None"] = relationship("User", foreign_keys=[created_by])
|
||||||
@@ -8,7 +8,6 @@ from app.core.database import Base
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.team import Team
|
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
|
|
||||||
|
|
||||||
@@ -18,10 +17,6 @@ class TargetList(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
)
|
)
|
||||||
team_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
|
|
||||||
nullable=False, index=True
|
|
||||||
)
|
|
||||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ class TreeShare(Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
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(
|
share_token: Mapped[str] = mapped_column(
|
||||||
String(64),
|
String(64),
|
||||||
unique=True,
|
unique=True,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .psa_connection import (
|
|||||||
PSATicketSearchResult, PSATicketStatusItem,
|
PSATicketSearchResult, PSATicketStatusItem,
|
||||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||||
|
PSABoardResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -50,4 +51,5 @@ __all__ = [
|
|||||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||||
|
"PSABoardResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,6 +28,111 @@ class ActivityEntry(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin Accounts & People Search ---
|
||||||
|
|
||||||
|
class AdminUserListItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: EmailStr
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
is_super_admin: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
account_id: Optional[UUID] = None
|
||||||
|
account_role: Optional[str] = None
|
||||||
|
account_name: Optional[str] = None
|
||||||
|
account_display_code: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserListResponse(BaseModel):
|
||||||
|
items: list[AdminUserListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountMember(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: EmailStr
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
is_super_admin: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
account_role: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountOwnerSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountSubscriptionSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
plan: str
|
||||||
|
status: str
|
||||||
|
billing_interval: Optional[str] = None
|
||||||
|
current_period_end: Optional[datetime] = None
|
||||||
|
cancel_at_period_end: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountUsageSummary(BaseModel):
|
||||||
|
tree_count: int = 0
|
||||||
|
session_count_this_month: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountInviteSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: EmailStr
|
||||||
|
role: str
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
used_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountListItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
display_code: str
|
||||||
|
created_at: datetime
|
||||||
|
owner_id: Optional[UUID] = None
|
||||||
|
owner: Optional[AdminAccountOwnerSummary] = None
|
||||||
|
subscription: Optional[AdminAccountSubscriptionSummary] = None
|
||||||
|
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
|
||||||
|
member_count: int = 0
|
||||||
|
active_member_count: int = 0
|
||||||
|
pending_invite_count: int = 0
|
||||||
|
sso_enabled: bool = False
|
||||||
|
branding_company_name: Optional[str] = None
|
||||||
|
members: list[AdminAccountMember] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountListResponse(BaseModel):
|
||||||
|
items: list[AdminAccountListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountDetailResponse(AdminAccountListItem):
|
||||||
|
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
plan: Literal["free", "pro", "team"] = "free"
|
||||||
|
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccountUpdate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
# --- Audit Logs ---
|
# --- Audit Logs ---
|
||||||
|
|
||||||
class AuditLogEntry(BaseModel):
|
class AuditLogEntry(BaseModel):
|
||||||
@@ -215,7 +320,7 @@ class AdminUserCreate(BaseModel):
|
|||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
account_mode: Literal["existing", "personal"]
|
account_mode: Literal["existing", "personal"]
|
||||||
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
|
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
|
||||||
account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
||||||
send_email: bool = True
|
send_email: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
37
backend/app/schemas/device_type.py
Normal file
37
backend/app/schemas/device_type.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Pydantic schemas for device types."""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTypeCreate(BaseModel):
|
||||||
|
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
category: str = Field(
|
||||||
|
min_length=1, max_length=50,
|
||||||
|
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
|
||||||
|
)
|
||||||
|
sort_order: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTypeUpdate(BaseModel):
|
||||||
|
label: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
category: str | None = Field(
|
||||||
|
default=None, min_length=1, max_length=50,
|
||||||
|
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
|
||||||
|
)
|
||||||
|
sort_order: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTypeResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
slug: str
|
||||||
|
label: str
|
||||||
|
category: str
|
||||||
|
is_system: bool
|
||||||
|
account_id: UUID
|
||||||
|
sort_order: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
145
backend/app/schemas/network_diagram.py
Normal file
145
backend/app/schemas/network_diagram.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Pydantic schemas for network diagrams."""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceProperties(BaseModel):
|
||||||
|
hostname: str | None = None
|
||||||
|
ip: str | None = None
|
||||||
|
subnet: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
vlan: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStyle(BaseModel):
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramNode(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
label: str
|
||||||
|
position: Position
|
||||||
|
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
||||||
|
nodeType: str | None = None
|
||||||
|
style: NodeStyle | None = None
|
||||||
|
parentId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramEdge(BaseModel):
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
label: str | None = None
|
||||||
|
connectionType: str = "ethernet"
|
||||||
|
speed: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
routing: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDiagramCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
client_name: str | None = None
|
||||||
|
asset_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||||
|
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDiagramUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
client_name: str | None = None
|
||||||
|
asset_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
nodes: list[DiagramNode] | None = None
|
||||||
|
edges: list[DiagramEdge] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDiagramResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
account_id: UUID
|
||||||
|
name: str
|
||||||
|
client_name: str | None = None
|
||||||
|
asset_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||||
|
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||||
|
thumbnail_url: str | None = None
|
||||||
|
is_archived: bool = False
|
||||||
|
created_by: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDiagramListItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
client_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
node_count: int = 0
|
||||||
|
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
thumbnail_url: str | None = None
|
||||||
|
created_by: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBounds(BaseModel):
|
||||||
|
minX: float
|
||||||
|
maxX: float
|
||||||
|
minY: float
|
||||||
|
maxY: float
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerateRequest(BaseModel):
|
||||||
|
description: str = Field(min_length=1, max_length=5000)
|
||||||
|
client_name: str | None = None
|
||||||
|
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
|
||||||
|
existingBounds: ExistingBounds | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerateResponse(BaseModel):
|
||||||
|
nodes: list[DiagramNode]
|
||||||
|
edges: list[DiagramEdge]
|
||||||
|
suggestedName: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramImportRequest(BaseModel):
|
||||||
|
schemaVersion: int = Field(ge=1, le=1)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
client_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||||
|
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramImportResponse(BaseModel):
|
||||||
|
diagram: NetworkDiagramResponse
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramExportResponse(BaseModel):
|
||||||
|
schemaVersion: int = 1
|
||||||
|
name: str
|
||||||
|
client_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
nodes: list[DiagramNode]
|
||||||
|
edges: list[DiagramEdge]
|
||||||
|
exportedAt: str
|
||||||
@@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
summary: str
|
summary: str
|
||||||
company_name: str | None = None
|
company_name: str | None = None
|
||||||
|
company_id: str | None = None
|
||||||
board_name: str | None = None
|
board_name: str | None = None
|
||||||
|
board_id: int | None = None
|
||||||
status_name: str | None = None
|
status_name: str | None = None
|
||||||
|
status_id: int | None = None
|
||||||
priority_name: str | None = None
|
priority_name: str | None = None
|
||||||
|
priority_id: int | None = None
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -111,13 +115,13 @@ class PsaPostLogResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PsaMemberMappingResponse(BaseModel):
|
class PsaMemberMappingResponse(BaseModel):
|
||||||
id: str
|
id: str | None = None # None for users without a mapping
|
||||||
user_id: str
|
user_id: str
|
||||||
user_email: str
|
user_email: str
|
||||||
user_name: str
|
user_name: str
|
||||||
external_member_id: str
|
external_member_id: str | None = None
|
||||||
external_member_name: str
|
external_member_name: str | None = None
|
||||||
matched_by: str
|
matched_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PsaMemberMappingSaveRequest(BaseModel):
|
class PsaMemberMappingSaveRequest(BaseModel):
|
||||||
@@ -136,3 +140,8 @@ class PsaMemberResponse(BaseModel):
|
|||||||
class AutoMatchResult(BaseModel):
|
class AutoMatchResult(BaseModel):
|
||||||
matched: list[PsaMemberMappingResponse]
|
matched: list[PsaMemberMappingResponse]
|
||||||
unmatched_users: int
|
unmatched_users: int
|
||||||
|
|
||||||
|
|
||||||
|
class PSABoardResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|||||||
64
backend/app/schemas/psa_tickets.py
Normal file
64
backend/app/schemas/psa_tickets.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Normalized DTOs for ticket management endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PSAResourceSchema(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
member_name: str
|
||||||
|
member_identifier: str
|
||||||
|
is_rf_user: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PSATicketCreatedSchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
summary: str
|
||||||
|
board_name: str
|
||||||
|
status_name: str
|
||||||
|
priority_name: str
|
||||||
|
company_name: str
|
||||||
|
resources: list[PSAResourceSchema] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PSATicketStatusUpdateSchema(BaseModel):
|
||||||
|
ticket_id: int
|
||||||
|
previous_status: str
|
||||||
|
new_status: str
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreatePayloadSchema(BaseModel):
|
||||||
|
summary: str
|
||||||
|
company_id: int
|
||||||
|
board_id: int
|
||||||
|
status_id: int
|
||||||
|
priority_id: int
|
||||||
|
description: str | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketListResponseSchema(BaseModel):
|
||||||
|
items: list = []
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 25
|
||||||
|
|
||||||
|
|
||||||
|
class AiParseRequestSchema(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
class AiParseResponseSchema(BaseModel):
|
||||||
|
summary: str | None = None
|
||||||
|
company_id: int | None = None
|
||||||
|
board_id: int | None = None
|
||||||
|
priority_id: int | None = None
|
||||||
|
status_id: int | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PSAPrioritySchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
@@ -23,7 +23,7 @@ class TargetListUpdate(BaseModel):
|
|||||||
|
|
||||||
class TargetListResponse(BaseModel):
|
class TargetListResponse(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
team_id: UUID
|
account_id: UUID
|
||||||
created_by: Optional[UUID]
|
created_by: Optional[UUID]
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
|
|||||||
@@ -68,4 +68,4 @@ class RoleUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AccountRoleUpdate(BaseModel):
|
class AccountRoleUpdate(BaseModel):
|
||||||
account_role: str = Field(..., pattern="^(engineer|viewer)$")
|
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")
|
||||||
|
|||||||
@@ -154,6 +154,23 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
|||||||
- If a question is clearly outside your domain, say so briefly and redirect.
|
- If a question is clearly outside your domain, say so briefly and redirect.
|
||||||
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
||||||
|
|
||||||
|
## SPIN-OFF TICKET CREATION
|
||||||
|
|
||||||
|
When you identify a second distinct issue that is clearly separate from the primary topic \
|
||||||
|
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
|
||||||
|
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
[ACTIONS]
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Create ticket: <brief issue title>",
|
||||||
|
"command": "create_spin_off_ticket",
|
||||||
|
"description": "<one sentence description of the separate issue>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
[/ACTIONS]
|
||||||
|
|
||||||
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
||||||
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
||||||
No exceptions. Not even when forking. A response without at least one of these markers \
|
No exceptions. Not even when forking. A response without at least one of these markers \
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class BranchManager:
|
|||||||
root = SessionBranch(
|
root = SessionBranch(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=session.account_id,
|
||||||
parent_branch_id=None,
|
parent_branch_id=None,
|
||||||
branch_order=1,
|
branch_order=1,
|
||||||
label="Root",
|
label="Root",
|
||||||
@@ -68,9 +69,17 @@ class BranchManager:
|
|||||||
"status": "untried",
|
"status": "untried",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Load session to get account_id for FK constraints
|
||||||
|
session_result = await self.db.execute(
|
||||||
|
select(AISession).where(AISession.id == session_id)
|
||||||
|
)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
account_id = session.account_id if session else None
|
||||||
|
|
||||||
fork_point = ForkPoint(
|
fork_point = ForkPoint(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=account_id,
|
||||||
parent_branch_id=parent_branch_id,
|
parent_branch_id=parent_branch_id,
|
||||||
trigger_step_id=trigger_step_id,
|
trigger_step_id=trigger_step_id,
|
||||||
fork_reason=fork_reason,
|
fork_reason=fork_reason,
|
||||||
@@ -90,6 +99,7 @@ class BranchManager:
|
|||||||
branch = SessionBranch(
|
branch = SessionBranch(
|
||||||
id=branch_ids[i],
|
id=branch_ids[i],
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=account_id,
|
||||||
parent_branch_id=parent_branch_id,
|
parent_branch_id=parent_branch_id,
|
||||||
fork_point_step_id=trigger_step_id,
|
fork_point_step_id=trigger_step_id,
|
||||||
branch_order=i + 1,
|
branch_order=i + 1,
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ async def start_session(
|
|||||||
# 7. Create first step
|
# 7. Create first step
|
||||||
step = _create_step_from_parsed(
|
step = _create_step_from_parsed(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
step_order=0,
|
step_order=0,
|
||||||
parsed=parsed,
|
parsed=parsed,
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
@@ -433,6 +434,7 @@ async def process_response(
|
|||||||
# Create new step
|
# Create new step
|
||||||
step = _create_step_from_parsed(
|
step = _create_step_from_parsed(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
step_order=session.step_count - 1,
|
step_order=session.step_count - 1,
|
||||||
parsed=parsed,
|
parsed=parsed,
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
@@ -694,6 +696,7 @@ async def pickup_session(
|
|||||||
briefing_step = AISessionStep(
|
briefing_step = AISessionStep(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
branch_id=session.active_branch_id if session.is_branching else None,
|
branch_id=session.active_branch_id if session.is_branching else None,
|
||||||
step_order=session.step_count,
|
step_order=session.step_count,
|
||||||
step_type="action",
|
step_type="action",
|
||||||
@@ -765,6 +768,7 @@ async def pickup_session(
|
|||||||
|
|
||||||
next_step = _create_step_from_parsed(
|
next_step = _create_step_from_parsed(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
step_order=session.step_count - 1,
|
step_order=session.step_count - 1,
|
||||||
parsed=parsed,
|
parsed=parsed,
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
@@ -997,6 +1001,7 @@ async def generate_status_update(
|
|||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
branch_id=session.active_branch_id if session.is_branching else None,
|
branch_id=session.active_branch_id if session.is_branching else None,
|
||||||
step_order=session.step_count,
|
step_order=session.step_count,
|
||||||
step_type="status_update",
|
step_type="status_update",
|
||||||
@@ -1440,6 +1445,7 @@ def _format_engineer_response(request: StepResponseRequest) -> str:
|
|||||||
|
|
||||||
def _create_step_from_parsed(
|
def _create_step_from_parsed(
|
||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
step_order: int,
|
step_order: int,
|
||||||
parsed: dict[str, Any],
|
parsed: dict[str, Any],
|
||||||
input_tokens: int,
|
input_tokens: int,
|
||||||
@@ -1487,6 +1493,7 @@ def _create_step_from_parsed(
|
|||||||
return AISessionStep(
|
return AISessionStep(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=account_id,
|
||||||
branch_id=branch_id,
|
branch_id=branch_id,
|
||||||
step_order=step_order,
|
step_order=step_order,
|
||||||
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
|
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class HandoffManager:
|
|||||||
|
|
||||||
handoff = SessionHandoff(
|
handoff = SessionHandoff(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=session.account_id,
|
||||||
handed_off_by=user_id,
|
handed_off_by=user_id,
|
||||||
intent=intent,
|
intent=intent,
|
||||||
source_branch_id=session.active_branch_id,
|
source_branch_id=session.active_branch_id,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.database import async_session_maker
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.models.ai_session import AISession
|
from app.models.ai_session import AISession
|
||||||
from app.services.knowledge_flywheel import analyze_session
|
from app.services.knowledge_flywheel import analyze_session
|
||||||
|
|
||||||
|
|||||||
151
backend/app/services/network_diagram_ai_service.py
Normal file
151
backend/app/services/network_diagram_ai_service.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""AI service for generating network diagrams from natural language."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.network_diagram import (
|
||||||
|
AIGenerateRequest,
|
||||||
|
AIGenerateResponse,
|
||||||
|
DiagramNode,
|
||||||
|
DiagramEdge,
|
||||||
|
DeviceProperties,
|
||||||
|
Position,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
|
||||||
|
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.
|
||||||
|
|
||||||
|
Return this exact structure:
|
||||||
|
{{
|
||||||
|
"nodes": [
|
||||||
|
{{
|
||||||
|
"id": "unique-string",
|
||||||
|
"type": "device-type-slug",
|
||||||
|
"label": "device label",
|
||||||
|
"position": {{ "x": number, "y": number }},
|
||||||
|
"properties": {{
|
||||||
|
"hostname": "string or null",
|
||||||
|
"ip": "string or null",
|
||||||
|
"subnet": "string or null",
|
||||||
|
"vendor": "string or null",
|
||||||
|
"model": "string or null",
|
||||||
|
"role": "string or null",
|
||||||
|
"vlan": "string or null",
|
||||||
|
"notes": "string or null",
|
||||||
|
"status": "unknown"
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{{
|
||||||
|
"id": "unique-string",
|
||||||
|
"source": "node-id",
|
||||||
|
"target": "node-id",
|
||||||
|
"label": "connection label or null",
|
||||||
|
"connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
|
||||||
|
"speed": "string or null",
|
||||||
|
"notes": "string or null"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"suggestedName": "short descriptive diagram name",
|
||||||
|
"notes": "any important assumptions or missing info, or null"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Available device type slugs: {available_slugs}
|
||||||
|
|
||||||
|
Position nodes thoughtfully in a logical network topology layout.
|
||||||
|
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
|
||||||
|
Place WAN/internet at top, core network in middle, endpoints at bottom.
|
||||||
|
{merge_instructions}"""
|
||||||
|
|
||||||
|
MERGE_INSTRUCTIONS = """
|
||||||
|
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
|
||||||
|
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
|
||||||
|
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
|
||||||
|
You may create edges that connect new nodes to existing nodes if the description implies a connection.
|
||||||
|
Use these existing node IDs for connections: {existing_node_ids}"""
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_diagram(
|
||||||
|
request: AIGenerateRequest,
|
||||||
|
available_slugs: list[str],
|
||||||
|
existing_node_ids: list[str] | None = None,
|
||||||
|
) -> AIGenerateResponse:
|
||||||
|
merge_instructions = ""
|
||||||
|
if request.mode == "merge" and request.existingBounds:
|
||||||
|
b = request.existingBounds
|
||||||
|
merge_instructions = MERGE_INSTRUCTIONS.format(
|
||||||
|
minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
|
||||||
|
existing_node_ids=", ".join(existing_node_ids or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
|
available_slugs=", ".join(available_slugs),
|
||||||
|
merge_instructions=merge_instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = settings.get_model_for_action("network_diagram_generate")
|
||||||
|
provider = get_ai_provider(model)
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": request.description}]
|
||||||
|
|
||||||
|
response_text, input_tokens, output_tokens = await provider.generate_json(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Network diagram AI generation: input_tokens=%d, output_tokens=%d",
|
||||||
|
input_tokens, output_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(response_text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse AI response as JSON: %s", e)
|
||||||
|
raise ValueError("AI generated an invalid response, please try again")
|
||||||
|
|
||||||
|
try:
|
||||||
|
nodes = []
|
||||||
|
for raw_node in data.get("nodes", []):
|
||||||
|
node_type = raw_node.get("type", "server")
|
||||||
|
if node_type not in available_slugs:
|
||||||
|
logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
|
||||||
|
node_type = "server"
|
||||||
|
|
||||||
|
nodes.append(DiagramNode(
|
||||||
|
id=raw_node["id"],
|
||||||
|
type=node_type,
|
||||||
|
label=raw_node.get("label", node_type),
|
||||||
|
position=Position(**raw_node.get("position", {"x": 0, "y": 0})),
|
||||||
|
properties=DeviceProperties(**{
|
||||||
|
k: v for k, v in raw_node.get("properties", {}).items()
|
||||||
|
if k in DeviceProperties.model_fields
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
|
||||||
|
edges = []
|
||||||
|
for raw_edge in data.get("edges", []):
|
||||||
|
edges.append(DiagramEdge(
|
||||||
|
id=raw_edge["id"],
|
||||||
|
source=raw_edge["source"],
|
||||||
|
target=raw_edge["target"],
|
||||||
|
label=raw_edge.get("label"),
|
||||||
|
connectionType=raw_edge.get("connectionType", "ethernet"),
|
||||||
|
speed=raw_edge.get("speed"),
|
||||||
|
notes=raw_edge.get("notes"),
|
||||||
|
))
|
||||||
|
except KeyError as e:
|
||||||
|
logger.warning("AI response missing required field: %s", e)
|
||||||
|
raise ValueError(f"AI generated incomplete data (missing {e}), please try again")
|
||||||
|
|
||||||
|
return AIGenerateResponse(
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
suggestedName=data.get("suggestedName"),
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
@@ -11,6 +11,11 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ class AutotaskProvider(PSAProvider):
|
|||||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
async def post_note(
|
async def post_note(
|
||||||
@@ -58,6 +63,9 @@ class AutotaskProvider(PSAProvider):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def list_boards(self) -> list[PSABoard]:
|
||||||
|
raise NotImplementedError("list_boards not implemented for this provider")
|
||||||
|
|
||||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
@@ -70,3 +78,18 @@ class AutotaskProvider(PSAProvider):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from .types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ class PSAProvider(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -64,6 +69,10 @@ class PSAProvider(ABC):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_boards(self) -> list[PSABoard]:
|
||||||
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
...
|
...
|
||||||
@@ -78,3 +87,23 @@ class PSAProvider(ABC):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
from .client import ConnectWiseClient
|
from .client import ConnectWiseClient
|
||||||
|
|
||||||
@@ -54,34 +59,59 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
)
|
)
|
||||||
return self._map_ticket(data)
|
return self._map_ticket(data)
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
"""Search CW tickets by summary. Supports board_id and status_id filters."""
|
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier,
|
||||||
|
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
|
||||||
|
page_size = filters.get("page_size", 10)
|
||||||
|
page = filters.get("page", 1)
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||||
"orderBy": "id desc",
|
"orderBy": "priority/sort asc,dateEntered desc",
|
||||||
"pageSize": 25,
|
"pageSize": page_size,
|
||||||
|
"page": page,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build CW condition query
|
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
if query:
|
if query:
|
||||||
conditions.append(f"summary contains '{query}'")
|
# Sanitize: strip single quotes to prevent CW condition injection
|
||||||
|
safe_query = query.replace("'", "")
|
||||||
|
conditions.append(f"summary contains '{safe_query}'")
|
||||||
if filters.get("board_id"):
|
if filters.get("board_id"):
|
||||||
conditions.append(f"board/id = {filters['board_id']}")
|
conditions.append(f"board/id = {filters['board_id']}")
|
||||||
if filters.get("status_id"):
|
if filters.get("status_id"):
|
||||||
conditions.append(f"status/id = {filters['status_id']}")
|
conditions.append(f"status/id = {filters['status_id']}")
|
||||||
if not filters.get("include_closed", False):
|
if not filters.get("include_closed", False):
|
||||||
conditions.append("closedFlag = false")
|
conditions.append("closedFlag = false")
|
||||||
|
if filters.get("member_identifier") is not None:
|
||||||
|
conditions.append(f"resources contains '{filters['member_identifier']}'")
|
||||||
|
if filters.get("unassigned", False):
|
||||||
|
conditions.append("resources = null")
|
||||||
|
board_ids: list[int] = filters.get("board_ids") or []
|
||||||
|
if board_ids:
|
||||||
|
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||||
|
conditions.append(f"board/id in ({board_list})")
|
||||||
|
if filters.get("company_id"):
|
||||||
|
conditions.append(f"company/id = {int(filters['company_id'])}")
|
||||||
|
|
||||||
if conditions:
|
condition_str = " and ".join(conditions) if conditions else ""
|
||||||
params["conditions"] = " and ".join(conditions)
|
if condition_str:
|
||||||
|
params["conditions"] = condition_str
|
||||||
|
|
||||||
data = await self.client.get("/service/tickets", params=params)
|
count_params: dict = {}
|
||||||
|
if condition_str:
|
||||||
|
count_params["conditions"] = condition_str
|
||||||
|
|
||||||
return [
|
# Fire page fetch + count in parallel
|
||||||
self._map_ticket(t)
|
data, count_data = await asyncio.gather(
|
||||||
for t in (data if isinstance(data, list) else [])
|
self.client.get("/service/tickets", params=params),
|
||||||
]
|
self.client.get("/service/tickets/count", params=count_params),
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
|
||||||
|
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
|
||||||
|
|
||||||
|
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
async def get_ticket_configurations(
|
async def get_ticket_configurations(
|
||||||
self, ticket_id: str
|
self, ticket_id: str
|
||||||
@@ -270,6 +300,32 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
psa_cache.set(cache_key, result, ttl_seconds=900)
|
psa_cache.set(cache_key, result, ttl_seconds=900)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def list_boards(self) -> list[PSABoard]:
|
||||||
|
"""List active CW service boards (cached 1 hour)."""
|
||||||
|
cache_key = "boards"
|
||||||
|
cached = psa_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
data = await self.client.get(
|
||||||
|
"/service/boards",
|
||||||
|
params={
|
||||||
|
"fields": "id,name,inactiveFlag",
|
||||||
|
"conditions": "inactiveFlag = false",
|
||||||
|
"pageSize": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = [
|
||||||
|
PSABoard(
|
||||||
|
id=b["id"],
|
||||||
|
name=b["name"],
|
||||||
|
inactive=b.get("inactiveFlag", False),
|
||||||
|
)
|
||||||
|
for b in (data if isinstance(data, list) else [])
|
||||||
|
]
|
||||||
|
psa_cache.set(cache_key, result, ttl_seconds=3600)
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Ticket Context ────────────────────────────────────────────────
|
# ── Ticket Context ────────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_ticket_context(
|
async def get_ticket_context(
|
||||||
@@ -536,7 +592,7 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
if work_type:
|
if work_type:
|
||||||
payload["workType"] = {"name": work_type}
|
payload["workType"] = {"name": work_type}
|
||||||
|
|
||||||
data = await self._client.post("/time/entries", payload)
|
data = await self.client.post("/time/entries", payload)
|
||||||
return PSATimeEntry(
|
return PSATimeEntry(
|
||||||
id=str(data["id"]),
|
id=str(data["id"]),
|
||||||
ticket_id=ticket_id,
|
ticket_id=ticket_id,
|
||||||
@@ -551,16 +607,112 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _map_ticket(data: dict) -> PSATicket:
|
def _map_ticket(data: dict) -> PSATicket:
|
||||||
"""Map a CW ticket JSON dict to a PSATicket."""
|
"""Map a CW ticket JSON dict to a PSATicket."""
|
||||||
|
company = data.get("company") or {}
|
||||||
|
board = data.get("board") or {}
|
||||||
|
status = data.get("status") or {}
|
||||||
|
priority = data.get("priority") or {}
|
||||||
return PSATicket(
|
return PSATicket(
|
||||||
id=str(data["id"]),
|
id=str(data.get("id", "")),
|
||||||
summary=data.get("summary", ""),
|
summary=data.get("summary", ""),
|
||||||
company_name=data.get("company", {}).get("name"),
|
company_name=company.get("name"),
|
||||||
company_id=str(data["company"]["id"]) if data.get("company") else None,
|
company_id=str(company.get("id")) if company.get("id") else None,
|
||||||
board_name=data.get("board", {}).get("name"),
|
board_name=board.get("name"),
|
||||||
board_id=data.get("board", {}).get("id"),
|
board_id=board.get("id"),
|
||||||
status_name=data.get("status", {}).get("name"),
|
status_name=status.get("name"),
|
||||||
status_id=data.get("status", {}).get("id"),
|
status_id=status.get("id"),
|
||||||
priority_name=data.get("priority", {}).get("name"),
|
priority_name=priority.get("name"),
|
||||||
priority_id=data.get("priority", {}).get("id"),
|
priority_id=priority.get("id"),
|
||||||
closed=data.get("closedFlag", False),
|
closed=data.get("closedFlag", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Resource management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
"""List members assigned to a CW ticket."""
|
||||||
|
data = await self.client.get(f"/service/tickets/{ticket_id}/members")
|
||||||
|
results = []
|
||||||
|
for m in (data if isinstance(data, list) else []):
|
||||||
|
member = m.get("member") or {}
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=member.get("id", 0),
|
||||||
|
member_name=member.get("name", ""),
|
||||||
|
member_identifier=member.get("identifier", ""),
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
"""Assign a member to a CW ticket."""
|
||||||
|
data = await self.client.post(
|
||||||
|
f"/service/tickets/{ticket_id}/members",
|
||||||
|
json_body={"member": {"id": member_id}},
|
||||||
|
)
|
||||||
|
member = (data.get("member") or {}) if isinstance(data, dict) else {}
|
||||||
|
return PSAResource(
|
||||||
|
member_id=member.get("id", member_id),
|
||||||
|
member_name=member.get("name", ""),
|
||||||
|
member_identifier=member.get("identifier", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
"""Remove a member from a CW ticket (idempotent)."""
|
||||||
|
# CW DELETE requires the member record id (junction record), not the member's id
|
||||||
|
members_data = await self.client.get(f"/service/tickets/{ticket_id}/members")
|
||||||
|
record_id = None
|
||||||
|
for m in (members_data if isinstance(members_data, list) else []):
|
||||||
|
if (m.get("member") or {}).get("id") == member_id:
|
||||||
|
record_id = m.get("id")
|
||||||
|
break
|
||||||
|
if record_id is None:
|
||||||
|
return # Already not assigned — idempotent
|
||||||
|
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
|
||||||
|
|
||||||
|
# ── Ticket creation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
"""Create a new CW service ticket."""
|
||||||
|
body: dict = {
|
||||||
|
"summary": payload.summary,
|
||||||
|
"board": {"id": payload.board_id},
|
||||||
|
"company": {"id": payload.company_id},
|
||||||
|
"status": {"id": payload.status_id},
|
||||||
|
"priority": {"id": payload.priority_id},
|
||||||
|
}
|
||||||
|
if payload.description:
|
||||||
|
body["initialDescription"] = payload.description
|
||||||
|
if payload.assigned_member_id:
|
||||||
|
body["owner"] = {"id": payload.assigned_member_id}
|
||||||
|
|
||||||
|
data = await self.client.post("/service/tickets", json_body=body)
|
||||||
|
|
||||||
|
ticket_id = data.get("id") if isinstance(data, dict) else None
|
||||||
|
resources: list[PSAResource] = []
|
||||||
|
if ticket_id and payload.assigned_member_id:
|
||||||
|
try:
|
||||||
|
resources = await self.list_resources(ticket_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
company = (data.get("company") or {}) if isinstance(data, dict) else {}
|
||||||
|
board = (data.get("board") or {}) if isinstance(data, dict) else {}
|
||||||
|
status = (data.get("status") or {}) if isinstance(data, dict) else {}
|
||||||
|
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
return PSACreatedTicket(
|
||||||
|
id=ticket_id or 0,
|
||||||
|
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
|
||||||
|
board_name=board.get("name", ""),
|
||||||
|
status_name=status.get("name", ""),
|
||||||
|
priority_name=priority.get("name", ""),
|
||||||
|
company_name=company.get("name", ""),
|
||||||
|
resources=resources,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Priorities ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
"""List CW service priorities."""
|
||||||
|
data = await self.client.get("/service/priorities", params={"pageSize": 50})
|
||||||
|
return [
|
||||||
|
{"id": p.get("id"), "name": p.get("name")}
|
||||||
|
for p in (data if isinstance(data, list) else [])
|
||||||
|
]
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
async def post_note(
|
async def post_note(
|
||||||
@@ -58,6 +63,9 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def list_boards(self) -> list[PSABoard]:
|
||||||
|
raise NotImplementedError("list_boards not implemented for this provider")
|
||||||
|
|
||||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
@@ -70,3 +78,18 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|||||||
@@ -67,6 +67,46 @@ class PSATimeEntry(BaseModel):
|
|||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PSABoard(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
inactive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedTicketResult(BaseModel):
|
||||||
|
items: list[PSATicket]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class PSAResource(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
member_name: str
|
||||||
|
member_identifier: str
|
||||||
|
is_rf_user: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PSACreatedTicket(BaseModel):
|
||||||
|
id: int
|
||||||
|
summary: str
|
||||||
|
board_name: str
|
||||||
|
status_name: str
|
||||||
|
priority_name: str
|
||||||
|
company_name: str
|
||||||
|
resources: list[PSAResource] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreatePayload(BaseModel):
|
||||||
|
summary: str
|
||||||
|
company_id: int
|
||||||
|
board_id: int
|
||||||
|
status_id: int
|
||||||
|
priority_id: int
|
||||||
|
description: str | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class NoteType:
|
class NoteType:
|
||||||
INTERNAL_ANALYSIS = "internal_analysis"
|
INTERNAL_ANALYSIS = "internal_analysis"
|
||||||
RESOLUTION = "resolution"
|
RESOLUTION = "resolution"
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ async def push_documentation(
|
|||||||
# Log success
|
# Log success
|
||||||
log_entry = PsaPostLog(
|
log_entry = PsaPostLog(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
account_id=session.account_id,
|
||||||
ai_session_id=session.id,
|
ai_session_id=session.id,
|
||||||
psa_connection_id=session.psa_connection_id,
|
psa_connection_id=session.psa_connection_id,
|
||||||
ticket_id=session.psa_ticket_id,
|
ticket_id=session.psa_ticket_id,
|
||||||
@@ -394,6 +395,7 @@ async def push_documentation(
|
|||||||
# Log failure with retry scheduling
|
# Log failure with retry scheduling
|
||||||
log_entry = PsaPostLog(
|
log_entry = PsaPostLog(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
account_id=session.account_id,
|
||||||
ai_session_id=session.id,
|
ai_session_id=session.id,
|
||||||
psa_connection_id=session.psa_connection_id,
|
psa_connection_id=session.psa_connection_id,
|
||||||
ticket_id=session.psa_ticket_id,
|
ticket_id=session.psa_ticket_id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import async_session_maker
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.models.psa_post_log import PsaPostLog
|
from app.models.psa_post_log import PsaPostLog
|
||||||
from app.services.psa_documentation_service import retry_failed_push
|
from app.services.psa_documentation_service import retry_failed_push
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ResolutionOutputGenerator:
|
|||||||
|
|
||||||
output = SessionResolutionOutput(
|
output = SessionResolutionOutput(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
account_id=session.account_id,
|
||||||
output_type=output_type,
|
output_type=output_type,
|
||||||
generated_content=content,
|
generated_content=content,
|
||||||
status="draft",
|
status="draft",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
|
|
||||||
from sqlalchemy import select, delete, func
|
from sqlalchemy import select, delete, func
|
||||||
|
|
||||||
from app.core.database import async_session_maker
|
from app.core.admin_database import _admin_session_factory as async_session_maker
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
from app.models.assistant_chat import AssistantChat
|
from app.models.assistant_chat import AssistantChat
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ def _extract_script_from_response(content: str, language: str) -> tuple[str | No
|
|||||||
async def create_session(
|
async def create_session(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
team_id: UUID | None,
|
team_id: UUID | None,
|
||||||
language: str,
|
language: str,
|
||||||
initial_prompt: str | None = None,
|
initial_prompt: str | None = None,
|
||||||
@@ -151,6 +152,7 @@ async def create_session(
|
|||||||
"""Create a new Script Builder session."""
|
"""Create a new Script Builder session."""
|
||||||
session = ScriptBuilderSession(
|
session = ScriptBuilderSession(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
|
|||||||
115
backend/app/services/ticket_service.py
Normal file
115
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.psa_connection import PsaConnection
|
||||||
|
from app.models.psa_member_mapping import PsaMemberMapping
|
||||||
|
from app.schemas.psa_tickets import (
|
||||||
|
PSAResourceSchema,
|
||||||
|
PSATicketCreatedSchema,
|
||||||
|
PSATicketStatusUpdateSchema,
|
||||||
|
)
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.types import TicketCreatePayload
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
|
||||||
|
"""Return set of external_member_id ints that are mapped to RF users."""
|
||||||
|
conn_result = await db.execute(
|
||||||
|
select(PsaConnection).where(PsaConnection.account_id == account_id)
|
||||||
|
)
|
||||||
|
conn = conn_result.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
return set()
|
||||||
|
mappings = await db.execute(
|
||||||
|
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||||
|
)
|
||||||
|
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_resources(
|
||||||
|
account_id: UUID, ticket_id: int, db: AsyncSession
|
||||||
|
) -> list[PSAResourceSchema]:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
resources = await provider.list_resources(ticket_id)
|
||||||
|
return [
|
||||||
|
PSAResourceSchema(
|
||||||
|
member_id=r.member_id,
|
||||||
|
member_name=r.member_name,
|
||||||
|
member_identifier=r.member_identifier,
|
||||||
|
is_rf_user=r.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
for r in resources
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_resource(
|
||||||
|
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
|
||||||
|
) -> PSAResourceSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
resource = await provider.add_resource(ticket_id, member_id)
|
||||||
|
return PSAResourceSchema(
|
||||||
|
member_id=resource.member_id,
|
||||||
|
member_name=resource.member_name,
|
||||||
|
member_identifier=resource.member_identifier,
|
||||||
|
is_rf_user=resource.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_resource(
|
||||||
|
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
await provider.remove_resource(ticket_id, member_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
|
||||||
|
) -> PSATicketStatusUpdateSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
# get current status before updating
|
||||||
|
ticket = await provider.get_ticket(str(ticket_id))
|
||||||
|
previous_status = ticket.status_name or ""
|
||||||
|
await provider.update_ticket_status(str(ticket_id), status_id)
|
||||||
|
# get new status name from statuses list
|
||||||
|
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
|
||||||
|
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
|
||||||
|
return PSATicketStatusUpdateSchema(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=new_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ticket(
|
||||||
|
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
|
||||||
|
) -> PSATicketCreatedSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
result = await provider.create_ticket(payload)
|
||||||
|
return PSATicketCreatedSchema(
|
||||||
|
id=result.id,
|
||||||
|
summary=result.summary,
|
||||||
|
board_name=result.board_name,
|
||||||
|
status_name=result.status_name,
|
||||||
|
priority_name=result.priority_name,
|
||||||
|
company_name=result.company_name,
|
||||||
|
resources=[
|
||||||
|
PSAResourceSchema(
|
||||||
|
member_id=r.member_id,
|
||||||
|
member_name=r.member_name,
|
||||||
|
member_identifier=r.member_identifier,
|
||||||
|
is_rf_user=r.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
for r in result.resources
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -80,7 +80,10 @@ def _display_code() -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
# Must use ADMIN_DATABASE_URL (BYPASSRLS) — Phase 4 enabled RLS on users.
|
||||||
|
# The app-role connection has no tenant context at seed time and would see 0 rows.
|
||||||
|
admin_url = getattr(settings, "ADMIN_DATABASE_URL", None) or settings.DATABASE_URL
|
||||||
|
engine = create_async_engine(admin_url, echo=False)
|
||||||
password_hash = get_password_hash(SHARED_PASSWORD)
|
password_hash = get_password_hash(SHARED_PASSWORD)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
team_account_id: uuid.UUID | None = None
|
team_account_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
|
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by
|
||||||
|
# global categories, gallery items, and other platform-owned content.
|
||||||
|
await conn.execute(sa.text("""
|
||||||
|
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'ResolutionFlow System',
|
||||||
|
'RF-SYS-1',
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""))
|
||||||
|
|
||||||
# Create async session maker
|
# Create async session maker
|
||||||
async_session_maker = async_sessionmaker(
|
async_session_maker = async_sessionmaker(
|
||||||
engine,
|
engine,
|
||||||
|
|||||||
@@ -19,8 +19,116 @@ class TestAdminEndpoints:
|
|||||||
"/api/v1/admin/users", headers=admin_auth_headers
|
"/api/v1/admin/users", headers=admin_auth_headers
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
users = response.json()
|
payload = response.json()
|
||||||
assert len(users) >= 2 # admin + test_user
|
assert payload["total"] >= 2 # admin + test_user
|
||||||
|
assert len(payload["items"]) >= 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_supports_search(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""Test admin people search by user email."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
params={"search": test_user["email"]},
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["total"] >= 1
|
||||||
|
assert any(item["email"] == test_user["email"] for item in payload["items"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_accounts_as_admin(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test listing accounts with member data."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/accounts", headers=admin_auth_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["total"] >= 1
|
||||||
|
assert len(payload["items"]) >= 1
|
||||||
|
assert "members" in payload["items"][0]
|
||||||
|
assert "subscription" in payload["items"][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_account_as_admin(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test creating an empty account from admin."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/accounts",
|
||||||
|
json={"name": "Acme Customer", "plan": "pro"},
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["name"] == "Acme Customer"
|
||||||
|
assert payload["subscription"]["plan"] == "pro"
|
||||||
|
assert payload["display_code"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_account_detail_as_admin(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""Test fetching account detail for management view."""
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/admin/accounts/{account_id}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["id"] == account_id
|
||||||
|
assert "members" in payload
|
||||||
|
assert "invites" in payload
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_account_name_as_admin(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""Test renaming an account from admin detail view."""
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
response = await client.put(
|
||||||
|
f"/api/v1/admin/accounts/{account_id}",
|
||||||
|
json={"name": "Renamed Customer Account"},
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["id"] == account_id
|
||||||
|
assert payload["name"] == "Renamed Customer Account"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_account_plan(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""Test changing an account's subscription plan."""
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
response = await client.put(
|
||||||
|
f"/api/v1/admin/accounts/{account_id}/subscription/plan",
|
||||||
|
json={"plan": "pro"},
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["plan"] == "pro"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extend_account_trial(
|
||||||
|
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""Test starting or extending an account trial."""
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
response = await client.put(
|
||||||
|
f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial",
|
||||||
|
json={"days": 14},
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "trialing"
|
||||||
|
assert response.json()["current_period_end"] is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_users_as_non_admin(
|
async def test_list_users_as_non_admin(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TestAdminGlobalCategories:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "Test Category"
|
assert data["name"] == "Test Category"
|
||||||
assert data["slug"] == "test-category"
|
assert data["slug"] == "test-category"
|
||||||
assert data["account_id"] is None
|
assert data["account_id"] == "00000000-0000-0000-0000-000000000001" # PLATFORM_ACCOUNT_ID
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_global_category(
|
async def test_update_global_category(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.script_template import ScriptTemplate, ScriptCategory
|
from app.models.script_template import ScriptTemplate, ScriptCategory
|
||||||
|
|
||||||
|
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -22,6 +23,7 @@ async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
|
|||||||
name="Gallery Test Flow",
|
name="Gallery Test Flow",
|
||||||
tree_type="troubleshooting",
|
tree_type="troubleshooting",
|
||||||
visibility="public",
|
visibility="public",
|
||||||
|
account_id=_PLATFORM_ACCOUNT_ID,
|
||||||
is_gallery_featured=False,
|
is_gallery_featured=False,
|
||||||
gallery_sort_order=0,
|
gallery_sort_order=0,
|
||||||
tree_structure={
|
tree_structure={
|
||||||
@@ -53,6 +55,7 @@ async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate
|
|||||||
script = ScriptTemplate(
|
script = ScriptTemplate(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
category_id=category.id,
|
category_id=category.id,
|
||||||
|
account_id=_PLATFORM_ACCOUNT_ID,
|
||||||
name="Gallery Test Script",
|
name="Gallery Test Script",
|
||||||
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
|
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
|
||||||
script_body="Write-Host 'Test'",
|
script_body="Write-Host 'Test'",
|
||||||
|
|||||||
@@ -594,6 +594,7 @@ class TestPsaMetrics:
|
|||||||
post_log = PsaPostLog(
|
post_log = PsaPostLog(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
ai_session_id=push_session_id,
|
ai_session_id=push_session_id,
|
||||||
|
account_id=account_id,
|
||||||
ticket_id="TICKET-123",
|
ticket_id="TICKET-123",
|
||||||
note_type="internal",
|
note_type="internal",
|
||||||
content_posted="Session summary",
|
content_posted="Session summary",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
|
from app.models.account import Account
|
||||||
from app.models.team import Team
|
from app.models.team import Team
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ async def _create_team_with_admin(
|
|||||||
team_name: str = "Branding Test Team",
|
team_name: str = "Branding Test Team",
|
||||||
) -> tuple[dict, str, Team]:
|
) -> tuple[dict, str, Team]:
|
||||||
"""Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
|
"""Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
|
||||||
|
account = Account(name=team_name, display_code=uuid.uuid4().hex[:8].upper())
|
||||||
|
test_db.add(account)
|
||||||
team = Team(name=team_name)
|
team = Team(name=team_name)
|
||||||
test_db.add(team)
|
test_db.add(team)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
@@ -36,6 +39,8 @@ async def _create_team_with_admin(
|
|||||||
team_id=team.id,
|
team_id=team.id,
|
||||||
is_team_admin=True,
|
is_team_admin=True,
|
||||||
role="engineer",
|
role="engineer",
|
||||||
|
account_id=account.id,
|
||||||
|
account_role="engineer",
|
||||||
)
|
)
|
||||||
test_db.add(user)
|
test_db.add(user)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
@@ -58,6 +63,15 @@ async def _create_team_member(
|
|||||||
is_team_admin: bool = False,
|
is_team_admin: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a regular team member. Returns auth_headers."""
|
"""Create a regular team member. Returns auth_headers."""
|
||||||
|
# Look up the account associated with this team via an existing member
|
||||||
|
from sqlalchemy import select as _select
|
||||||
|
from app.models.user import User as _User
|
||||||
|
result = await test_db.execute(
|
||||||
|
_select(_User).where(_User.team_id == team.id).limit(1)
|
||||||
|
)
|
||||||
|
team_member = result.scalar_one_or_none()
|
||||||
|
member_account_id = team_member.account_id if team_member else None
|
||||||
|
|
||||||
email = f"member_{uuid.uuid4().hex[:8]}@test.com"
|
email = f"member_{uuid.uuid4().hex[:8]}@test.com"
|
||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
@@ -67,6 +81,8 @@ async def _create_team_member(
|
|||||||
team_id=team.id,
|
team_id=team.id,
|
||||||
is_team_admin=is_team_admin,
|
is_team_admin=is_team_admin,
|
||||||
role="engineer",
|
role="engineer",
|
||||||
|
account_id=member_account_id,
|
||||||
|
account_role="engineer",
|
||||||
)
|
)
|
||||||
test_db.add(user)
|
test_db.add(user)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|||||||
@@ -334,12 +334,13 @@ class TestDraftTreesAPI:
|
|||||||
"""Test that migration defaults existing trees to published status."""
|
"""Test that migration defaults existing trees to published status."""
|
||||||
# Create a tree without specifying status (relies on DB default)
|
# Create a tree without specifying status (relies on DB default)
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
_platform_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||||
tree = Tree(
|
tree = Tree(
|
||||||
name="Legacy Tree",
|
name="Legacy Tree",
|
||||||
description="Created before status field",
|
description="Created before status field",
|
||||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||||
author_id=None,
|
author_id=None,
|
||||||
account_id=None
|
account_id=_platform_id,
|
||||||
)
|
)
|
||||||
test_db.add(tree)
|
test_db.add(tree)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|||||||
@@ -127,10 +127,12 @@ async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_header
|
|||||||
test_db.add(other_team)
|
test_db.add(other_team)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
|
from uuid import UUID as _UUID
|
||||||
other_tree = Tree(
|
other_tree = Tree(
|
||||||
name="Other Team Tree",
|
name="Other Team Tree",
|
||||||
tree_type="maintenance",
|
tree_type="maintenance",
|
||||||
team_id=other_team.id,
|
team_id=other_team.id,
|
||||||
|
account_id=_UUID("00000000-0000-0000-0000-000000000001"),
|
||||||
tree_structure={
|
tree_structure={
|
||||||
"steps": [
|
"steps": [
|
||||||
{"id": "s1", "type": "procedure_step", "title": "Step",
|
{"id": "s1", "type": "procedure_step", "title": "Step",
|
||||||
|
|||||||
96
backend/tests/test_network_diagrams.py
Normal file
96
backend/tests/test_network_diagrams.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.device_type import DeviceType
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_headers(client, email: str, password: str) -> dict[str, str]:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login/json",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_types_include_platform_and_account_custom(client, test_db, auth_headers, test_user):
|
||||||
|
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
test_db.add(
|
||||||
|
DeviceType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
slug="platform-router",
|
||||||
|
label="Platform Router",
|
||||||
|
category="network",
|
||||||
|
is_system=True,
|
||||||
|
account_id=PLATFORM_ACCOUNT_ID,
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/device-types/",
|
||||||
|
json={
|
||||||
|
"slug": "tenant-appliance",
|
||||||
|
"label": "Tenant Appliance",
|
||||||
|
"category": "network",
|
||||||
|
"sort_order": 3,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
assert create_response.json()["account_id"] == str(user.account_id)
|
||||||
|
|
||||||
|
list_response = await client.get("/api/v1/device-types/", headers=auth_headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
payload = list_response.json()
|
||||||
|
slugs = {item["slug"] for item in payload}
|
||||||
|
|
||||||
|
assert "platform-router" in slugs
|
||||||
|
assert "tenant-appliance" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_network_diagrams_are_account_scoped(client, test_db, auth_headers, test_user):
|
||||||
|
other_user = {
|
||||||
|
"email": "other-network@example.com",
|
||||||
|
"password": "TestPassword123!",
|
||||||
|
"name": "Other Network User",
|
||||||
|
}
|
||||||
|
register_response = await client.post("/api/v1/auth/register", json=other_user)
|
||||||
|
assert register_response.status_code in (200, 201)
|
||||||
|
other_headers = await _login_headers(client, other_user["email"], other_user["password"])
|
||||||
|
|
||||||
|
owner_result = await test_db.execute(select(User).where(User.email == test_user["email"]))
|
||||||
|
owner = owner_result.scalar_one()
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/network-diagrams/",
|
||||||
|
json={
|
||||||
|
"name": "HQ Core",
|
||||||
|
"client_name": "Acme",
|
||||||
|
"description": "Primary topology",
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
diagram = create_response.json()
|
||||||
|
assert diagram["account_id"] == str(owner.account_id)
|
||||||
|
|
||||||
|
own_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=auth_headers)
|
||||||
|
assert own_get.status_code == 200
|
||||||
|
|
||||||
|
other_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=other_headers)
|
||||||
|
assert other_get.status_code == 404
|
||||||
@@ -200,6 +200,7 @@ class TestAccountPermissions:
|
|||||||
})
|
})
|
||||||
outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"}
|
outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"}
|
||||||
|
|
||||||
# Outsider should NOT see the private tree
|
# Outsider should NOT see the private tree.
|
||||||
|
# With RLS, the tree is invisible to other tenants — 404 not 403.
|
||||||
response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers)
|
response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 404
|
||||||
|
|||||||
@@ -464,7 +464,6 @@ async def test_target_list_account_id_from_team_admin(test_db: AsyncSession):
|
|||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
target_list = TargetList(
|
target_list = TargetList(
|
||||||
team_id=team.id,
|
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
name="Server Targets",
|
name="Server Targets",
|
||||||
|
|||||||
55
backend/tests/test_psa_tickets.py
Normal file
55
backend/tests/test_psa_tickets.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# backend/tests/test_psa_tickets.py
|
||||||
|
"""Routing and auth tests for new ticket management endpoints."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ticket_requires_auth(client):
|
||||||
|
"""POST /tickets returns 401 without auth."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/integrations/psa/tickets",
|
||||||
|
json={
|
||||||
|
"summary": "Test", "company_id": 1, "board_id": 1,
|
||||||
|
"status_id": 1, "priority_id": 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_resources_requires_auth(client):
|
||||||
|
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
|
||||||
|
"""search endpoint returns TicketListResponse shape when no PSA connected."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/integrations/psa/tickets/search",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
# No PSA connection → 400 or 502; with PSA → 200
|
||||||
|
assert response.status_code in (200, 400, 502)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_status_requires_auth(client):
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ai_parse_requires_auth(client):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/integrations/psa/tickets/ai-parse",
|
||||||
|
json={"prompt": "New ticket for Acme"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
|
|
||||||
|
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -41,6 +43,7 @@ async def _create_featured_tree(db: AsyncSession, name: str = "Featured Flow", f
|
|||||||
description="A featured flow for the gallery",
|
description="A featured flow for the gallery",
|
||||||
tree_type="troubleshooting",
|
tree_type="troubleshooting",
|
||||||
tree_structure=_make_tree_structure(4),
|
tree_structure=_make_tree_structure(4),
|
||||||
|
account_id=_PLATFORM_ACCOUNT_ID,
|
||||||
is_gallery_featured=featured,
|
is_gallery_featured=featured,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
usage_count=42,
|
usage_count=42,
|
||||||
@@ -74,6 +77,7 @@ async def _create_featured_script(
|
|||||||
) -> ScriptTemplate:
|
) -> ScriptTemplate:
|
||||||
script = ScriptTemplate(
|
script = ScriptTemplate(
|
||||||
category_id=category.id,
|
category_id=category.id,
|
||||||
|
account_id=_PLATFORM_ACCOUNT_ID,
|
||||||
name=name,
|
name=name,
|
||||||
slug=name.lower().replace(" ", "-"),
|
slug=name.lower().replace(" ", "-"),
|
||||||
description="A gallery-featured script",
|
description="A gallery-featured script",
|
||||||
@@ -312,7 +316,7 @@ class TestCategoriesEndpoint:
|
|||||||
from app.models.category import TreeCategory
|
from app.models.category import TreeCategory
|
||||||
|
|
||||||
# Create a category and a featured tree in that category
|
# Create a category and a featured tree in that category
|
||||||
cat = TreeCategory(name="Networking", slug="networking", is_active=True)
|
cat = TreeCategory(name="Networking", slug="networking", is_active=True, account_id=_PLATFORM_ACCOUNT_ID)
|
||||||
test_db.add(cat)
|
test_db.add(cat)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
await test_db.refresh(cat)
|
await test_db.refresh(cat)
|
||||||
@@ -321,6 +325,7 @@ class TestCategoriesEndpoint:
|
|||||||
name="Router Diagnostics",
|
name="Router Diagnostics",
|
||||||
tree_type="troubleshooting",
|
tree_type="troubleshooting",
|
||||||
tree_structure=_make_tree_structure(2),
|
tree_structure=_make_tree_structure(2),
|
||||||
|
account_id=_PLATFORM_ACCOUNT_ID,
|
||||||
is_gallery_featured=True,
|
is_gallery_featured=True,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
usage_count=5,
|
usage_count=5,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db
|
|||||||
|
|
||||||
output = SessionResolutionOutput(
|
output = SessionResolutionOutput(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
output_type="psa_ticket_notes",
|
output_type="psa_ticket_notes",
|
||||||
generated_content="Original notes",
|
generated_content="Original notes",
|
||||||
status="draft",
|
status="draft",
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ Run with:
|
|||||||
The test DB is patherly_test (matches conftest.py default).
|
The test DB is patherly_test (matches conftest.py default).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# All tests in this module use module-scoped async fixtures (admin_conn,
|
||||||
|
# seed_rls_test_data) which run on the module event loop. Without this marker,
|
||||||
|
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
|
||||||
|
# "Future attached to a different loop" errors on the asyncpg connections.
|
||||||
|
pytestmark = pytest.mark.asyncio(loop_scope="module")
|
||||||
|
|
||||||
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
|
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
|
||||||
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
|
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
|
||||||
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
|
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
|
||||||
@@ -37,7 +46,25 @@ ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def admin_conn():
|
def _ensure_rls_schema():
|
||||||
|
"""Re-apply Alembic migrations before the module runs.
|
||||||
|
|
||||||
|
Function-scoped test_db fixtures in other modules drop and recreate the
|
||||||
|
public schema using Base.metadata.create_all, which does not enable RLS
|
||||||
|
or create DB roles. This fixture re-runs 'alembic upgrade head' so that
|
||||||
|
the full migration-managed schema (including RLS policies) is in place.
|
||||||
|
"""
|
||||||
|
backend_dir = Path(__file__).parent.parent
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=backend_dir,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
async def admin_conn(_ensure_rls_schema):
|
||||||
"""Superuser asyncpg connection for fixture setup and teardown."""
|
"""Superuser asyncpg connection for fixture setup and teardown."""
|
||||||
conn = await asyncpg.connect(_ADMIN_DSN)
|
conn = await asyncpg.connect(_ADMIN_DSN)
|
||||||
yield conn
|
yield conn
|
||||||
@@ -170,7 +197,6 @@ async def conn_no_context():
|
|||||||
# trees
|
# trees
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
|
async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
@@ -178,7 +204,6 @@ async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
|
|||||||
assert len(rows) == 0, "Account A should not see Account B trees"
|
assert len(rows) == 0, "Account A should not see Account B trees"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trees_account_a_can_see_own_rows(conn_a):
|
async def test_trees_account_a_can_see_own_rows(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
|
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
|
||||||
@@ -186,7 +211,6 @@ async def test_trees_account_a_can_see_own_rows(conn_a):
|
|||||||
assert len(rows) >= 1, "Account A should see its own trees"
|
assert len(rows) >= 1, "Account A should see its own trees"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trees_no_context_sees_no_private_trees(conn_no_context):
|
async def test_trees_no_context_sees_no_private_trees(conn_no_context):
|
||||||
rows = await conn_no_context.fetch(
|
rows = await conn_no_context.fetch(
|
||||||
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
|
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
|
||||||
@@ -198,7 +222,6 @@ async def test_trees_no_context_sees_no_private_trees(conn_no_context):
|
|||||||
# tree_tags — platform visibility
|
# tree_tags — platform visibility
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
|
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
@@ -206,7 +229,6 @@ async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
|
|||||||
assert len(rows) == 0
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
|
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
|
||||||
rows_a = await conn_a.fetch(
|
rows_a = await conn_a.fetch(
|
||||||
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
|
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
|
||||||
@@ -222,7 +244,6 @@ async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
|
|||||||
# tree_categories — platform visibility
|
# tree_categories — platform visibility
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
|
async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
@@ -234,7 +255,6 @@ async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
|
|||||||
# step_categories — platform visibility
|
# step_categories — platform visibility
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_step_categories_account_a_cannot_see_account_b(conn_a):
|
async def test_step_categories_account_a_cannot_see_account_b(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
@@ -246,7 +266,6 @@ async def test_step_categories_account_a_cannot_see_account_b(conn_a):
|
|||||||
# psa_connections — tenant-only
|
# psa_connections — tenant-only
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
|
async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
@@ -258,9 +277,782 @@ async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
|
|||||||
# flow_proposals — tenant-only
|
# flow_proposals — tenant-only
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
|
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
|
||||||
rows = await conn_a.fetch(
|
rows = await conn_a.fetch(
|
||||||
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
|
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
)
|
)
|
||||||
assert len(rows) == 0
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
async def session_row_ids(admin_conn):
|
||||||
|
"""
|
||||||
|
Insert one `sessions` row and one `ai_sessions` row for each of
|
||||||
|
ACCOUNT_A and ACCOUNT_B using the superuser connection (BYPASSRLS).
|
||||||
|
Returns a dict with the inserted IDs for use in tests.
|
||||||
|
Cleans up on exit.
|
||||||
|
"""
|
||||||
|
# Resolve a valid tree_id and user_id for each account
|
||||||
|
tree_a = await admin_conn.fetchrow(
|
||||||
|
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
|
||||||
|
)
|
||||||
|
tree_b = await admin_conn.fetchrow(
|
||||||
|
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
|
||||||
|
)
|
||||||
|
user_a = await admin_conn.fetchrow(
|
||||||
|
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
|
||||||
|
)
|
||||||
|
user_b = await admin_conn.fetchrow(
|
||||||
|
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tree_a is not None, f"No tree found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
|
||||||
|
assert tree_b is not None, f"No tree found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
|
||||||
|
assert user_a is not None, f"No user found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
|
||||||
|
assert user_b is not None, f"No user found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
|
||||||
|
|
||||||
|
tree_a_id = str(tree_a["id"])
|
||||||
|
tree_b_id = str(tree_b["id"])
|
||||||
|
user_a_id = str(user_a["id"])
|
||||||
|
user_b_id = str(user_b["id"])
|
||||||
|
|
||||||
|
session_a_id = str(uuid.uuid4())
|
||||||
|
session_b_id = str(uuid.uuid4())
|
||||||
|
ai_session_a_id = str(uuid.uuid4())
|
||||||
|
ai_session_b_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Insert sessions rows (sessions uses started_at not created_at)
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, tree_id, user_id, account_id, tree_snapshot,
|
||||||
|
path_taken, decisions, custom_steps, started_at
|
||||||
|
) VALUES
|
||||||
|
('{session_a_id}', '{tree_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
|
||||||
|
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()),
|
||||||
|
('{session_b_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Insert ai_sessions rows
|
||||||
|
# confidence_tier valid values: 'guided' | 'exploring' | 'discovery'
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO ai_sessions (
|
||||||
|
id, user_id, account_id, session_type, intake_type,
|
||||||
|
intake_content, status, confidence_tier, confidence_score,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('{ai_session_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
|
||||||
|
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
|
||||||
|
NOW(), NOW()),
|
||||||
|
('{ai_session_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
|
||||||
|
NOW(), NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Seed Account B rows for every "cannot-see" table that would otherwise be
|
||||||
|
# empty. Without these, isolation tests pass vacuously even when RLS is off.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# session_branches (FK: ai_sessions.id)
|
||||||
|
branch_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO session_branches (
|
||||||
|
id, session_id, account_id, branch_order, label, status,
|
||||||
|
conversation_messages, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, 1, 'test-branch', 'active',
|
||||||
|
'[]'::jsonb, NOW(), NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", ai_session_b_id, ACCOUNT_B_ID)
|
||||||
|
branch_b_id = str(branch_b_row["id"])
|
||||||
|
|
||||||
|
# session_supporting_data (FK: sessions.id)
|
||||||
|
supporting_data_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO session_supporting_data (
|
||||||
|
id, session_id, account_id, label, data_type, content,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, 'test-data', 'text_snippet',
|
||||||
|
'test content', 0, NOW(), NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", session_b_id, ACCOUNT_B_ID)
|
||||||
|
supporting_data_b_id = str(supporting_data_b_row["id"])
|
||||||
|
|
||||||
|
# session_resolution_outputs (FK: ai_sessions.id)
|
||||||
|
resolution_output_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO session_resolution_outputs (
|
||||||
|
id, session_id, account_id, output_type, generated_content,
|
||||||
|
status, generated_by_model, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, 'psa_ticket_notes',
|
||||||
|
'test content', 'draft', 'test-model', NOW(), NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", ai_session_b_id, ACCOUNT_B_ID)
|
||||||
|
resolution_output_b_id = str(resolution_output_b_row["id"])
|
||||||
|
|
||||||
|
# session_handoffs (FK: ai_sessions.id, users.id)
|
||||||
|
handoff_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO session_handoffs (
|
||||||
|
id, session_id, account_id, handed_off_by, intent, snapshot,
|
||||||
|
priority, psa_note_pushed, notification_sent, created_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, 'park',
|
||||||
|
'{}'::jsonb, 'normal', false, false, NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
|
||||||
|
handoff_b_id = str(handoff_b_row["id"])
|
||||||
|
|
||||||
|
# maintenance_schedules (FK: trees.id)
|
||||||
|
maintenance_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO maintenance_schedules (
|
||||||
|
id, tree_id, account_id, cron_expression, timezone,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, '0 9 * * 1', 'UTC',
|
||||||
|
NOW(), NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", tree_b_id, ACCOUNT_B_ID)
|
||||||
|
maintenance_b_id = str(maintenance_b_row["id"])
|
||||||
|
|
||||||
|
# psa_post_log (FK: ai_sessions.id, users.id)
|
||||||
|
psa_log_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO psa_post_log (
|
||||||
|
id, ai_session_id, account_id, ticket_id, note_type,
|
||||||
|
content_posted, status, posted_by, posted_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, 'TEST-0001', 'internal',
|
||||||
|
'test note', 'success', $3::uuid, NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
|
||||||
|
psa_log_b_id = str(psa_log_b_row["id"])
|
||||||
|
|
||||||
|
# script_templates requires a script_categories row — insert a temporary one
|
||||||
|
script_category_b_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
|
||||||
|
VALUES ('{script_category_b_id}', 'RLS Test Category', 'rls-test-category-{script_category_b_id[:8]}',
|
||||||
|
0, true, NOW(), NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
script_template_b_row = await admin_conn.fetchrow(f"""
|
||||||
|
INSERT INTO script_templates (
|
||||||
|
id, category_id, account_id, name, slug, script_body,
|
||||||
|
complexity, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), '{script_category_b_id}'::uuid, $1::uuid,
|
||||||
|
'RLS Test Template', 'rls-test-template-b-' || gen_random_uuid()::text,
|
||||||
|
'Write-Host "test"', 'beginner', true, NOW(), NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", ACCOUNT_B_ID)
|
||||||
|
script_template_b_id = str(script_template_b_row["id"])
|
||||||
|
|
||||||
|
# script_generations (FK: script_templates.id, users.id)
|
||||||
|
script_gen_b_row = await admin_conn.fetchrow("""
|
||||||
|
INSERT INTO script_generations (
|
||||||
|
id, template_id, user_id, account_id, parameters_used,
|
||||||
|
generated_script, created_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, '{}'::jsonb,
|
||||||
|
'test script', NOW()
|
||||||
|
) RETURNING id
|
||||||
|
""", script_template_b_id, user_b_id, ACCOUNT_B_ID)
|
||||||
|
script_gen_b_id = str(script_gen_b_row["id"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield {
|
||||||
|
"session_a": session_a_id,
|
||||||
|
"session_b": session_b_id,
|
||||||
|
"ai_session_a": ai_session_a_id,
|
||||||
|
"ai_session_b": ai_session_b_id,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
# Cleanup in reverse FK order (children before parents)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM script_generations WHERE id = '{script_gen_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM session_branches WHERE id = '{branch_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM session_supporting_data WHERE id = '{supporting_data_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM session_resolution_outputs WHERE id = '{resolution_output_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM session_handoffs WHERE id = '{handoff_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM maintenance_schedules WHERE id = '{maintenance_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM psa_post_log WHERE id = '{psa_log_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM script_templates WHERE id = '{script_template_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM script_categories WHERE id = '{script_category_b_id}'"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM sessions WHERE id IN ('{session_a_id}', '{session_b_id}')"
|
||||||
|
)
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM ai_sessions WHERE id IN ('{ai_session_a_id}', '{ai_session_b_id}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_sessions_account_a_cannot_see_account_b_sessions(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_b']}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B sessions"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_account_a_can_see_own_sessions(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_a']}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 1, "Account A should see its own sessions"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_no_context_sees_nothing(conn_no_context, session_row_ids):
|
||||||
|
rows = await conn_no_context.fetch(
|
||||||
|
f"SELECT id FROM sessions WHERE id IN "
|
||||||
|
f"('{session_row_ids['session_a']}', '{session_row_ids['session_b']}')"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "No-context connection should see no sessions"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ai_sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_ai_sessions_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_b']}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B ai_sessions"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ai_sessions_account_a_can_see_own(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_a']}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 1, "Account A should see its own ai_sessions"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session_branches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_session_branches_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM session_branches WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B session_branches"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session_supporting_data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_session_supporting_data_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM session_supporting_data WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B session_supporting_data"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session_resolution_outputs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_session_resolution_outputs_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM session_resolution_outputs WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B session_resolution_outputs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session_handoffs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_session_handoffs_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM session_handoffs WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B session_handoffs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# script_templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_script_templates_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM script_templates WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B script_templates"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# script_generations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_script_generations_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM script_generations WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B script_generations"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# maintenance_schedules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_maintenance_schedules_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM maintenance_schedules WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B maintenance_schedules"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# psa_post_log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_ids):
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM psa_post_log WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B psa_post_log"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step_library — visibility-aware policy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
|
||||||
|
"""Private/non-public steps owned by Account B must not be visible to Account A."""
|
||||||
|
private_step_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_library (
|
||||||
|
id, account_id, title, step_type, content,
|
||||||
|
visibility, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{private_step_id}', '{ACCOUNT_B_ID}', 'RLS Private Step', 'action',
|
||||||
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM step_library "
|
||||||
|
f"WHERE id = '{private_step_id}' AND visibility != 'public'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B's private step_library rows"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM step_library WHERE id = '{private_step_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
|
||||||
|
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
|
||||||
|
public_step_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_library (
|
||||||
|
id, account_id, title, step_type, content,
|
||||||
|
visibility, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{public_step_id}', '{ACCOUNT_B_ID}', 'RLS Public Step', 'action',
|
||||||
|
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM step_library WHERE id = '{public_step_id}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 1, (
|
||||||
|
"Account A should see public steps owned by Account B "
|
||||||
|
"(cross-tenant public visibility policy)"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM step_library WHERE id = '{public_step_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Phase 3 RLS isolation tests
|
||||||
|
# Tables: step_ratings, step_usage_log, target_lists,
|
||||||
|
# session_shares, audit_logs, tree_shares
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers shared by Phase 3 fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _get_user_b_id(admin_conn) -> str:
|
||||||
|
row = await admin_conn.fetchrow(
|
||||||
|
"SELECT id FROM users WHERE email = 'rls-user-b@example.com'"
|
||||||
|
)
|
||||||
|
return str(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_tree_b_id(admin_conn) -> str:
|
||||||
|
row = await admin_conn.fetchrow(
|
||||||
|
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
|
||||||
|
)
|
||||||
|
return str(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step_ratings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see step ratings belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
|
||||||
|
# Need a step_library row as FK target
|
||||||
|
step_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_library (
|
||||||
|
id, account_id, title, step_type, content,
|
||||||
|
visibility, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 RLS Step', 'action',
|
||||||
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
rating_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_ratings (
|
||||||
|
id, step_id, user_id, account_id, is_verified_use, is_visible,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{rating_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
FALSE, TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM step_ratings WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B step_ratings"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM step_ratings WHERE id = '{rating_id}'")
|
||||||
|
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step_usage_log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see step usage logs belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
tree_b_id = await _get_tree_b_id(admin_conn)
|
||||||
|
|
||||||
|
step_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_library (
|
||||||
|
id, account_id, title, step_type, content,
|
||||||
|
visibility, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 Usage Step', 'action',
|
||||||
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Need a sessions row as FK for usage log
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, tree_id, user_id, account_id, tree_snapshot,
|
||||||
|
path_taken, decisions, custom_steps, started_at
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
log_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO step_usage_log (
|
||||||
|
id, step_id, user_id, account_id, session_id, used_at
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'{session_id}', NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM step_usage_log WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B step_usage_log"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM step_usage_log WHERE id = '{log_id}'")
|
||||||
|
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
|
||||||
|
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# target_lists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_target_lists_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see target lists belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
|
||||||
|
tl_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO target_lists (
|
||||||
|
id, account_id, created_by, name, targets, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{tl_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
|
||||||
|
'Phase3 RLS Target List', '[]'::jsonb, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM target_lists WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B target_lists"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM target_lists WHERE id = '{tl_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session_shares
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_session_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see session shares belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
tree_b_id = await _get_tree_b_id(admin_conn)
|
||||||
|
|
||||||
|
# Need a sessions row as FK
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, tree_id, user_id, account_id, tree_snapshot,
|
||||||
|
path_taken, decisions, custom_steps, started_at
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
share_id = str(uuid.uuid4())
|
||||||
|
share_token = f"phase3-rls-test-{share_id[:8]}"
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO session_shares (
|
||||||
|
id, session_id, account_id, share_token, visibility,
|
||||||
|
created_by, view_count, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{share_id}', '{session_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'{share_token}', 'account', '{user_b_id}',
|
||||||
|
0, TRUE, NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM session_shares WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B session_shares"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM session_shares WHERE id = '{share_id}'")
|
||||||
|
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# audit_logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_audit_logs_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see audit logs belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
|
||||||
|
log_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
id, user_id, account_id, action, resource_type, created_at
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'test.action', 'test_resource', NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM audit_logs WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B audit_logs"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM audit_logs WHERE id = '{log_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tree_shares
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_tree_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see tree shares belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
tree_b_id = await _get_tree_b_id(admin_conn)
|
||||||
|
|
||||||
|
share_id = str(uuid.uuid4())
|
||||||
|
share_token = f"phase3-tree-rls-{share_id[:8]}"
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO tree_shares (
|
||||||
|
id, tree_id, account_id, share_token, created_by,
|
||||||
|
allow_forking, created_at
|
||||||
|
) VALUES (
|
||||||
|
'{share_id}', '{tree_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'{share_token}', '{user_b_id}', TRUE, NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM tree_shares WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B tree_shares"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM tree_shares WHERE id = '{share_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Phase 4 RLS isolation tests
|
||||||
|
# Tables: users, script_builder_sessions, ai_session_steps, notifications
|
||||||
|
#
|
||||||
|
# Note: platform_steps and template_trees have no account_id column and no RLS —
|
||||||
|
# they are globally readable by all authenticated users.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# users
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_users_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see users belonging to Account B."""
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B users"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_users_account_a_can_see_own(admin_conn, conn_a):
|
||||||
|
"""Account A must be able to see its own users."""
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) > 0, "Account A should see its own users"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# script_builder_sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see script builder sessions belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO script_builder_sessions (
|
||||||
|
id, user_id, account_id, language, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'powershell', NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM script_builder_sessions WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B script_builder_sessions"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(
|
||||||
|
f"DELETE FROM script_builder_sessions WHERE id = '{session_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ai_session_steps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see ai_session_steps belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
tree_b_id = await _get_tree_b_id(admin_conn)
|
||||||
|
|
||||||
|
# Need an ai_sessions row as FK
|
||||||
|
ai_session_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO ai_sessions (
|
||||||
|
id, user_id, account_id, flow_type, status, confidence_tier,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'troubleshooting', 'active', 'guided', NOW(), NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
step_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO ai_session_steps (
|
||||||
|
id, session_id, account_id, step_type, content,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'question', 'Phase4 RLS test step', NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM ai_session_steps WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B ai_session_steps"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM ai_session_steps WHERE id = '{step_id}'")
|
||||||
|
await admin_conn.execute(f"DELETE FROM ai_sessions WHERE id = '{ai_session_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# notifications
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
|
||||||
|
"""Account A must not see notifications belonging to Account B."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
|
|
||||||
|
notif_id = str(uuid.uuid4())
|
||||||
|
await admin_conn.execute(f"""
|
||||||
|
INSERT INTO notifications (
|
||||||
|
id, user_id, account_id, type, title, message,
|
||||||
|
is_read, created_at
|
||||||
|
) VALUES (
|
||||||
|
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
|
'info', 'Phase4 RLS Test', 'RLS isolation test notification',
|
||||||
|
FALSE, NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
rows = await conn_a.fetch(
|
||||||
|
f"SELECT id FROM notifications WHERE account_id = '{ACCOUNT_B_ID}'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 0, "Account A should not see Account B notifications"
|
||||||
|
finally:
|
||||||
|
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class TestSaveSessionAsTreeAPI:
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=UUID(test_user["user_data"]["id"]),
|
user_id=UUID(test_user["user_data"]["id"]),
|
||||||
|
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||||
tree_snapshot=tree.tree_structure,
|
tree_snapshot=tree.tree_structure,
|
||||||
path_taken=["root"],
|
path_taken=["root"],
|
||||||
decisions=[{"node_id": "root", "timestamp": datetime.now(timezone.utc).isoformat()}],
|
decisions=[{"node_id": "root", "timestamp": datetime.now(timezone.utc).isoformat()}],
|
||||||
@@ -199,6 +200,7 @@ class TestSaveSessionAsTreeAPI:
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=UUID(test_user["user_data"]["id"]),
|
user_id=UUID(test_user["user_data"]["id"]),
|
||||||
|
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||||
tree_snapshot=tree.tree_structure,
|
tree_snapshot=tree.tree_structure,
|
||||||
path_taken=["root"],
|
path_taken=["root"],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -239,6 +241,7 @@ class TestSaveSessionAsTreeAPI:
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=UUID(test_user["user_data"]["id"]),
|
user_id=UUID(test_user["user_data"]["id"]),
|
||||||
|
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||||
tree_snapshot=tree.tree_structure,
|
tree_snapshot=tree.tree_structure,
|
||||||
path_taken=["root"],
|
path_taken=["root"],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -279,6 +282,7 @@ class TestSaveSessionAsTreeAPI:
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=UUID(test_user["user_data"]["id"]),
|
user_id=UUID(test_user["user_data"]["id"]),
|
||||||
|
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||||
tree_snapshot=tree.tree_structure,
|
tree_snapshot=tree.tree_structure,
|
||||||
path_taken=["root"],
|
path_taken=["root"],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -352,6 +356,7 @@ class TestSaveSessionAsTreeAPI:
|
|||||||
session = Session(
|
session = Session(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
user_id=other_user.id,
|
user_id=other_user.id,
|
||||||
|
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||||
tree_snapshot=tree.tree_structure,
|
tree_snapshot=tree.tree_structure,
|
||||||
path_taken=["root"],
|
path_taken=["root"],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
|
|||||||
89
backend/tests/test_service_account.py
Normal file
89
backend/tests/test_service_account.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core import service_account as service_account_module
|
||||||
|
from app.core.service_account import (
|
||||||
|
SERVICE_ACCOUNT_EMAIL,
|
||||||
|
SYSTEM_ACCOUNT_DISPLAY_CODE,
|
||||||
|
ensure_service_account,
|
||||||
|
)
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionFactoryOverride:
|
||||||
|
def __init__(self, session):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_service_account_creates_and_reuses_seeded_user(test_db, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service_account_module,
|
||||||
|
"_admin_session_factory",
|
||||||
|
_SessionFactoryOverride(test_db),
|
||||||
|
)
|
||||||
|
|
||||||
|
service_account_id = await ensure_service_account(test_db)
|
||||||
|
|
||||||
|
created_user = (
|
||||||
|
await test_db.execute(select(User).where(User.id == service_account_id))
|
||||||
|
).scalar_one()
|
||||||
|
assert created_user.email == SERVICE_ACCOUNT_EMAIL
|
||||||
|
assert created_user.is_service_account is True
|
||||||
|
|
||||||
|
system_account = (
|
||||||
|
await test_db.execute(
|
||||||
|
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert created_user.account_id == system_account.id
|
||||||
|
|
||||||
|
second_id = await ensure_service_account(test_db)
|
||||||
|
assert second_id == service_account_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_service_account_marks_existing_user_as_service_account(test_db, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service_account_module,
|
||||||
|
"_admin_session_factory",
|
||||||
|
_SessionFactoryOverride(test_db),
|
||||||
|
)
|
||||||
|
|
||||||
|
system_account = (
|
||||||
|
await test_db.execute(
|
||||||
|
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
existing_user = User(
|
||||||
|
email=SERVICE_ACCOUNT_EMAIL,
|
||||||
|
name="ResolutionFlow",
|
||||||
|
password_hash="!service-account-no-login",
|
||||||
|
role="engineer",
|
||||||
|
is_super_admin=False,
|
||||||
|
is_team_admin=False,
|
||||||
|
is_active=True,
|
||||||
|
is_service_account=False,
|
||||||
|
must_change_password=False,
|
||||||
|
account_id=system_account.id,
|
||||||
|
account_role="engineer",
|
||||||
|
)
|
||||||
|
test_db.add(existing_user)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
resolved_id = await ensure_service_account(test_db)
|
||||||
|
await test_db.refresh(existing_user)
|
||||||
|
|
||||||
|
assert resolved_id == existing_user.id
|
||||||
|
assert existing_user.is_service_account is True
|
||||||
@@ -3,37 +3,10 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.team import Team
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def auth_headers(client: AsyncClient, test_db: AsyncSession, test_user: dict):
|
|
||||||
"""Override auth_headers to ensure the test user has a team_id assigned."""
|
|
||||||
# Fetch the user from DB and assign a team
|
|
||||||
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
|
|
||||||
user = result.scalar_one()
|
|
||||||
|
|
||||||
# Create a team and assign the user to it
|
|
||||||
team = Team(name="Test Team")
|
|
||||||
test_db.add(team)
|
|
||||||
await test_db.flush()
|
|
||||||
|
|
||||||
user.team_id = team.id
|
|
||||||
await test_db.commit()
|
|
||||||
|
|
||||||
# Re-login to get a fresh token
|
|
||||||
login_data = {
|
|
||||||
"email": test_user["email"],
|
|
||||||
"password": test_user["password"],
|
|
||||||
}
|
|
||||||
resp = await client.post("/api/v1/auth/login/json", json=login_data)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
token_data = resp.json()
|
|
||||||
return {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_target_list(client: AsyncClient, auth_headers: dict):
|
async def test_create_target_list(client: AsyncClient, auth_headers: dict):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -107,25 +80,28 @@ async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
|
|||||||
assert get.status_code == 404
|
assert get.status_code == 404
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db):
|
async def test_cannot_access_other_accounts_list(client: AsyncClient, auth_headers: dict, test_db):
|
||||||
"""User from team B cannot access team A's list."""
|
"""User from account B cannot access account A's target list."""
|
||||||
import uuid
|
import uuid
|
||||||
from app.models.team import Team
|
from app.models.account import Account
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
# Create team A list using existing auth_headers
|
# Create account A list using existing auth_headers
|
||||||
create = await client.post(
|
create = await client.post(
|
||||||
"/api/v1/target-lists/",
|
"/api/v1/target-lists/",
|
||||||
json={"name": "Team A List", "targets": [{"label": "SRV-A"}]},
|
json={"name": "Account A List", "targets": [{"label": "SRV-A"}]},
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert create.status_code == 201
|
assert create.status_code == 201
|
||||||
list_id = create.json()["id"]
|
list_id = create.json()["id"]
|
||||||
|
|
||||||
# Create a separate team B with its own user
|
# Create a separate account B with its own user
|
||||||
team_b = Team(name=f"Team B {uuid.uuid4()}")
|
account_b = Account(
|
||||||
test_db.add(team_b)
|
name=f"Account B {uuid.uuid4()}",
|
||||||
|
display_code=f"AB{str(uuid.uuid4())[:6].upper()}",
|
||||||
|
)
|
||||||
|
test_db.add(account_b)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
user_b = User(
|
user_b = User(
|
||||||
@@ -133,11 +109,13 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
|
|||||||
password_hash=get_password_hash("password123"),
|
password_hash=get_password_hash("password123"),
|
||||||
name="User B",
|
name="User B",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
team_id=team_b.id,
|
account_id=account_b.id,
|
||||||
|
account_role="engineer",
|
||||||
role="engineer",
|
role="engineer",
|
||||||
)
|
)
|
||||||
test_db.add(user_b)
|
test_db.add(user_b)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
# Get auth token for user B
|
# Get auth token for user B
|
||||||
login = await client.post(
|
login = await client.post(
|
||||||
@@ -148,6 +126,6 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
|
|||||||
token_b = login.json()["access_token"]
|
token_b = login.json()["access_token"]
|
||||||
headers_b = {"Authorization": f"Bearer {token_b}"}
|
headers_b = {"Authorization": f"Bearer {token_b}"}
|
||||||
|
|
||||||
# Team B cannot access Team A's list
|
# Account B cannot access Account A's list
|
||||||
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
|
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class TestTreeSharing:
|
|||||||
for i in range(3):
|
for i in range(3):
|
||||||
share = TreeShare(
|
share = TreeShare(
|
||||||
tree_id=sample_tree.id,
|
tree_id=sample_tree.id,
|
||||||
|
account_id=sample_tree.account_id,
|
||||||
share_token=f"token_{i}_" + "x" * 56,
|
share_token=f"token_{i}_" + "x" * 56,
|
||||||
created_by=sample_tree.author_id,
|
created_by=sample_tree.author_id,
|
||||||
allow_forking=i % 2 == 0
|
allow_forking=i % 2 == 0
|
||||||
@@ -162,6 +163,7 @@ class TestTreeSharing:
|
|||||||
# Create a share
|
# Create a share
|
||||||
share = TreeShare(
|
share = TreeShare(
|
||||||
tree_id=sample_tree.id,
|
tree_id=sample_tree.id,
|
||||||
|
account_id=sample_tree.account_id,
|
||||||
share_token="public_test_token" + "x" * 47,
|
share_token="public_test_token" + "x" * 47,
|
||||||
created_by=UUID(test_user["user_data"]["id"]),
|
created_by=UUID(test_user["user_data"]["id"]),
|
||||||
allow_forking=True
|
allow_forking=True
|
||||||
@@ -192,6 +194,7 @@ class TestTreeSharing:
|
|||||||
# Create expired share
|
# Create expired share
|
||||||
share = TreeShare(
|
share = TreeShare(
|
||||||
tree_id=sample_tree.id,
|
tree_id=sample_tree.id,
|
||||||
|
account_id=sample_tree.account_id,
|
||||||
share_token="expired_token" + "x" * 50,
|
share_token="expired_token" + "x" * 50,
|
||||||
created_by=UUID(test_user["user_data"]["id"]),
|
created_by=UUID(test_user["user_data"]["id"]),
|
||||||
allow_forking=True,
|
allow_forking=True,
|
||||||
@@ -209,6 +212,7 @@ class TestTreeSharing:
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
share = TreeShare(
|
share = TreeShare(
|
||||||
tree_id=sample_tree.id,
|
tree_id=sample_tree.id,
|
||||||
|
account_id=sample_tree.account_id,
|
||||||
share_token="inactive_tree_token" + "x" * 44,
|
share_token="inactive_tree_token" + "x" * 44,
|
||||||
created_by=UUID(test_user["user_data"]["id"]),
|
created_by=UUID(test_user["user_data"]["id"]),
|
||||||
allow_forking=True
|
allow_forking=True
|
||||||
@@ -248,6 +252,37 @@ class TestTreeSharing:
|
|||||||
tokens.add(token)
|
tokens.add(token)
|
||||||
assert len(tokens) == 5
|
assert len(tokens) == 5
|
||||||
|
|
||||||
|
async def test_share_account_id_matches_tree_not_actor(
|
||||||
|
self, client: AsyncClient, sample_tree, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Share account_id must equal tree.account_id, not the actor's account_id.
|
||||||
|
|
||||||
|
A super admin in a different account can share any tree. The resulting
|
||||||
|
TreeShare row must live in the tree-owner's account so that the tree
|
||||||
|
owner's RLS context covers it. If account_id were derived from the
|
||||||
|
actor instead, the share would vanish from the tree owner's view once
|
||||||
|
RLS is enabled.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/trees/{sample_tree.id}/share",
|
||||||
|
json={"allow_forking": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
share_token = response.json()["share_token"]
|
||||||
|
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(TreeShare).where(TreeShare.share_token == share_token)
|
||||||
|
)
|
||||||
|
share = result.scalar_one()
|
||||||
|
assert share.account_id == sample_tree.account_id, (
|
||||||
|
"TreeShare.account_id must equal tree.account_id, not the actor's account. "
|
||||||
|
"Shares must live in the tree owner's tenant for RLS to cover them."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_migration_defaults_visibility_to_team(test_db):
|
async def test_migration_defaults_visibility_to_team(test_db):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Lessons Archive (1-40)
|
# Lessons Archive (1-70)
|
||||||
|
|
||||||
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
||||||
|
|
||||||
@@ -81,3 +81,67 @@
|
|||||||
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
||||||
|
|
||||||
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archived Lessons (41-70)
|
||||||
|
|
||||||
|
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
||||||
|
|
||||||
|
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
||||||
|
|
||||||
|
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
||||||
|
|
||||||
|
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
||||||
|
|
||||||
|
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
||||||
|
|
||||||
|
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
||||||
|
|
||||||
|
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
|
||||||
|
|
||||||
|
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
||||||
|
|
||||||
|
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
|
||||||
|
|
||||||
|
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
||||||
|
|
||||||
|
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
|
||||||
|
|
||||||
|
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
||||||
|
|
||||||
|
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
||||||
|
|
||||||
|
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
||||||
|
|
||||||
|
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
||||||
|
|
||||||
|
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||||
|
|
||||||
|
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
||||||
|
|
||||||
|
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
||||||
|
|
||||||
|
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||||
|
|
||||||
|
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
||||||
|
|
||||||
|
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
|
||||||
|
|
||||||
|
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
|
||||||
|
|
||||||
|
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||||
|
|
||||||
|
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
|
||||||
|
|
||||||
|
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
|
||||||
|
|
||||||
|
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
|
||||||
|
|
||||||
|
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
|
||||||
|
|
||||||
|
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
|
||||||
|
|
||||||
|
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
|
||||||
|
|
||||||
|
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.
|
||||||
|
|||||||
158
docs/connectwise-psa-testing-checklist.md
Normal file
158
docs/connectwise-psa-testing-checklist.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# ConnectWise PSA Integration — Testing Checklist
|
||||||
|
|
||||||
|
> **Purpose:** Step-by-step guide to connect ResolutionFlow to a ConnectWise developer sandbox and validate each integration feature end-to-end.
|
||||||
|
>
|
||||||
|
> **Date created:** 2026-04-14
|
||||||
|
> **Branch:** main
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before starting, make sure you have:
|
||||||
|
|
||||||
|
- [ ] ResolutionFlow backend running (`uvicorn app.main:app --reload` from `backend/`)
|
||||||
|
- [ ] ResolutionFlow frontend running (`npm run dev` from `frontend/`)
|
||||||
|
- [ ] A ConnectWise developer sandbox account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Get Your ConnectWise Developer Credentials
|
||||||
|
|
||||||
|
You need four pieces of information from your ConnectWise sandbox.
|
||||||
|
|
||||||
|
**Company ID**
|
||||||
|
- This is the company name you log in with on the CW login screen (e.g. if your URL is `na.myconnectwise.net` and your login company is `resolutionflow`, the Company ID is `resolutionflow`)
|
||||||
|
|
||||||
|
**Site URL**
|
||||||
|
- Developer sandboxes are typically `na.myconnectwise.net` or `aus.connectwisedev.com`
|
||||||
|
- Do **not** include `https://` — enter just the hostname (e.g. `na.myconnectwise.net`)
|
||||||
|
|
||||||
|
**API Public Key + Private Key**
|
||||||
|
1. Log into your CW sandbox
|
||||||
|
2. Go to **System → Members** → open your own member record
|
||||||
|
3. Click the **API Keys** tab
|
||||||
|
4. Click **New** → give it a name (e.g. "ResolutionFlow Dev")
|
||||||
|
5. Save — the **Private Key** is shown only once, copy it now
|
||||||
|
6. Note both the **Public Key** (shown on the list) and **Private Key**
|
||||||
|
|
||||||
|
**Client ID** (already configured server-side)
|
||||||
|
- The `CW_CLIENT_ID` is set in `backend/app/core/config.py` — this identifies the ResolutionFlow app to ConnectWise and is shared across all tenants. You do not need to enter this in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Connect ResolutionFlow to ConnectWise
|
||||||
|
|
||||||
|
- [ ] Log into ResolutionFlow as a **Team Admin or Super Admin** user
|
||||||
|
- [ ] Navigate to **Account → Integrations**
|
||||||
|
- [ ] On the **Connection** tab, fill in the form:
|
||||||
|
- Display Name: anything (e.g. `CW Dev Sandbox`)
|
||||||
|
- Site URL: your sandbox hostname (e.g. `na.myconnectwise.net`)
|
||||||
|
- Company ID: your CW company ID
|
||||||
|
- Public Key: from Step 1
|
||||||
|
- Private Key: from Step 1
|
||||||
|
- [ ] Click **Connect** — the backend tests the credentials before saving
|
||||||
|
- [ ] Verify: "Connected" status appears with a green dot
|
||||||
|
- [ ] Click **Test Connection** button and confirm it returns a success message + server version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Member Mapping
|
||||||
|
|
||||||
|
Maps ResolutionFlow users to ConnectWise members so that PSA posts are attributed to the right technician.
|
||||||
|
|
||||||
|
- [ ] Click the **Member Mapping** tab
|
||||||
|
- [ ] Click **Auto-Match by Email** — ResolutionFlow matches users to CW members with the same email address
|
||||||
|
- [ ] Verify the matched count in the toast notification
|
||||||
|
- [ ] If any users are unmatched, manually assign them via the dropdown
|
||||||
|
- [ ] Click **Save Mappings** if you made manual changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Ticket Search (via FlowPilot session)
|
||||||
|
|
||||||
|
- [ ] Start a new FlowPilot session (from the Dashboard)
|
||||||
|
- [ ] Look for the **Link Ticket** button in the session header
|
||||||
|
- [ ] Search for a ticket by keyword or ticket number
|
||||||
|
- [ ] Verify: ticket results appear showing summary, board, status, priority
|
||||||
|
- [ ] Select a ticket and confirm it links to the session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Ticket Context Injection
|
||||||
|
|
||||||
|
Once a ticket is linked, FlowPilot should enrich its context with CW data.
|
||||||
|
|
||||||
|
- [ ] With a ticket linked, send a message to FlowPilot
|
||||||
|
- [ ] Verify: FlowPilot's response references ticket details (company name, status, configurations, etc.)
|
||||||
|
- [ ] Check backend logs to confirm `GET /integrations/psa/tickets/{id}/context` is being called
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — PSA Post (push session notes to ticket)
|
||||||
|
|
||||||
|
This is the core feature — pushing session documentation back to the ConnectWise ticket.
|
||||||
|
|
||||||
|
- [ ] In the linked session, click **Update** (or the PSA post button in the session header)
|
||||||
|
- [ ] Review the **Preview** — confirm the generated content looks correct
|
||||||
|
- [ ] Select a **Note Type**:
|
||||||
|
- `Internal Analysis` — internal-only note (visible to techs, not clients)
|
||||||
|
- `Resolution` — marks as resolved, notifies client
|
||||||
|
- `Description` — main ticket description note
|
||||||
|
- [ ] Optionally select a **Status** to update the ticket to (e.g. "In Progress" → "Resolved")
|
||||||
|
- [ ] Click **Post to Ticket**
|
||||||
|
- [ ] Verify: success toast appears
|
||||||
|
- [ ] Verify in ConnectWise: open the ticket and confirm the note was posted with correct content and attribution (your member name)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 — FlowPilot Settings
|
||||||
|
|
||||||
|
Configure how FlowPilot behaves with PSA automation.
|
||||||
|
|
||||||
|
- [ ] Go to **Account → Integrations → FlowPilot** tab
|
||||||
|
- [ ] Review each setting:
|
||||||
|
- **Auto Push** — automatically post session doc on session close
|
||||||
|
- **Auto Time Entry** — automatically log hours from session duration
|
||||||
|
- **Time Rounding** — 15min / 30min / exact / none
|
||||||
|
- **Note Visibility** — internal only vs. internal + external
|
||||||
|
- **Include Diagnostic Steps** — whether to include step-by-step notes
|
||||||
|
- **Prompt Status on Resolution** — ask to update CW status when resolving
|
||||||
|
- **Prompt Status on Escalation** — ask to update CW status when escalating
|
||||||
|
- [ ] Adjust to your preference and save
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8 — End-to-End Smoke Test
|
||||||
|
|
||||||
|
Run a complete session to confirm the full flow works together.
|
||||||
|
|
||||||
|
- [ ] Start a new FlowPilot session with a test ticket in CW
|
||||||
|
- [ ] Link the ticket at session start
|
||||||
|
- [ ] Work through a troubleshooting flow (even a simple one)
|
||||||
|
- [ ] Resolve or escalate the session
|
||||||
|
- [ ] Post the session documentation to the CW ticket
|
||||||
|
- [ ] Open the ticket in ConnectWise and confirm:
|
||||||
|
- [ ] Note content is correct and well-formatted
|
||||||
|
- [ ] Note is attributed to the correct CW member
|
||||||
|
- [ ] Ticket status was updated (if you chose to update)
|
||||||
|
- [ ] Duration / time entry was logged (if auto-time-entry is on)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues / Bugs Fixed
|
||||||
|
|
||||||
|
| Bug | Status | Location |
|
||||||
|
|-----|--------|----------|
|
||||||
|
| `create_time_entry()` used `self._client` instead of `self.client` | Fixed 2026-04-14 | `services/psa/connectwise/provider.py:539` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's NOT Yet Implemented
|
||||||
|
|
||||||
|
| Feature | Notes |
|
||||||
|
|---------|-------|
|
||||||
|
| Autotask PSA | Schema accepts `autotask` as provider but no implementation exists |
|
||||||
|
| Retry queue for failed posts | `retry_count` / `next_retry_at` columns exist in DB but no background job |
|
||||||
|
| `psa_activity_log` population | Table exists, no endpoints write to it yet |
|
||||||
|
| Post History tab | Currently a placeholder — post history is viewable per-session only |
|
||||||
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ConnectWise integration docs
|
||||||
|
|
||||||
|
Reference material for ResolutionFlow's ConnectWise Manage integration.
|
||||||
|
This folder pairs a **human-editable source** (the XLSX) with two
|
||||||
|
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
|
||||||
|
read the Markdown; edits happen in the XLSX.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Role | Edit? |
|
||||||
|
|------|------|-------|
|
||||||
|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
|
||||||
|
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
|
||||||
|
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
|
||||||
|
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
|
||||||
|
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
|
||||||
|
|
||||||
|
## Regeneration workflow
|
||||||
|
|
||||||
|
After editing the XLSX:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs/integrations/connectwise/source
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python generate_role_docs.py \
|
||||||
|
--source Security_Roles_Matrix_11132017.xlsx \
|
||||||
|
--out-yaml ../api-member-security-roles.yaml \
|
||||||
|
--out-md ../api-member-security-roles.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
|
||||||
|
is what reviewers should scrutinize — it is the source of truth for code.
|
||||||
|
|
||||||
|
## Querying the YAML from integration code
|
||||||
|
|
||||||
|
The YAML groups permissions by module and action. Example — checking
|
||||||
|
what `Inquire: ALL` means for Service Desk → Service Tickets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
doc = yaml.safe_load(
|
||||||
|
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
|
||||||
|
)
|
||||||
|
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
|
||||||
|
print(levels["ALL"])
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
|
||||||
|
layer should use when the required permission level for a given API
|
||||||
|
endpoint needs to be documented or validated against an assigned role.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
|
||||||
|
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
|
||||||
|
- **`Not applicable` notes** in a verb's cell mean the meaningful level
|
||||||
|
is documented under another verb (almost always `inquire`) — the
|
||||||
|
generator preserves these as `note:` fields rather than inventing
|
||||||
|
placeholder levels.
|
||||||
|
- **The XLSX is the single source of input.** Never hand-edit the YAML
|
||||||
|
or Markdown; your changes will be overwritten on the next regeneration.
|
||||||
Binary file not shown.
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
File diff suppressed because it is too large
Load Diff
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
File diff suppressed because it is too large
Load Diff
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Generate ConnectWise security-role documentation from the source XLSX.
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
- api-member-security-roles.yaml : machine-readable source of truth
|
||||||
|
- api-member-security-roles.md : human-readable reference
|
||||||
|
|
||||||
|
Re-run this script after editing the source XLSX. Both outputs are
|
||||||
|
deterministic — they will produce identical content from identical input,
|
||||||
|
so diffs in version control reflect only real permission-model changes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python generate_role_docs.py \
|
||||||
|
--source source/Security_Roles_Matrix_11132017.xlsx \
|
||||||
|
--out-yaml ../api-member-security-roles.yaml \
|
||||||
|
--out-md ../api-member-security-roles.md
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# A level description line looks like "ALL: text..." or "NONE: text..."
|
||||||
|
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
|
||||||
|
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
|
||||||
|
|
||||||
|
# Recognized ConnectWise permission levels, most-to-least privileged.
|
||||||
|
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
|
||||||
|
|
||||||
|
VERBS = ["add", "edit", "delete", "inquire"]
|
||||||
|
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CellPermission:
|
||||||
|
"""Parsed contents of a single (action, verb) cell."""
|
||||||
|
|
||||||
|
levels: Dict[str, str] = field(default_factory=dict) # level -> description
|
||||||
|
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
|
||||||
|
raw: str = "" # original cell text, preserved for audit
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionRow:
|
||||||
|
module: str
|
||||||
|
action: str
|
||||||
|
permissions: Dict[str, CellPermission] # verb -> CellPermission
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cell(raw: Optional[str]) -> CellPermission:
|
||||||
|
"""Parse a single cell's multi-line content into levels + note."""
|
||||||
|
if raw is None:
|
||||||
|
return CellPermission(raw="")
|
||||||
|
text = str(raw).strip()
|
||||||
|
cp = CellPermission(raw=text)
|
||||||
|
if not text:
|
||||||
|
return cp
|
||||||
|
|
||||||
|
# Split into candidate entries. Each entry is typically one line that
|
||||||
|
# starts with a level prefix, but description text can itself contain
|
||||||
|
# newlines. We therefore split on newlines and accumulate continuation
|
||||||
|
# lines into the preceding entry.
|
||||||
|
current_level: Optional[str] = None
|
||||||
|
current_buf: List[str] = []
|
||||||
|
note_buf: List[str] = []
|
||||||
|
|
||||||
|
def flush_level() -> None:
|
||||||
|
nonlocal current_level, current_buf
|
||||||
|
if current_level is not None:
|
||||||
|
cp.levels[current_level] = " ".join(current_buf).strip()
|
||||||
|
current_level = None
|
||||||
|
current_buf = []
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = LEVEL_LINE.match(line)
|
||||||
|
if m:
|
||||||
|
flush_level()
|
||||||
|
current_level = m.group(1).upper()
|
||||||
|
current_buf = [m.group(2).strip()]
|
||||||
|
elif current_level is not None:
|
||||||
|
current_buf.append(line)
|
||||||
|
else:
|
||||||
|
# No level prefix yet — belongs to the note.
|
||||||
|
note_buf.append(line)
|
||||||
|
flush_level()
|
||||||
|
|
||||||
|
if note_buf:
|
||||||
|
cp.note = " ".join(note_buf).strip()
|
||||||
|
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
|
||||||
|
wb = load_workbook(xlsx_path, data_only=True)
|
||||||
|
ws = wb.active # Single sheet in this workbook.
|
||||||
|
|
||||||
|
# Header row is row 2 per the source file; data begins row 3.
|
||||||
|
actions: List[ActionRow] = []
|
||||||
|
for r in range(3, ws.max_row + 1):
|
||||||
|
module = ws.cell(row=r, column=1).value
|
||||||
|
action = ws.cell(row=r, column=2).value
|
||||||
|
if not (module or action):
|
||||||
|
continue # skip fully empty rows
|
||||||
|
if not module or not action:
|
||||||
|
# Partial row — keep but flag. This shouldn't happen in the
|
||||||
|
# current source; if it does, the generator should fail loudly
|
||||||
|
# rather than silently produce wrong output.
|
||||||
|
raise ValueError(
|
||||||
|
f"Row {r} has a missing Module or Action: "
|
||||||
|
f"module={module!r}, action={action!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
perms: Dict[str, CellPermission] = {}
|
||||||
|
for verb, col in VERB_COLS.items():
|
||||||
|
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
|
||||||
|
|
||||||
|
actions.append(
|
||||||
|
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output: YAML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
|
||||||
|
"""Build a plain-dict representation that YAML dumps cleanly."""
|
||||||
|
# Group by module, preserving action order within each module.
|
||||||
|
modules: Dict[str, List[ActionRow]] = {}
|
||||||
|
for a in actions:
|
||||||
|
modules.setdefault(a.module, []).append(a)
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"metadata": {
|
||||||
|
"source_file": source_file,
|
||||||
|
"generated_on": date.today().isoformat(),
|
||||||
|
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
|
||||||
|
"description": (
|
||||||
|
"ConnectWise security-role matrix. Each (module, action) entry "
|
||||||
|
"describes what each access level (ALL, MY, MINE, NONE) means "
|
||||||
|
"for the Add, Edit, Delete, and Inquire verbs. This is a "
|
||||||
|
"reference catalog, not a per-role assignment — role "
|
||||||
|
"assignments live in ConnectWise and are mirrored in the "
|
||||||
|
"ResolutionFlow integration config."
|
||||||
|
),
|
||||||
|
"level_order_most_to_least_privileged": LEVEL_ORDER,
|
||||||
|
},
|
||||||
|
"modules": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for module_name, rows in modules.items():
|
||||||
|
module_block = {"actions": {}}
|
||||||
|
for a in rows:
|
||||||
|
action_block: Dict[str, object] = {}
|
||||||
|
for verb in VERBS:
|
||||||
|
cell = a.permissions[verb]
|
||||||
|
entry: Dict[str, object] = {}
|
||||||
|
if cell.levels:
|
||||||
|
# Emit levels in canonical order, only those present.
|
||||||
|
entry["levels"] = {
|
||||||
|
lvl: cell.levels[lvl]
|
||||||
|
for lvl in LEVEL_ORDER
|
||||||
|
if lvl in cell.levels
|
||||||
|
}
|
||||||
|
if cell.note:
|
||||||
|
entry["note"] = cell.note
|
||||||
|
if not entry:
|
||||||
|
# Truly empty cell — represent explicitly so downstream
|
||||||
|
# consumers can distinguish "empty" from "missing".
|
||||||
|
entry["note"] = "(no description provided)"
|
||||||
|
action_block[verb] = entry
|
||||||
|
module_block["actions"][a.action] = action_block
|
||||||
|
doc["modules"][module_name] = module_block
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class _LiteralStr(str):
|
||||||
|
"""Marker type so PyYAML renders long strings as block literals."""
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_presenter(dumper, data):
|
||||||
|
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
||||||
|
|
||||||
|
|
||||||
|
yaml.add_representer(_LiteralStr, _literal_presenter)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_block_style_for_long_strings(obj):
|
||||||
|
"""Recursively wrap long strings so the YAML is readable, not one-line."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_use_block_style_for_long_strings(v) for v in obj]
|
||||||
|
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
|
||||||
|
return _LiteralStr(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def dump_yaml(doc: dict, out_path: Path) -> None:
|
||||||
|
prepared = _use_block_style_for_long_strings(doc)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
|
||||||
|
f.write("# Generated from the source XLSX; do not edit by hand.\n")
|
||||||
|
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
|
||||||
|
yaml.dump(
|
||||||
|
prepared,
|
||||||
|
f,
|
||||||
|
sort_keys=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
width=100,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output: Markdown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _md_escape(text: str) -> str:
|
||||||
|
"""Escape pipes and collapse whitespace for Markdown table cells."""
|
||||||
|
return text.replace("|", "\\|").replace("\n", " ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
|
||||||
|
modules: Dict[str, List[ActionRow]] = {}
|
||||||
|
for a in actions:
|
||||||
|
modules.setdefault(a.module, []).append(a)
|
||||||
|
|
||||||
|
lines: List[str] = []
|
||||||
|
lines.append("# ConnectWise API Member — Security Roles Reference")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"_Generated {date.today().isoformat()} from "
|
||||||
|
f"`{source_file}`. Do not edit by hand — update the XLSX and "
|
||||||
|
f"re-run `generate_role_docs.py`._"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## How to read this document")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Each ConnectWise module lists the actions it governs. For every "
|
||||||
|
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
|
||||||
|
"**Inquire** — can be granted at one of these levels, most to "
|
||||||
|
"least privileged:"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Level | Meaning |")
|
||||||
|
lines.append("|-------|---------|")
|
||||||
|
lines.append("| `ALL` | Access to all records in the system. |")
|
||||||
|
lines.append("| `MY` | Access to records owned by the user's team. |")
|
||||||
|
lines.append("| `MINE` | Access only to records owned by the user. |")
|
||||||
|
lines.append("| `NONE` | No access. |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Not every level applies to every action — the source matrix "
|
||||||
|
"only documents the levels that are meaningful for each cell. "
|
||||||
|
"Cells marked _Not applicable_ reference another verb (usually "
|
||||||
|
"Inquire) where the meaningful level is defined."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"The machine-readable form of this document is "
|
||||||
|
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
|
||||||
|
"Use the YAML when writing integration code; use this Markdown "
|
||||||
|
"when reviewing, discussing, or onboarding."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Table of contents")
|
||||||
|
lines.append("")
|
||||||
|
for module_name in modules:
|
||||||
|
anchor = module_name.lower().replace(" ", "-").replace("/", "")
|
||||||
|
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for module_name, rows in modules.items():
|
||||||
|
lines.append(f"## {module_name}")
|
||||||
|
lines.append("")
|
||||||
|
for a in rows:
|
||||||
|
lines.append(f"### {a.action}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Verb | Level | Description |")
|
||||||
|
lines.append("|------|-------|-------------|")
|
||||||
|
wrote_any = False
|
||||||
|
for verb in VERBS:
|
||||||
|
cell = a.permissions[verb]
|
||||||
|
if cell.levels:
|
||||||
|
for lvl in LEVEL_ORDER:
|
||||||
|
if lvl in cell.levels:
|
||||||
|
lines.append(
|
||||||
|
f"| {verb.capitalize()} | `{lvl}` | "
|
||||||
|
f"{_md_escape(cell.levels[lvl])} |"
|
||||||
|
)
|
||||||
|
wrote_any = True
|
||||||
|
elif cell.note:
|
||||||
|
lines.append(
|
||||||
|
f"| {verb.capitalize()} | — | "
|
||||||
|
f"_{_md_escape(cell.note)}_ |"
|
||||||
|
)
|
||||||
|
wrote_any = True
|
||||||
|
if not wrote_any:
|
||||||
|
lines.append("| — | — | _(no description provided)_ |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write_markdown(md_text: str, out_path: Path) -> None:
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(md_text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", type=Path, required=True,
|
||||||
|
help="Path to the source .xlsx")
|
||||||
|
parser.add_argument("--out-yaml", type=Path, required=True,
|
||||||
|
help="Path to write the YAML output")
|
||||||
|
parser.add_argument("--out-md", type=Path, required=True,
|
||||||
|
help="Path to write the Markdown output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
actions = read_matrix(args.source)
|
||||||
|
doc = build_yaml_document(actions, source_file=args.source.name)
|
||||||
|
dump_yaml(doc, args.out_yaml)
|
||||||
|
|
||||||
|
md = build_markdown(actions, source_file=args.source.name)
|
||||||
|
write_markdown(md, args.out_md)
|
||||||
|
|
||||||
|
# Quick data-quality summary to stdout — helpful when re-running after edits.
|
||||||
|
from collections import Counter
|
||||||
|
modules_seen = Counter(a.module for a in actions)
|
||||||
|
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
|
||||||
|
for m, n in modules_seen.most_common():
|
||||||
|
print(f" {m}: {n}")
|
||||||
|
print(f"\nWrote {args.out_yaml}")
|
||||||
|
print(f"Wrote {args.out_md}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user