179 Commits

Author SHA1 Message Date
fb7690485b fix(tickets): fix statuses endpoint, members auth gate, and graceful error handling
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
- Add GET /boards/{board_id}/statuses endpoint — direct board-to-statuses lookup
  without ticket roundabout; used by filter bar and new ticket form
- Fix TicketsPage and NewTicketModal to call getBoardStatuses(board_id) instead
  of misusing getTicketStatuses(ticket_id) with a board_id value
- Fix list_members auth: was require_account_owner (owner/super_admin only) —
  changed to require_engineer_or_admin so engineers can see member list for
  ticket assignment
- list_members: return [] on PSAError instead of 502 (Lesson 111 pattern)
- get_ticket_statuses: return [] on PSAError instead of 502

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 05:33:23 +00:00
6044d5a88b fix(tickets): fix permissions toast, board fallback, assignment search, remove load more
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
- list_resources: return [] on PSAError instead of 502 — stops global interceptor
  toast when CW API key lacks ticket members permission (Lesson 111)
- list_boards/list_priorities: add warning logging so Railway logs reveal the
  root cause when CW permissions are missing
- TicketsPage: derive board options from ticket search results when listBoards
  returns empty (CW permissions fallback)
- TicketFilterBar: replace assignment <select> with searchable member picker —
  fixed options (All/Mine/Unassigned) + text-filtered member dropdown
- TicketQueue: remove Load More / infinite scroll; page now exists at /tickets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 04:59:03 +00:00
00cd8b7c55 feat(tickets): update TicketQueue with mapping detection, 5-item cap, View All link
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:42:25 +00:00
fded959b5e fix(tickets): guard linkedTicket fetch with currentChatRef to prevent race condition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:40:31 +00:00
5f5b9e5b23 feat(tickets): add spin-off ticket creation in ResolutionAssist — state, action handler, modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:37:46 +00:00
b2ee1a2150 fix(tickets): improve accessibility and error logging in ticket creation components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:34:08 +00:00
08909aa884 feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:31:21 +00:00
070d2383bc fix: remove unused PSATicketSearchResult import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:26:23 +00:00
d7b1fe6645 feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:24:18 +00:00
a3f8bb3427 feat(tickets): add ticket detail subcomponents
- TicketDetailHeader: Display ticket info with status dropdown
- TicketNotesFeed: Chronological list of ticket notes with internal flag
- TicketAddNote: Form to add notes (requires linked session)
- TicketConfigs: Display related configurations/devices
- TicketRelated: List of related tickets as clickable buttons

All components use type-safe imports from psaContext and integrations APIs.
Styling follows design system (flat dark theme, electric blue accent, Tailwind v4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:19:18 +00:00
f050afc2f7 feat(tickets): add /tickets route and sidebar nav item
Add Tickets page route to router with lazy loading and code splitting.
Add Tickets navigation entry to sidebar in RESOLVE section for both
icon rail and pinned layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:15:35 +00:00
849e1c16e2 feat(tickets): add TicketsPage with URL-param filter state, stub detail panel and modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:12:26 +00:00
5310cd3fff fix(tickets): add company_id reset to filter clear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:09:51 +00:00
d2689afa53 feat(tickets): add TicketFilterBar and TicketListRow components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:08:15 +00:00
9d88c8456c feat(tickets): add tickets API client, update integrations API for paginated search, fix callers
- Create frontend/src/api/tickets.ts with ticketsApi (resources, status, create, ai-parse, priorities, search)
- Update integrationsApi.searchTickets and searchTicketsQueue return types from PSATicketSearchResult[] to TicketListResponse
- Fix TicketQueue.tsx to use results.items (append/set) and results.items.length for pagination check
- Fix TicketPickerModal.tsx to use results.items when setting search results
- Export ticketsApi from api/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:05:13 +00:00
506aac609d feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:02:53 +00:00
7fa81f69a6 feat(psa): add spin-off ticket system prompt rule, backend routing tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:01:21 +00:00
6e0188d0b4 feat(psa): add AI ticket parse endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:59:02 +00:00
24ab1908a6 fix(psa): add TicketListResponseSchema response_model to search_tickets endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:57:23 +00:00
e2cdfac1c3 feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:55:49 +00:00
a5e9615666 feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:52:32 +00:00
66cca70588 feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:50:56 +00:00
e714088a2b feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:49:20 +00:00
ff0ec143e2 feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:45:24 +00:00
8d964e64e4 fix(psa): update autotask/halopsa stub search_tickets return type annotation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:44:08 +00:00
44634b1145 feat(psa): add PaginatedTicketResult type, update provider search_tickets signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:41:48 +00:00
001438008b docs: fix PSA ticket management spec — prefill state, TicketQueue naming
- Replace false claim about linkedTicket state with explicit fetch step on modal open
- Remove MyQueueWidget references; TicketQueue is the existing component being updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:00:33 +00:00
c8b68ad26d docs: fix PSA ticket management spec — pagination source, widget, linked ticket IDs
- Define PaginatedTicketResult provider type + parallel count fetch via CW /count endpoint
- Fix dashboard widget: updates existing TicketQueue (not new), uses searchTicketsQueue
- Fix NewTicketModal prefill: expand PSATicketInfo with company_id/board_id fields
- Correct Dashboard section description: not collapsible, TicketQueue already exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:49:39 +00:00
2b3d52ad77 docs: fix PSA ticket management spec — API contract, actions format, file routing
- Explicitly call out search_tickets breaking change and all existing callers
- Fix [ACTIONS] marker to use JSON array format matching existing parser
- Route system prompt change to assistant_chat_service.py, not flowpilot_engine
- Pivot detail panel hydration to existing getTicketContext + listResources

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:44:34 +00:00
52b369680b docs: add PSA ticket management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:36:27 +00:00
f0ccf313a4 docs: add lessons 110-111 (RLS backfill audit, axios interceptor pattern)
Some checks failed
CI / backend (push) Failing after 15m45s
CI / frontend (push) Failing after 47s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:50:43 +00:00
0d9babb986 fix(rls): add account_id to AISessionStep creations, fix boards toast
Some checks failed
CI / backend (push) Failing after 16m37s
CI / frontend (push) Failing after 45s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
- flowpilot_engine: pass account_id at all 5 AISessionStep instantiation
  sites (_create_step_from_parsed x3, briefing step, status update step).
  Phase 4 RLS blocked every INSERT with NULL account_id — this broke all
  new FlowPilot sessions since the Phase 4 migration was applied.
- integrations: list_boards returns [] on PSAError instead of 502, stopping
  the spurious 'Server error' toast on dashboard load (boards are optional).
- client.ts: 5xx global toast now shows backend detail when available.
- useFlowPilotSession: startSession extracts backend detail for error state;
  suppresses duplicate toast for 5xx (global interceptor already handles it).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 04:41:14 +00:00
567985402f fix(psa): use board/id in (...) for multi-board filter per CW docs
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:54:05 +00:00
08a4c6600d fix(psa): use resources contains identifier for my tickets filter
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 3s
CW resources field is a plain string of member identifiers (login names),
not a navigable object. resources/member/id was invalid syntax causing 403.

Now resolves the CW member identifier from the cached member list and
uses: resources contains '{identifier}' which is the correct condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:53:26 +00:00
29fa48e71b fix(psa): revert to resources/member/id for my tickets filter
Some checks failed
CI / backend (push) Has started running
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
Requires CW API member security role to have All scope on Service Tickets.
owner/id was incorrect for workflows using resources-based assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:48:10 +00:00
908a867986 fix(psa): use owner/id instead of resources/member/id for my tickets filter
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
resources/member/id requires All scope on Service Tickets security role.
owner/id (primary assignee) works with standard Mine scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:43:34 +00:00
346576a730 feat(psa): ticket queue dashboard with board selector and session auto-start
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
- Add PSABoard type + list_boards() to CW provider (cached 1h)
- Extend search_tickets with assigned_to_me, unassigned, board_ids, page, page_size
- New GET /integrations/psa/boards endpoint
- New TicketQueue dashboard component: My Tickets / Unassigned tabs,
  multi-select board filter, Load more pagination, Start Session per ticket
- Add TicketQueue to QuickStartPage after active sessions
- FlowPilotSessionPage auto-starts with ticket context when navigated
  from TicketQueue (psaTicketId + psaTicket in location.state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:20:45 +00:00
b18072e24b fix(psa): set account_id on PsaMemberMapping in save and auto-match
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
2026-04-15 02:59:49 +00:00
e0f44e2985 fix(ci): connect to postgres service by hostname, not localhost
Some checks failed
CI / backend (push) Failing after 16m41s
CI / frontend (push) Failing after 56s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 2s
2026-04-15 01:52:03 +00:00
adfbb39297 fix(ci): use --break-system-packages for pip on Ubuntu 24.04
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
CI / backend (push) Failing after 50s
CI / frontend (push) Failing after 42s
CI / e2e (push) Has been skipped
2026-04-15 01:49:58 +00:00
6bae205a8c chore: trigger CI
Some checks failed
CI / backend (push) Failing after 12s
CI / frontend (push) Failing after 1m6s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-15 01:48:17 +00:00
ee2b2c2399 feat(ci): port CI workflow from Github Actions to Gitea
Some checks failed
Mirror to GitHub / mirror (push) Successful in 3s
CI / backend (push) Failing after 35s
CI / frontend (push) Failing after 32s
CI / e2e (push) Has been skipped
2026-04-14 23:33:12 +00:00
37bc47b75b chore: add runner probe workflow
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-14 23:27:30 +00:00
c8bdd0014e Update Github mirror workflow
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-14 22:50:53 +00:00
2a2b770405 Update Github mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 3s
2026-04-14 22:49:20 +00:00
d6d0e9f3c1 Add GitHub mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
2026-04-14 22:43:09 +00:00
ab4bf3b32f Add GitHub mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
2026-04-14 22:31:37 +00:00
chihlasm
d3c93cd006 feat(admin): allow setting owner when creating an account
feat(admin): allow setting owner when creating an account
2026-04-14 17:27:02 -04:00
chihlasm
4037a5213e fix(admin): use EmailStr for owner_email validation in AdminAccountCreate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:25:03 +00:00
chihlasm
0ed5977fee feat(admin): allow setting owner when creating an account
Adds optional owner_email field to the Create Account modal. Superadmin
can specify an existing user's email to assign as account owner at
creation time. Backend 404s with a clear message if the email is unknown.
Error detail now surfaces to the toast instead of a generic message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:30:23 +00:00
chihlasm
c5b8229ef6 fix(admin): allow owner and admin account roles in user creation and role management
Four places were hardcoded to engineer|viewer only:
- AccountRoleUpdate schema (user.py) — blocked PUT /admin/users/{id}/account-role at the API level
- AdminUserCreate schema (admin.py) — blocked creating users with owner/admin role
- AccountDetailPage role dropdowns (create form + inline member role changer)
- AccountsPage create user role dropdown

Now all four accept the full set: owner, admin, engineer, viewer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:24:17 +00:00
chihlasm
eba50e1f95 docs(claude-md): trim GitNexus section to selective-use guidance
Remove mandatory "MUST run before every edit" rules — they add overhead
without value for additive/isolated changes. Keep the tools table and
use-it-when-it-matters guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:58:40 +00:00
chihlasm
8eb814283d fix(psa): fix time entry AttributeError and show all users in member mapping
- Fix create_time_entry() using self._client instead of self.client
- GET /member-mappings now returns all active account users, not just mapped
  ones — allows manual assignment when auto-match by email doesn't work
- PsaMemberMappingResponse mapping fields are now Optional (id, external_member_id,
  external_member_name, matched_by) to represent unmapped users
- Frontend MemberMappingTab skips null external_member_id when building
  localMappings, and derives user list from all returned entries
- Add docs/connectwise-psa-testing-checklist.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:09:01 +00:00
chihlasm
b433b232dc polish(network): visual refinements across node, edge, and panel components
- DeviceNode: flat bg-card (no surface gradient), darker icon plate inset,
  correct text-muted token for category label
- GroupNode: label pill gets bg-card/90 background so it reads against canvas
- ConnectionEdge: label now has border + bg-card so it doesn't float invisible
- BaseHandle: tightened to 12px with accent-toned border
- NodeStatusIndicator: glow reduced to 0.15 opacity (design system compliant)
- ContextMenu: Ungroup now uses Ungroup icon instead of BoxSelect
- DeviceToolbar: group type icons coloured with semantic palette
- PropertiesPanel: empty state gets icon tile + cleaner copy hierarchy
- DiagramEditor: shortcut ? button repositioned above MiniMap, accent hover
- NetworkDiagrams list: card thumbnail placeholder uses dot-grid pattern,
  card menu gets icons and divider before destructive action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 05:35:25 +00:00
chihlasm
015df1fe5f fix(network): consolidate import buttons, redesign empty state, add shortcut overlay
- Import/Export button in editor header: removed standalone Import button, moved
  draw.io import into Export/Import dropdown with labelled sections; fixes
  conceptual trap where Import implied operating on the current diagram
- List page: replaced two identical Upload-icon Import buttons with a single
  dropdown (Import JSON / Import draw.io) with format descriptions
- Empty state: replaced icon-in-box with a horizontal card featuring a static
  SVG topology preview, MSP-specific value prop, and dual CTAs
- Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid),
  triggered by ? key or the ? button pinned to the canvas bottom-right corner;
  wired into useCanvasShortcuts hook
- Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone,
  FileOutput = export file format)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 04:49:25 +00:00
chihlasm
cf9c258f9e fix(network): surface connect tool and middle-pan 2026-04-14 03:41:21 +00:00
chihlasm
c063952f12 feat(network): add connect tool and middle-pan 2026-04-14 03:28:07 +00:00
chihlasm
36721eb5af feat(network): improve connector editing 2026-04-14 02:56:28 +00:00
chihlasm
3cd4084f78 refactor(network): simplify diagram node visuals 2026-04-14 02:42:47 +00:00
chihlasm
ed763d1cea chore(network): remove asset style lab 2026-04-14 02:29:26 +00:00
chihlasm
c37e216e0b feat(network): add asset style lab mockups 2026-04-14 02:10:48 +00:00
chihlasm
91cc9a4170 feat(network): draw.io XML import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:30:22 +00:00
chihlasm
2a4220b496 feat(network): draw.io XML export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:25:49 +00:00
chihlasm
c8f571db39 feat(network): thumbnail generation on save, shown on list page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:22:51 +00:00
chihlasm
7efa22454d feat(network): improve PDF export with print stylesheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:20:28 +00:00
chihlasm
05421fc65c feat(network): add SVG export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:19:19 +00:00
chihlasm
dfcad531e2 fix(network): context menu on groups + group/ungroup in properties panel
Context menu fix:
- Group nodes pass pointer events through to children in React Flow, so
  right-clicking a group fires onPaneContextMenu instead of onNodeContextMenu
- handlePaneContextMenu now checks for selected nodes and shows the node
  context menu (with align/group options) when any nodes are selected

Properties panel multi-select:
- Add Group section with type dropdown (Subnet, VLAN, Site, DMZ, Custom)
- "Group into [Type]" button creates a group of the chosen type
- Ungroup button appears when a group node is in the selection
- useDiagramCommands.groupSelection now accepts a groupType param and
  uses it as the label and color key for the new group node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:55:34 +00:00
chihlasm
684fb07e47 feat(network): add pointer/hand mode toggle to diagram toolbar
- Header shows MousePointer2 (select) and Hand (pan) toggle buttons
- Select mode: drag on canvas draws a selection box (selectionOnDrag)
- Pan mode: drag on canvas pans the viewport (panOnDrag)
- Space held in either mode temporarily switches to pan (panActivationKeyCode)
- Keyboard shortcuts: V = select mode, H = pan mode
- Cursor changes to grab/grabbing in pan mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:38:51 +00:00
chihlasm
4a12c9b37d fix(network): persist group node type, size, and child parentId on save/load
Backend DiagramNode schema was missing nodeType, style, and parentId fields —
Pydantic stripped them on save, so group nodes lost their identity on reload
and re-appeared as small device icons.

- Backend: add nodeType, style (NodeStyle), parentId to DiagramNode schema
- Frontend: serialize parentId for device nodes inside groups
- Frontend: restore parentId + extent:'parent' on both deserializer paths (setNodes + history init)
- Frontend: add parentId to DiagramNode interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:49:26 +00:00
chihlasm
e41d7bd960 fix(network): align resize border with node visual boundary
NodeResizer handles positioned at RF wrapper size, but NodeTooltip and
NodeStatusIndicator wrappers had no size constraints, causing BaseNode
(w-full h-full) to shrink to content size instead of filling the wrapper.

Add w-full h-full to NodeTooltip, NodeTooltipTrigger, and
NodeStatusIndicator so the full height chain is maintained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:27:58 +00:00
chihlasm
f2c3bd7a9b fix(network): normalize z-order to 1..N after bring-to-front/send-to-back
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:44 +00:00
chihlasm
9786c6b1fb feat(network): add inline label editing on DeviceNode (double-click)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:41 +00:00
chihlasm
4529955f7d feat(network): add orthogonal edge routing option
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:33 +00:00
chihlasm
b7b0d41f92 feat(network): add group/ungroup commands with bounding box calculation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:14:26 +00:00
chihlasm
a4512dcf90 feat(network): add GroupNode component with resize, inline label, and group type colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:13:03 +00:00
chihlasm
764db79060 feat(network): add alignment toolbar to PropertiesPanel for multi-select
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:11:12 +00:00
chihlasm
f90e2c956f feat(network): add align/distribute/group sections to context menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:09:32 +00:00
chihlasm
bdaea68dd3 feat(network): add useDiagramCommands — alignment and distribution command layer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:08:37 +00:00
chihlasm
02c19a7580 feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:06:33 +00:00
chihlasm
a392d24101 feat(network): add undo/redo buttons to DiagramHeader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:04:58 +00:00
chihlasm
b9c9bb548d fix(network): force re-render on undo/redo so canUndo/canRedo stay accurate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:03:35 +00:00
chihlasm
662df2907d feat(network): add undo/redo snapshot history stack to DiagramEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:01:21 +00:00
chihlasm
b9547e6ce1 docs: add network diagrams Phase 2 implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:23:23 +00:00
chihlasm
760e0f77f8 docs: add network diagram draw.io-style implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:16:54 +00:00
chihlasm
a71f082e25 feat: extract admin account management rework from PR 124 (#138)
* feat: reorganize admin panel around accounts

* feat: expand admin customer account controls

* feat: add admin account detail management

* fix: remove unused admin account icon import

* refactor: design critique fixes for account pages

- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use get_admin_db for all new admin account endpoints

All admin endpoints query across tenants without a tenant context.
get_db (app-role, subject to RLS) was never imported and would crash
at runtime — replace all 6 occurrences with get_admin_db (BYPASSRLS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:44:51 -04:00
chihlasm
abd79bc763 feat: extract network map builder from PR 124 (#137)
* feat: add device_types table with system seed data

Creates DeviceType SQLAlchemy model and migration 073 that provisions the
device_types table with 28 system-seeded device types across 7 categories
(network, compute, storage, cloud, endpoint, infrastructure, security).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network_diagrams table

Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Pydantic schemas for device types and network diagrams

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add device types CRUD router

Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AI generation service for network diagrams

Adds network_diagram_ai_service.py with generate_diagram() function that
calls the AI provider to convert plain-English network descriptions into
structured DiagramNode/DiagramEdge data. Registers the action in
ACTION_MODEL_MAP as a standard-tier route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network diagrams CRUD + AI generate + export/import router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TypeScript types for network diagrams

Adds all interfaces for network diagrams and device types including
DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse,
AI generate request/response, import/export shapes, and list item types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add frontend API clients for device types and network diagrams

Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi
(list, get, create, update, archive, duplicate, exportJson, importJson,
aiGenerate, listClients) following the existing apiClient module pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add device registry, DeviceNode, ConnectionEdge for React Flow

Creates the React Flow building blocks for the network diagram editor:
device type registry with icon/color mappings, DeviceNode component with
status indicators and connection handles, ConnectionEdge with per-type
styling, and nodeTypes/edgeTypes registration maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PropertiesPanel for node and edge property editing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AIAssistPanel with replace and merge modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add NetworkCanvas wrapper and DiagramHeader components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DiagramEditor page assembling all panels with auto-save and AI generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Diagrams list page with search, client filter, import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Maps to sidebar navigation and router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve TypeScript errors in DeviceToolbar and DiagramEditor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve stale selection bug in network diagram PropertiesPanel

Selection state now stores IDs and derives objects from live arrays,
so edits in PropertiesPanel inputs reflect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add React Flow UI foundation components for network diagrams

BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add LabeledGroupNode and AnimatedSvgEdge components

GroupNode for subnet/VLAN/site grouping with positioned label badge.
AnimatedSvgEdge for traffic flow visualization with animated SVG
shape along edge path. Both registered in type maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip

Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add grouping toolbar items and traffic flow toggle

DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with
drag-drop. PropertiesPanel gets Show Traffic toggle that switches
edges between connection and animated types. DiagramEditor handles
both device and group node drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings for React Flow UI integration

- Use screenToFlowPosition() for drop coordinates (fixes zoom/pan bug)
- Remove duplicate selection border from DeviceNode (BaseNode handles it)
- Add w-full to GroupNode for proper container sizing
- Remove unused 'selected' destructuring from DeviceNode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ISP icon to network diagram device registry

Globe icon with accent color, under cloud category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve drag-and-drop feel in network diagram editor

Grip icons on draggable toolbar items, press effect on drag start,
dashed border overlay with 'Drop to add' text when dragging over canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ContextMenu component for network diagram editor

Charcoal-styled context menu with action factories for node
and canvas variants. Viewport-clamped positioning, auto-dismiss
on click outside, escape, or scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add useCanvasShortcuts hook for copy/paste/duplicate

Keyboard shortcuts with preventDefault and input guard.
Clipboard stores nodes with relative positions and edge indices.
Paste computes canvas center via screenToFlowPosition.
Duplicate offsets +30px. Supports both device and group nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire context menu and keyboard shortcuts into diagram editor

Right-click context menus for nodes (copy/duplicate/delete) and
canvas (paste/select-all/fit-view). Right-click selects the node
per spec. serializeNodes now handles group nodes correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context menu dismisses on pane click, ISP in toolbar

Context menu now closes when clicking anywhere on the canvas via
onPaneClick prop. ISP device added as built-in toolbar item under
Internet section so it's always available without a database entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: backend code review fixes for network diagrams

- Replace legacy Optional imports with modern str | None syntax
- Type JSONB columns as Mapped[list[dict[str, Any]]]
- Escape SQL LIKE wildcards (%, _) in diagram search
- Type DiagramNode.position as Position(x, y) Pydantic model
- Wrap AI response parsing in KeyError handler for clean 422 errors
- Remove unused Optional/TYPE_CHECKING imports from schemas/models
- Extract _get_available_slugs helper to DRY duplicate queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize

- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: network diagram team_id guard + multi-style edge routing

Backend:
- Guard create_diagram with 422 if current_user.team_id is None (prevents
  NOT NULL constraint crash for accounts not yet assigned to a team)
- Add routing field to DiagramEdge schema (straight/curved/step)

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: network diagrams UX overhaul — icons, empty canvas, properties panel

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop changelog noise from network extraction

* fix: align network map builder with account isolation

* feat: add manual create option for network maps

* feat: make manual network map creation easier to discover

* fix(network-maps): address design critique — harden, normalize, clarify, polish

- Archive: two-step inline confirm in card dropdown menu
- Delete Device/Edge: two-step inline confirm in PropertiesPanel footer
- Context menu Delete: floating confirm bar instead of immediate deletion
- AI Generate New: two-step confirm when replacing existing diagram nodes
- DiagramHeader: show 'Unsaved changes' in amber when isDirty and not saving
- deviceRegistry: SECURITY_COLOR #f97316 → #f87171 (deprecated ember orange removed)
- CanvasEmptyPrompt: remove backdrop-blur (design system violation)
- CanvasEmptyPrompt: remove redundant 'Skip AI' bottom button (duplicate of Build manually card)
- CanvasEmptyPrompt: rounded-xl/rounded-2xl → rounded-lg, border-2 → border
- Topology bar: h-1 → h-2 + native tooltip with category breakdown
- AIAssistPanel: replace pulse-dot loading with spinner (consistent with rest of feature)
- ContextMenu: add shadow-lg (consistent with other dropdowns)
- DeviceNode tooltip: Position.Bottom → Position.Top (avoids canvas-edge clipping)
- CanvasEmptyPrompt: raise ⌘↵ hint from /50 opacity to full text-muted-foreground

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(network-maps): bring to front / send to back layering for nodes

Three entry points for z-index control:
- Right-click context menu: Bring to Front / Send to Back with ] / [ shortcuts, separated by dividers from copy/delete groups
- Properties panel: Layer row with Bring Front + Send Back buttons, tooltip shows keyboard shortcut
- Keyboard: ] brings selected node(s) to front, [ sends to back (skips when input focused)

Context menu also gains divider support (dividerBefore flag) for visual grouping.
Layering handlers use max/min zIndex across all nodes so repeated presses always stack correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: swap switch icon from Layers → Network (Lucide)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: icon size picker (S/M/L) on device nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: drag-to-resize device nodes + BrickWallFire for firewall

- NodeResizer on DeviceNode (same pattern as group nodes); icon scales
  proportionally with node width, clamped 16–60px
- Removes S/M/L static picker — resize is now direct manipulation
- firewall: ShieldAlert → BrickWallFire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: trigger Railway rebuild

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add missing hero_001.jpg to git (was untracked, broke Railway deploy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: ShieldAlert still referenced in CATEGORY_DEFAULTS after icon swap

Removed ShieldAlert from imports when swapping firewall icon to BrickWallFire
but left it in CATEGORY_DEFAULTS — runtime crash, device toolbar empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(network): proportional node resize with locked aspect ratio

Nodes grew into rectangles because NodeResizer had no aspect ratio
constraint, minWidth != minHeight, and icon/text only scaled from width.

- DeviceNode: add keepAspectRatio + equal minWidth/minHeight (80×80),
  maxWidth/maxHeight (280×280), scale icon and label/IP font sizes from
  Math.min(width, height) so all content grows uniformly
- DiagramEditor: set explicit 120×120 style on dropped device nodes so
  React Flow has a definite starting size for aspect ratio calculation
- DiagramEditor: persist device node style (width/height) in
  serializeNodes and restore it on load so size survives save/reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): suppress ESLint errors in network diagram components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:38:01 -04:00
Claude
af5ceea7f9 docs: update CHANGELOG with Phase 4 tenant isolation details
- Added Phase 4 RLS enforcement on all 31 remaining tables (#136)
- Documented BYPASSRLS session pattern and admin session factory
- Listed Phase 4 fixes for auth deps, background jobs, and seed scripts

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-12 10:44:07 +00:00
chihlasm
f54d7ecd78 docs: update current state after Phase 4 merge 2026-04-12 04:35:30 +00:00
chihlasm
46593ba8ca Merge PR #136: feat: tenant isolation Phase 4 — RLS on all remaining tables 2026-04-12 04:35:01 +00:00
chihlasm
52553d62d2 fix(tests): update expectations for RLS-correct behavior
- test_rls_isolation: add pytestmark for module-scoped event loop to fix
  "Future attached to a different loop" with pytest-asyncio 0.23 + asyncpg
  module-scoped fixtures
- test_admin_categories_global: global categories use PLATFORM_ACCOUNT_ID
  not NULL; update stale assertion
- test_permissions_account: with RLS, cross-tenant tree access returns 404
  (invisible) not 403 (forbidden) — update to match actual behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:48:30 +00:00
chihlasm
a48660700a fix: background jobs and lifespan must use BYPASSRLS sessions
All code that runs outside a request context (APScheduler jobs,
lifespan startup) has no app.current_account_id set, so the
app-role session returns 0 rows from every RLS-protected table.

Changed to _admin_session_factory (BYPASSRLS) in:
- knowledge_flywheel_scheduler.py — queries ai_sessions
- psa_retry_scheduler.py — queries psa_post_log
- retention_cleanup.py — queries assistant_chats
- scheduler.py (_fire_maintenance_schedule, _cleanup_expired_ai_conversations)
- main.py (archive_stale_ai_sessions, _process_notification_retries,
  load_all_schedules at startup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:44:23 +00:00
chihlasm
3ff886363c fix: use BYPASSRLS session for all auth deps and user-mutation endpoints
Phase 4 enabled RLS on the users table. All code paths that touch users
(or other RLS-protected tables) before require_tenant_context sets
app.current_account_id must use get_admin_db (BYPASSRLS):

- deps.py: get_current_user and get_current_active_user → get_admin_db
- auth.py: all endpoints → get_admin_db (login, register, refresh, etc.
  run before tenant context exists; mutation endpoints also need session
  consistency since current_user is in the admin session)
- accounts.py: transfer_ownership, leave_account, delete_account
  → get_admin_db (these mutate current_user directly)
- onboarding.py: dismiss_onboarding → get_admin_db (same reason)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:25:18 +00:00
chihlasm
501442e5f0 fix: seed_test_users must use ADMIN_DATABASE_URL after Phase 4 RLS on users
RLS is now enabled on the users table. The seed script was using the
app-role connection (DATABASE_URL) which has no tenant context at seed
time — all SELECTs return 0 rows and INSERTs are blocked by FORCE RLS.

Falls back to DATABASE_URL if ADMIN_DATABASE_URL is not set (local dev
without roles configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:12:46 +00:00
chihlasm
6f53ec06f5 docs: add lessons 107-109 — RLS startup, global tables, tree_shares account_id
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:58:12 +00:00
chihlasm
ec322f7cdf fix: bootstrap service account with BYPASSRLS session 2026-04-12 02:44:36 +00:00
chihlasm
f9248aeaa8 fix: remove platform_steps and template_trees from Phase 4 RLS
Both tables have no account_id column — they are globally readable
by all authenticated users and must not have RLS policies.

Also removes the corresponding test cases that assumed these tables
had account_id-based policies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:50 +00:00
chihlasm
c6da4ebee5 fix: remove script_categories from Phase 4 RLS — no account_id column
script_categories is a global lookup table (shared across all tenants).
The account_id column belongs to ScriptTemplate in the same model file,
not ScriptCategory. The Python scan matched the file, not the class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:32:42 +00:00
chihlasm
64f004a62c feat: tenant isolation Phase 4 — RLS on 31 remaining tables + script_builder fix
Enable RLS on all remaining tenant-scoped tables (31 tables):

Standard policy (tenant sees own rows):
  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,
  script_categories, session_ratings, tree_embeddings, user_folders,
  user_pinned_trees

Platform-visibility policy (own rows OR PLATFORM_ACCOUNT_ID):
  platform_steps, template_trees

Intentionally skipped:
  accounts (IS the root table, no account_id column)
  plan_feature_defaults (platform config, no account_id column)

Also fixes script_builder_service.create_session() which was missing
account_id= on ScriptBuilderSession construction, causing 500s on all
script builder endpoints (pre-existing CI failure).

Adds Phase 4 RLS isolation tests covering: users, script_builder_sessions,
ai_session_steps, notifications, platform_steps, template_trees.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:25:28 +00:00
Claude
ba36e37dab docs: update CHANGELOG with Tenant Isolation Phase 2 and Phase 3 details
- Document Phase 2: PostgreSQL RLS on 11 session tables, account_id NOT NULL enforcement, Alembic migration support
- Document Phase 3: RLS on audit_logs and tree_shares, cross-tenant session access for public shares, complete account_id propagation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:43:10 +00:00
chihlasm
9e6965512b Merge pull request #135 from resolutionflow/feat/tenant-isolation-phase-3
feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
2026-04-11 04:28:47 -04:00
chihlasm
893b8a5008 fix: tree_shares.account_id must come from tree owner, not the actor
- trees.py: change account_id=current_user.account_id →
  account_id=tree.account_id so super-admin cross-account shares land in
  the tree's tenant where RLS will see them.

- migration a05e1a1bea7c: fix backfill to join tree_shares → trees instead
  of tree_shares → users(created_by). Same logic: historical shares belong
  to the tree's tenant.

- test_tree_sharing.py: add test_share_account_id_matches_tree_not_actor
  to assert share.account_id == tree.account_id after POST /share; also
  add missing account_id to all direct TreeShare(...) constructors in
  existing tests.

- test_phase1_migrations.py: remove team_id= from TargetList constructor
  (column dropped in Phase 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
e05472615b feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
P3-A: Add account_id to audit_logs model + migration (backfill via user_id →
  users.account_id). log_audit() gains optional account_id param with fallback
  SELECT to avoid churn across 40 call sites.

P3-B: Add account_id to tree_shares model + migration (backfill via created_by
  → users.account_id). TreeShare constructor updated in trees.py.

P3-C: Enable RLS on 6 remaining tables: step_ratings, step_usage_log,
  target_lists, session_shares, audit_logs, tree_shares.

P3-D: Drop team_id from target_lists — endpoint, schema, and model now use
  account_id as the sole isolation key.

P3-E: Append Phase 3 RLS isolation tests for all 6 tables.

test_target_lists.py: fix cross-account test to use Account model (not Team)
and set account_id on new User.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
00fdd663bc Merge pull request #134 from resolutionflow/feat/tenant-isolation-phase-2
feat: Phase 2 tenant isolation — RLS on 11 session tables
2026-04-11 03:02:25 -04:00
chihlasm
8cf58add22 fix: use valid confidence_tier value in RLS test ai_sessions INSERT
'medium' is not a valid value for ck_ai_sessions_confidence_tier.
Valid values are 'guided' | 'exploring' | 'discovery'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 05:28:52 +00:00
chihlasm
6c231ef1c6 fix: use started_at (not created_at) in RLS test session INSERT
sessions table has started_at as the timestamp column, not created_at.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:53:35 +00:00
chihlasm
758cd61621 fix: propagate account_id through all write paths missing NOT NULL coverage
Service layer (production code):
- branch_manager: set account_id on SessionBranch (root + fork) and ForkPoint
  from session.account_id; load session in create_fork for this purpose
- handoff_manager: set account_id on SessionHandoff from session.account_id
- ai_suggestions endpoint: set account_id on AISuggestion from current_user
- steps endpoint (/feedback): set account_id on StepRating from current_user
- ratings endpoint: set account_id on StepRating from current_user

Test infrastructure:
- conftest.py: seed PLATFORM_ACCOUNT_ID (00000000-...-0001) account after
  Base.metadata.create_all so global categories and gallery items have a valid FK
- test_rls_isolation: add _ensure_rls_schema fixture that runs
  'alembic upgrade head' before module tests — previous function-scoped
  test_db fixtures drop the schema, leaving the RLS tests with no tables
- test_branding: create Account before User in helper functions
- test_admin_gallery: set account_id=PLATFORM_ACCOUNT_ID on Tree/ScriptTemplate
- test_public_templates: set account_id=PLATFORM_ACCOUNT_ID on Tree,
  ScriptTemplate, TreeCategory
- test_resolution_outputs: set account_id=session.account_id on
  SessionResolutionOutput
- test_analytics_phase5: set account_id on PsaPostLog
- test_draft_trees: replace account_id=None with PLATFORM_ACCOUNT_ID in
  migration default test (NOT NULL now enforced)
- test_maintenance_schedules: set account_id on other_tree
- test_save_session_as_tree: set account_id on all 5 Session() constructors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:24:36 +00:00
chihlasm
b9fcdd5d73 fix: use DATABASE_URL_SYNC (Railway reference var) as primary Alembic URL
DATABASE_URL_SYNC is now set as a Railway reference variable pointing to
${{pgvector.DATABASE_URL}}, which resolves to the correct postgres superuser
credentials per environment (production, PR preview, fresh DBs). This handles
the bootstrap case where resolutionflow_admin doesn't exist yet.

Falls back to ADMIN_DATABASE_URL (sync-converted) for local dev only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:42:07 +00:00
chihlasm
4273ed0e5c fix: use Railway native PG env vars for Alembic migrations
Prior approach (ADMIN_DATABASE_URL first) broke PR preview environments: fresh
Railway PostgreSQL instances have no resolutionflow_admin role yet, so the admin
URL fails before the create_db_roles migration can run (bootstrap deadlock).

New priority order in _alembic_sync_url():
1. PGHOST/PGUSER/PGPASSWORD/PGDATABASE — Railway auto-links these from the
   PostgreSQL service per-environment, giving correct superuser creds for every
   env including fresh PR preview DBs where no custom roles exist yet.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS, asyncpg→sync) — local
   dev and stable envs where the role already exists.
3. DATABASE_URL_SYNC — legacy fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:35:04 +00:00
chihlasm
0107d2d896 fix: use resolutionflow_admin for Alembic migrations (avoid postgres superuser)
DATABASE_URL_SYNC uses the postgres superuser whose password is unavailable
in Railway after Phase 1 switched runtime to the app role. resolutionflow_admin
(BYPASSRLS) is the correct role for migrations. Derive a psycopg2 sync URL from
ADMIN_DATABASE_URL; fall back to DATABASE_URL_SYNC for local dev environments
where ADMIN_DATABASE_URL is not set separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:23:32 +00:00
chihlasm
79ae34108a fix: add Alembic migrations step + RLS env vars to CI
- Run alembic upgrade head before tests so DB roles and RLS policies exist
- Set TEST_DB_NAME=resolutionflow_test so test_rls_isolation.py connects to
  the correct database (was defaulting to patherly_test which doesn't exist in CI)
- Set DB_APP_ROLE_PASSWORD so create_db_roles migration creates resolutionflow_app
  with a known password that the RLS isolation tests can connect with

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:55:10 +00:00
chihlasm
bd29f590a2 fix: set account_id on all Session constructors; fix 3 ESLint errors in CI
Backend: start_session, prepare_session, batch_launch_sessions all missing
account_id=current_user.account_id — Phase 1 NOT NULL constraint made these
500 in test suite (test_ratings.py fixture couldn't create sessions).

Frontend ESLint:
- TaskLane.tsx: suppress react-refresh/only-export-components for clearTaskState
- TeamSummary.tsx: init loading from isAccountOwner to avoid sync setState in effect
- ScriptBodyEditor.tsx: move lastValueRef.current assignment into useEffect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:41:42 +00:00
chihlasm
ce4cfc3240 fix: set account_id on PsaPostLog in psa_post_to_ticket (missed third write path); fix get_admin_db docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:12:45 +00:00
chihlasm
82ee177d9b fix: harden Phase 2 RLS tests — try/finally cleanup, assert guards, seed B-data for isolation checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:07:26 +00:00
chihlasm
ed8de92c52 test: add Phase 2 RLS isolation tests for 11 session tables (incl. step_library visibility regression)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:00:09 +00:00
chihlasm
5bd331ca92 fix: clarify step_library RLS comment; remove unused sqlalchemy import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:57:41 +00:00
chihlasm
87fac02e9b feat: migration — enable RLS on 11 Phase 2 session tables (tenant-only + step_library visibility policy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:55:25 +00:00
chihlasm
4f4bc435da docs: broaden admin_database docstring to cover non-admin BYPASSRLS use cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:51:53 +00:00
chihlasm
ac2b193909 fix: use get_admin_db in access_share to handle cross-tenant session reads (public shares)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:50:00 +00:00
chihlasm
b641ac6c55 fix: set account_id on session_supporting_data, session_resolution_outputs, maintenance_schedules, psa_post_log constructors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:44:17 +00:00
chihlasm
8292e6ec65 fix: handle non-default, no-team trees in global content migration
Migration 019 only backfills trees with team_id IS NOT NULL.
Migration 3a40fe11b427 only covered is_default=TRUE trees.
Trees with team_id=NULL and is_default=FALSE (e.g. inactive test trees,
pre-team-system content) fell through both passes and triggered the NULL
guard.

Add two new UPDATE steps after the is_default pass:
1. Assign remaining trees to their author's account (if author has one)
2. Final fallback to PLATFORM_ACCOUNT_ID for any still-NULL rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 05:21:26 +00:00
chihlasm
20bd428d83 Merge pull request #133 from resolutionflow/feat/tenant-isolation-phase-1
feat: Phase 1 tenant isolation — add account_id to all tenant tables
2026-04-10 00:57:53 -04:00
chihlasm
b9da0e7107 chore: resolve merge conflicts with main
- deps.py: keep require_tenant_context + require_admin_db (RLS deps);
  drop unused get_tenant_context stub from Phase 0
- categories.py: keep both PLATFORM_ACCOUNT_ID and tenant_filter imports
  (body uses both)
- tenant-isolation spec: keep main's resolved TargetList/teams audit answers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:57:39 +00:00
chihlasm
8f044849d4 fix: get_tree returns 404 (not 403) for inaccessible trees — don't leak resource existence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:17:31 +00:00
chihlasm
14304be383 fix: correct RLS test fixtures — tree_structure NOT NULL, tree_tags schema, session-scoped set_config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:15:41 +00:00
chihlasm
a5c5eb6cc3 fix: convert DATABASE_URL_SYNC from property to overridable field for Alembic superuser URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:03:32 +00:00
chihlasm
c4f919f3a5 feat: migration — enable RLS on trees, tags, categories, psa_connections, flow_proposals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:02:10 +00:00
chihlasm
8de6ee7aa4 feat: migration — create resolutionflow_app and resolutionflow_admin DB roles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:59:28 +00:00
chihlasm
83ad2e0661 feat: migrate admin endpoints to get_admin_db (BYPASSRLS) before RLS switch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:57:18 +00:00
chihlasm
ce4056c6b9 test: add failing RLS isolation tests (green after Task 10 migration + Task 11 URL switch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:54:42 +00:00
chihlasm
9d60b9a244 feat: apply require_tenant_context to all user-facing routers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:52:52 +00:00
chihlasm
df9ecf2d29 feat: add require_tenant_context and require_admin_db dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:50:59 +00:00
chihlasm
b0e5f12897 feat: register RLS transaction-begin listener on app engine at startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:49:49 +00:00
chihlasm
b4f8694f6b feat: add tenant_context module — ContextVar, transaction listener, tenant_filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:48:34 +00:00
chihlasm
6f1becf21f feat: add admin_engine and get_admin_db for BYPASSRLS admin endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:46:29 +00:00
chihlasm
acbfb3fb37 feat: add ADMIN_DATABASE_URL setting with fallback to DATABASE_URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:45:52 +00:00
chihlasm
a394a1d464 fix: replace account_id=None with PLATFORM_ACCOUNT_ID for global content
After migration 174f442795b7 enforces NOT NULL on account_id, all
platform/global content must use the sentinel platform account instead
of NULL. Three categories of fixes:

1. trees.py: is_default trees now get PLATFORM_ACCOUNT_ID (not None)
2. admin_categories.py: global category CRUD now uses PLATFORM_ACCOUNT_ID
3. categories.py, tags.py, step_categories.py: creation endpoints coerce
   None → PLATFORM_ACCOUNT_ID; IS NULL filter queries updated to
   == PLATFORM_ACCOUNT_ID (IS NULL queries returned empty after migration
   backfilled all global rows to the platform account)

Defines PLATFORM_ACCOUNT_ID constant in app/core/service_account.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:35:52 +00:00
chihlasm
d2ebc4f182 fix: correct tree tags subquery in template_trees migration
The INSERT into template_trees incorrectly referenced `tags` as a column
on the `trees` table. Tags are a relationship via the `tree_tag_assignments`
join table — there is no direct column. Migration was failing with:

  UndefinedColumn: column "tags" does not exist ... FROM trees

Fixed by replacing COALESCE(tags, '[]') with a correlated subquery that
aggregates tag names from tree_tag_assignments → tree_tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:30:05 +00:00
chihlasm
8bcf08ae06 fix: persist account ownership for script templates and generations 2026-04-09 17:18:38 +00:00
Claude
85575839f2 docs: update CHANGELOG with tenant isolation Phase 0 and security fixes
- Add Tenant Isolation Phase 0 (#132) — app-layer filtering, cross-tenant audit, UUID isolation
- Document CRITICAL copilot tree query isolation fix (#131)
- Add AI session search, analytics, category, PSA retry, and task lane fixes
- Note 404 (not 403) responses for cross-tenant access to avoid confirming resource existence

https://claude.ai/code/session_014EUBLi2jHrnzJupcetmdwV
2026-04-09 10:41:21 +00:00
chihlasm
478205c208 fix: platform account fallback for script_templates seeded without team/user
Migration 057 inserts 6 AD script templates with NULL team_id and NULL
created_by. Neither backfill path (created_by→users, team_id→team admin)
could attribute them to an account, causing the verify check to fail.

Fix: pre-create the platform sentinel account (ON CONFLICT DO NOTHING,
safe since 3a40fe11b427 also creates it idempotently) and add a final
fallback UPDATE assigning any remaining NULL script_templates to it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:41:00 +00:00
chihlasm
0f33feb6d6 fix: use correlated subquery in psa_post_log backfill to avoid invalid FROM-clause reference
PostgreSQL UPDATE...FROM does not allow the updated table to be
referenced inside the FROM clause's JOIN conditions. Replace the
LEFT JOIN psa_connections with a correlated subquery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:31:17 +00:00
chihlasm
034b858fc9 fix: add depends_on 067 to cc214c63aa30 to fix fresh-DB migration order
session_resolution_outputs is created in migration 067 (sequential branch
from 064). On fresh databases, Alembic could run cc214c63aa30 before 067,
causing "table does not exist" errors. depends_on ensures 067 always runs
first regardless of branch traversal order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:20:00 +00:00
chihlasm
b937cb41e4 fix: merge Phase 1 account_id chain with main head to resolve multiple-heads error
Combines the Phase 1 tenant isolation chain (064 → ... → 174f442795b7)
with the main sequential chain (064 → ... → 070) into a single Alembic
head (a9f3b2c1d4e5) so `alembic upgrade head` in the Dockerfile works
without ambiguity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:14:04 +00:00
chihlasm
0d475c71ed fix: correct Phase 1 down_revision — chain from 064 not b8d2f4a6c091
b8d2f4a6c091 was NOT the production head. The true head was 064
(064_normalize_script_builder_messages) via the chain:
b8d2f4a6c091 → f0aad74ea51b → 062 → 063 → 064

This caused 'multiple head revisions' on Railway deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 06:04:10 +00:00
chihlasm
417fa562ce fix: Task 9 migration — include tags in template_trees INSERT
The tags column was accidentally omitted from the is_default tree copy.
Now uses COALESCE(tags, '[]'::jsonb) to preserve source tree tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:34:59 +00:00
chihlasm
42937b24a4 feat: Phase 1 Group 9 — enforce NOT NULL on all account_id columns
All previously-nullable account_id columns are now NOT NULL.
tree_embeddings and feedback backfilled before constraint applied.
Global content assigned to platform sentinel account (00000000-...-0001)
in preceding migration.

Tables updated: users, trees, tree_categories, tree_tags,
step_categories, step_library, tree_embeddings, feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:34:32 +00:00
chihlasm
b4b8c67d3b feat: Phase 1 Group 10 — create global content tables and platform account
Creates template_trees and platform_steps (no account_id, no RLS).
Migrates is_default=TRUE trees and public steps into them.
Creates sentinel platform account (00000000-...-0001) for global
tree_categories, tree_tags, step_categories, step_library, and
is_default trees — clearing all NULL account_id rows in those tables
as prerequisite for Group 9 SET NOT NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:31:33 +00:00
chihlasm
d24da77604 feat: Phase 1 Group 8 — add account_id to target_lists (keep team_id)
Zero rows in production — this is a schema-only migration in practice.
team_id kept for app code compatibility. Drop deferred to later cleanup.
Backfill: team_id → team admin user → account_id; fallback: created_by.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:25:24 +00:00
chihlasm
857e782d14 feat: Phase 1 Group 7 — add account_id to script tables (keep team_id)
team_id is kept in all three tables — drop deferred until app code
is fully migrated off team_id references.

Tables: script_builder_sessions, script_templates, script_generations
Backfill: user_id/created_by → users.account_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:23:35 +00:00
chihlasm
086c4580f1 feat: Phase 1 Group 6 — add account_id to maintenance_schedules
Primary backfill: tree_id → trees.account_id
Fallback: created_by → users.account_id (for is_default tree rows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:20:56 +00:00
chihlasm
0d69474128 feat: Phase 1 Group 5 — add account_id to PSA and notification tables
psa_post_log: backfill via psa_connection, fallback to posted_by user
psa_member_mappings: backfill via psa_connection
notification_logs: backfill via notification_config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:19:12 +00:00
chihlasm
b5fdb488b3 feat: Phase 1 Group 4 — add account_id to user_folders and user_pinned_trees
Backfill: user_id → users.account_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:16:50 +00:00
chihlasm
de5ecf4fb2 feat: Phase 1 Group 3 — add account_id to step_ratings and step_usage_log
Backfill from rater/user's account_id (not the step's account_id).
This is an explicit design decision — step rating data is attributed
to the account that performed the rating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:15:10 +00:00
chihlasm
2779a41b94 feat: Phase 1 Group 2 — add account_id to AI branching tables
Tables: session_branches, session_handoffs, fork_points,
        ai_session_steps, ai_suggestions
Backfill: session_id → ai_sessions.account_id (all except
ai_suggestions which uses user_id → users.account_id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:12:18 +00:00
chihlasm
4666c4f6d2 feat: Phase 1 Group 1 — add account_id to core session tables
Migration sequence: add nullable → backfill via user_id/ai_session chain
→ verify zero NULLs → SET NOT NULL → CREATE INDEX.

Tables: sessions, attachments, session_supporting_data,
        session_resolution_outputs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:09:14 +00:00
chihlasm
2837c6e4cf docs: add Phase 1 tenant isolation schema migrations implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 04:58:24 +00:00
chihlasm
b3dba57bc5 feat: tenant isolation Phase 0 — app-layer filters, UUID audit, CI gate (#132)
* docs: add tenant data isolation design spec

Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add background job isolation policy to tenant isolation spec

Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add tenant isolation Phase 0 implementation plan

8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add tenant_filter() helper and get_tenant_context dependency

tenant_filter(model, account_id) is the canonical app-layer tenant
scoping expression. Every query on a tenant table must use it.
build_tree_access_filter and build_step_visibility_filter updated
to call tenant_filter() internally for the account_id match.

get_tenant_context is a FastAPI dependency that returns account_id
or raises 403 if the user has no account — prevents raw access to
current_user.account_id and centralises the null check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope analytics/flows/{tree_id} to requesting account

Any authenticated user could read flow analytics (session counts,
completion rates, CSAT) for any tree UUID. Now returns 404 if the
tree doesn't belong to the requesting account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope category tree_count to requesting account

tree_count on GET /categories/{id} was including trees from all
accounts, leaking cross-tenant row counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restrict AI session search to current user only

Search endpoint used OR(user_id, account_id), exposing other users'
problem_summary and problem_domain within the same account. Sessions
are user-scoped only — cross-user access requires explicit escalation
or sharing. List and search endpoints now behave consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add ownership check and 404 responses to ai-sessions endpoints

Cross-tenant isolation audit found:
- retry-psa-push had NO ownership check (CRITICAL) — any user could retry any session's PSA push
- save_task_lane used db.get() without ownership filter, returned 403 revealing existence
- get_session returned 403 instead of 404 for unauthorized access
- stream_documentation returned 403 instead of 404

All now use query-level user_id filtering and return 404 to avoid revealing existence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant session access

All session endpoints (get, update, complete, scratchpad, variables, export,
ticket-link) now return 404 instead of 403 when a user tries to access
another user's session. This prevents confirming existence of resources
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant tree access

get_tree and update_tree now return 404 when a user cannot access a tree
(private tree from another account). Prevents confirming resource existence
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant step access

get_step_or_404 now returns 404 when can_view_step or can_edit_step fails,
preventing confirmation of step existence across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant upload access

get_upload_url and delete_upload now return 404 when the upload belongs to
a different account/user, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant share access

revoke_share and create_share now return 404 when the caller is not the
owner, preventing resource existence confirmation across users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-team tree access in maintenance schedules

_get_tree_or_403 now returns 404 when the user's team does not match,
preventing confirmation of tree existence across teams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account tag access

get_tag now returns 404 for account-specific tags that belong to another
account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account step category access

get_step_category now returns 404 for account-specific categories that
belong to another account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add cross-tenant isolation tests for Task 6 UUID audit

Tests cover:
- Tree GET/PUT returns 404 for cross-account access
- Session GET returns 404 for cross-user access
- AI session GET returns 404 for cross-user access
- AI session retry-psa-push requires ownership
- Upload URL returns 404 for cross-account access
- Share revoke returns 404 for cross-user access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 (not 403) for get_documentation cross-user access; add missing Task 6 tests

get_documentation was revealing session existence via 403. Added pre-check
query filtering by session_id AND user_id before calling the engine.

Also add cross-tenant isolation tests for steps, tags, step_categories,
and maintenance_schedules endpoints fixed in Task 6 (TDD was skipped).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Task 6 quality review — rename helper, restore 403 for intra-account, add docs test

- Rename _get_tree_or_403 → _get_tree_or_404 in maintenance_schedules.py
  (function now raises 404, old name was misleading)
- Restore HTTP 403 for intra-account permission failures in update_tree:
  same-account users who can see a tree but can't edit it got 404 (wrong);
  only cross-account lookups should return 404 to avoid confirming existence
- Apply same 403/404 distinction to update_tree_visibility
- Add test: get_documentation must return 404 for cross-user session access
- Add comment documenting owner-only design for documentation endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Task 7+8 — TargetList audit, CI tenant-filter grep check

Task 7: TargetList dead code audit
- Found active code references in 12+ files across backend and frontend
  (full CRUD API + frontend page + MaintenanceScheduleSection + BatchLaunchModal)
- Decision: migrate to account_id in Phase 1 (cannot drop)
- DB row count not available from code-server — must verify from VPS SSH
  before Phase 1 migration
- Teams orphan check query documented; must run from VPS SSH before Phase 1
- Results documented in spec Section 9

Task 8: CI tenant-filter enforcement check (warn mode)
- Create backend/scripts/check_tenant_filters.py
  Scans endpoint and service files for select() on tenant tables without
  tenant_filter/account_id/user_id in surrounding context. Currently
  reports 109 warnings (Phase 1 backlog). Exits 0 (warn mode).
- Add Check tenant filter enforcement step to backend CI job
  Add --fail flag after Phase 1 backlog clears to make it blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: record Phase 0 audit results — 0 orphaned teams, 0 target_list rows

Both checks confirmed 2026-04-09 from production DB.
Phase 1 migration is safe to proceed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:42:19 -04:00
chihlasm
29a9573d6e fix: CRITICAL — scope copilot tree query to current account (#131)
* docs: add tenant data isolation design spec

Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add background job isolation policy to tenant isolation spec

Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add tenant isolation Phase 0 implementation plan

8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: CRITICAL — scope copilot tree query to current account

A user who knew another account's tree UUID could start a copilot
conversation, causing the tree's full node structure, names, and
descriptions to be sent to the AI as part of the system prompt.

Fix: add account_id (or is_default / visibility='public') filter to
the tree SELECT in copilot_service.start_conversation(). Returns 404
for inaccessible trees. Test added in test_tenant_isolation_p0.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:41:30 -04:00
chihlasm
56775eca04 docs: add tenant isolation Phase 0 implementation plan
8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:02:19 +00:00
chihlasm
82bb7967d8 docs: add background job isolation policy to tenant isolation spec
Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 02:38:55 +00:00
chihlasm
a7dff9e143 docs: add tenant data isolation design spec
Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 02:24:38 +00:00
Claude
ba0680ce06 docs: update CHANGELOG with image support, header actions, and design token normalization
- Added image support in Assistant Chat with S3 upload and vision integration
- Moved session lifecycle actions to header bar in AssistantChatPage
- Normalized design system tokens across FlowPilot, AssistantChat, ScriptBuilder
- Fixed 'sorry something went wrong' errors and image display in chat
- Fixed Task Lane stale data and chat ref invalidation race conditions

https://claude.ai/code/session_01LGJSDQqPi3sPWjC6vh9Uyj
2026-04-08 10:40:44 +00:00
chihlasm
290f2be2fd fix: resolve "sorry something went wrong" errors and show images in chat
Three fixes from beta tester session feedback:

1. MCP error handling (backend/app/services/assistant_chat_service.py)
   - The MCP Microsoft Learn integration was catching only BadRequestError.
     Any other error type (APIStatusError, APIConnectionError, timeout) from
     the external MCP server propagated as a 502, causing the generic error.
   - Now catches all Exception types when MCP is active and retries without
     MCP using the stable client.messages.create endpoint.

2. Frontend error UX (frontend/src/pages/AssistantChatPage.tsx)
   - catch {} was silently swallowing all errors and inserting a generic
     assistant message. Now: differentiates 429 (rate limit) vs 502/503
     (AI unavailable), removes the optimistic user message on failure,
     restores the failed message to the input so users can retry without
     retyping, and logs errors to console for debugging.

3. Image attachments visible in chat (frontend/src/components/assistant/ChatMessage.tsx)
   - Uploaded images were sent to the AI correctly but never shown in the
     chat thread. Now captures preview URLs before clearing pendingUploads
     and renders thumbnails above the user bubble, clickable to full size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:16 +00:00
chihlasm
e8e12cc7e5 fix: move session lifecycle actions to header bar in AssistantChatPage
- Add persistent session header with title, status badge, Resolve,
  Escalate, and Update Ticket/Share Update buttons — mirrors
  FlowPilotSessionPage pattern exactly
- Update Ticket label when psa_ticket_id present, Share Update otherwise
- Full mobile support via ⋯ overflow menu (Resolve, Escalate, Update, Pause)
- Strip _(not yet completed)_ markers from stored conversation_messages
  in unified_chat_service to prevent stale task lane items from prior
  turns leaking into new sessions via the AI's re-include instruction
- Add currentChatRef guard to handleResumeNew (was missing unlike handleSend)
- Remove Update/Conclude from chatbar — toolbar is now input utilities only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 06:31:24 +00:00
chihlasm
bf45322c46 Merge pull request #126 from resolutionflow/refactor/dashboard-design-critique
refactor: normalize FlowPilot/Assistant/ScriptBuilder to design system tokens
2026-04-06 20:23:50 -04:00
Michael Chihlas
f45b045943 refactor: resolve merge conflicts — combine main improvements with token normalization
- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry
- ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text
  for filename labels; use neutral ghost style for Save button in ScriptCodeBlock;
  use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:23:36 -04:00
Michael Chihlas
cef853d7ea refactor: normalize FlowPilot/Assistant/ScriptBuilder to design system tokens
Replace hardcoded Tailwind color utilities with semantic CSS variable tokens
across 31 files in the FlowPilot, Assistant Chat, and Script Builder feature
communities — the areas graphify identified as design-system-free.

- text-blue-400 → text-accent, bg-blue-500/10 → bg-accent-dim, border-blue-500/20 → border-accent/20
- text-amber-400 → text-warning, bg-amber-400/10 → bg-warning-dim, border-l-amber-500 → border-l-warning
- text-rose-400/500 → text-danger, bg-rose-500/10 → bg-danger-dim
- text-emerald-400 → text-success, bg-emerald-500/10 → bg-success-dim, border-l-emerald-500 → border-l-success
- bg-white/[0.08] → bg-elevated (opacity hack → semantic surface token)
- bg-gradient-to-r from-blue-500 to-blue-400 → bg-accent (no gradient surfaces)
- bg-[#60a5fa] → bg-accent (hard-coded hex removed)

Also adds graphify-out/ to .gitignore.

Theme resilience: accent color has changed twice in 5 weeks. Semantic tokens
mean the next change is a 1-line edit in index.css, not 110 grep-and-replace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:20:07 -04:00
chihlasm
87cf874199 fix: invalidate currentChatRef before await in handleNewChat and handleResumeNew
The previous fix (990f044) moved state clears before the createChatSession
await but left currentChatRef.current pointing at the old session during the
entire network call. Any in-flight handleSend/handleTaskSubmit for the old
session would pass the guard (oldId === oldId) and re-apply stale task lane
data to the new empty session.

Setting currentChatRef.current = null before the await ensures in-flight
handlers from the previous session see a mismatch and bail — matching the
same pattern already used correctly in selectChat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:56:10 +00:00
chihlasm
2b53315cc9 Merge pull request #125 from resolutionflow/fix/task-lane-partial-submit
fix: resolve task lane stale state, partial submit, and closure bugs
2026-04-06 16:31:41 -04:00
chihlasm
1811889ed9 chore: update docs and redesign landing page hero
- CLAUDE.md: correct Docker container names, update migration format
  docs (hash IDs now default), fix Node path in Lesson 63, update
  design system values to electric blue accent, add retracted lessons
  note, add GitNexus section
- .gitignore: add .gitnexus
- Landing page: replace animated chat preview with ticket-comparison
  hero layout; remove backdrop-filter from scrolled nav (aligns with
  design system); clean up removed chat animation CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:17:47 +00:00
chihlasm
990f04489f fix: prevent TaskLane showing stale data when starting new chat
Three race conditions in AssistantChatPage:

1. handleNewChat cleared showTaskLane/activeQuestions/activeActions
   AFTER the createChatSession await — old lane was visible during
   the network call. Moved clears before the await.

2. handleResumeNew never cleared old TaskLane state at all. Added
   upfront clears before the first await.

3. handleSend and handleTaskSubmit had no stale-session guard. If
   the user switched chats while sendChatMessage was in flight, the
   response would set showTaskLane on the wrong session. Added
   sentForChatId snapshot + currentChatRef guard (same pattern
   already used in selectChat).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:17:39 +00:00
chihlasm
ba815d3ee5 Merge remote-tracking branch 'origin/main' into fix/task-lane-partial-submit 2026-04-06 20:14:45 +00:00
chihlasm
8bd395a0c7 fix: resolve task lane stale state, partial submit, and closure bugs
- Import and call clearTaskState before updating questions/actions in
  handleSend and handleTaskSubmit so new AI tasks always replace stale
  sessionStorage cache instead of being overridden by it
- Include pending (not yet completed) tasks in the AI message on partial
  submit so the AI knows which tasks were left unanswered
- Fix stale closure in TaskLane saveTaskLane useEffect — use refs for
  questions/actions so the debounced backend save always uses current values
- Add responses field to pending_task_lane TypeScript type, removing the
  unsafe double-cast in selectChat
- Instruct the AI to re-surface incomplete tasks unless ≥75% confident
  the information is no longer needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:53:48 +00:00
Claude
7198c165b2 docs: update CHANGELOG with session documentation overhaul and client communications
Added entries for:
- Session documentation overhaul with reformatted PSA notes, decimal hour display,
  and follow-up recommendations
- Client communication improvements with request_info audience type
- PSA documentation formatting enhancements
- Status update generation improvements
- Option label resolution fix

https://claude.ai/code/session_01GpyJYk4F3eGiJXwsgycChK
2026-04-06 10:35:01 +00:00
chihlasm
58fe3574bf docs: resolve all contract decisions from codex readiness review
Addresses every Red and Yellow item from the codex review:
- Canonical handoff: ResolutionOutputGenerator is the source of truth
- AI vs manual authority: manual edits win, AI never overwrites
- evidence_items: full-list replacement, frontend is merge authority
- TaskLane persistence: lifted into hook, StepsPanel is presentation-only
- Quick replies: immediate-send, full-stack contract change
- issue_category + asset_name: free text in v1
- Adds 5 implementation guardrails and Phase 2 gate for triage extraction
- Execution order updated to 37 steps with persistence extraction step

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:18:31 +00:00
266 changed files with 27051 additions and 2195 deletions

154
.gitea/workflows/ci.yml Normal file
View 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

View 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

View 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"

View File

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

5
.gitignore vendored
View File

@@ -233,3 +233,8 @@ package.json
package-lock.json
.worktrees/
.gstack/
.gitnexus
# graphify knowledge graph outputs
graphify-out/
.graphify_python

View File

@@ -8,19 +8,43 @@ All notable changes to ResolutionFlow are documented here.
- Tree Templates + Import/Export marketplace (#66)
- Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
### Changed
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
- **Account settings page** — audit fixes for improved consistency and usability
- **PSA documentation formatting** — removed duplicate timing blocks and AI confidence sections; added client-facing communication context guidance
- **Status update generation** — fixed option label lookup to use human-readable labels instead of machine values
- **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot
- **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
### Fixed
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
- Dark text rendering on blue accent step-number badges across all flow types
- Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation
- Stale async results in Assistant Chat (selectChat) no longer clobber new session task lane
- Sentry DSN hardcoded fallback removed — now uses environment variable only
- Option label resolution in status update context generation
- "Sorry something went wrong" errors in chat when rendering unsupported message types
- Task Lane stale data when creating new chat or resuming from concluded session
- Chat ref invalidation race condition between handleNewChat and async data loads
- Images now properly display in chat message history instead of blank placeholders
---

114
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** March 27, 2026
> **Last Updated:** April 6, 2026
---
@@ -16,7 +16,8 @@
| Context | Name Used |
|---------|-----------|
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
| Repository / directory / database | `patherly` (internal name) |
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
| Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
@@ -44,7 +45,7 @@
- **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional
- **Database:** PostgreSQL with Docker, 98 migrations
- **Database:** PostgreSQL with Docker, 101 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress
@@ -96,7 +97,7 @@ patherly/
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── alembic/ # Database migrations (001-029+)
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
│ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
├── frontend/
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
## Development Commands
```powershell
# Start PostgreSQL
docker start patherly_postgres
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker start resolutionflow_postgres
# Backend (from backend/)
source venv/bin/activate # Linux/Mac
@@ -203,30 +204,27 @@ npm run dev
pytest --override-ini="addopts="
# First time only: create test database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build
# Database migrations
cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069).
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1
# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py).
# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations.
alembic revision --autogenerate -m "Description"
# Sequential 3-digit IDs (001070) were used historically. New migrations use Alembic's default hex hash IDs.
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
# Access PostgreSQL
docker exec -it patherly_postgres psql -U postgres -d patherly
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# Seed data
cd backend && pip install httpx && python -m scripts.seed_trees
# 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
# CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
# Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
# `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
```
### URLs
@@ -292,7 +290,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="/home/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
@@ -332,7 +330,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
@@ -344,6 +342,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
*(Lessons 8991 were retracted.)*
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
@@ -353,7 +352,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links).
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
@@ -375,6 +374,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
**110. Backfill migrations for `account_id` require a service-code audit:** When a migration adds `account_id` to an existing model via backfill (nullable → backfill → NOT NULL), grep for ALL `ModelClass(` instantiation sites in service code and verify `account_id=` is passed. SQLAlchemy accepts `None` silently with no warning; Phase 4 RLS WITH CHECK only surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`. Fixed example: `AISessionStep` — all 5 creation sites in `flowpilot_engine.py` were missing `account_id` until April 2026.
**111. Global Axios interceptor fires before component `.catch()` — fix optional-data endpoints at the source:** The global 5xx handler in `client.ts` fires for ALL non-401 5xx responses, even when a component does `.catch(() => {})`. If an endpoint returns optional UI data (e.g., board filters, PSA config), return `[]` / `{}` on provider failure rather than raising 502. Silencing the error in the component is not enough — the toast appears anyway. See `list_boards` in `integrations.py` for the fixed pattern.
## RBAC & Permissions
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
@@ -390,16 +399,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode planned.
- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`)
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color.
- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity
- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`)
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
---
@@ -444,6 +453,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- Large features: commit per phase with `npm run build` validation
- **Remote is Gitea, not GitHub directly:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Gitea auto-mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
### After Completing Work
@@ -491,7 +501,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
- Auto-deploys on push to `main`
- Auto-deploys via: push to Gitea → Gitea mirrors to GitHub → Railway watches GitHub `main` and deploys
- PR environments auto-created (need manual domain generation in Railway dashboard)
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
@@ -518,3 +528,43 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow**. 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.
## When to Use It
**Use GitNexus when:**
- Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks
- Renaming anything used across multiple files
- Tracing an unfamiliar bug through a call chain you haven't read
- Assessing whether a refactor is safe before starting
**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
## Useful Tools
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` |
| `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` |
| `impact` | Blast radius check before editing a shared symbol | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
## Keeping the Index Fresh
A PostToolUse hook re-indexes automatically after `git commit`. To manually refresh:
```bash
npx gitnexus analyze
```
<!-- gitnexus:end -->

View File

@@ -2,7 +2,7 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** March 23, 2026
> **Last Updated:** April 12, 2026
---
@@ -163,6 +163,13 @@
- SQL wildcard escaping in tag search
- PSA credentials encrypted at rest (Fernet)
### Tenant Isolation (Phases 1-4 Complete)
- PostgreSQL RLS enabled across tenant-scoped tables in phased rollout
- `account_id` propagation completed across core content, sessions, analytics, notifications, shares, and remaining Phase 4 tables
- Global platform tables correctly excluded from tenant RLS where they have no `account_id` (`script_categories`, `platform_steps`, `template_trees`)
- Runtime bootstrap paths updated to use BYPASSRLS/admin sessions where needed (auth/user mutations, startup service account, background jobs, seed scripts)
- Preview Railway backend and frontend deployments green for PR 136 after the Phase 4 fixes
### Copilot-First Dashboard (March 2026)
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)

View File

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

View 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")

View 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")

View 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")

View File

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

View File

@@ -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'])

View File

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

View File

@@ -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')

View File

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

View File

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

View File

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

View 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")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')

View File

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

View File

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

View 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")

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.core.tenant_context import set_current_account_id, clear_current_account_id
from app.core.admin_database import get_admin_db # noqa: F401 — re-exported for use in endpoints
# Routes that are allowed even when must_change_password is True
_PASSWORD_CHANGE_ALLOWLIST = {
@@ -22,10 +24,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
token: Annotated[str, Depends(oauth2_scheme)]
) -> 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(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
@@ -75,10 +81,14 @@ async def get_refresh_token_payload(
async def get_current_active_user(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> User:
"""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:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -190,3 +200,44 @@ async def get_plan_limits_for_user(
"""Get plan limits for the current user's account."""
from app.core.subscriptions import get_user_plan_limits
return await get_user_plan_limits(current_user.account_id, db)
async def require_tenant_context(
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Set per-request tenant context for RLS.
Raises 403 if the authenticated user has no account_id — never falls back
to PLATFORM_ACCOUNT_ID (that would grant platform-scope access to a
malformed account).
Sets the ContextVar that the SQLAlchemy transaction-begin listener reads to
issue set_config('app.current_account_id', …, true) on every transaction.
Applied to every user-facing router. NOT applied to /admin/* routers or
public endpoints (auth, shared, webhooks).
"""
if current_user.account_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account required",
)
token = set_current_account_id(current_user.account_id)
try:
yield
finally:
clear_current_account_id(token)
async def require_admin_db(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
) -> AsyncSession:
"""Return a BYPASSRLS admin DB session after verifying super_admin role.
Use on /admin/* endpoints that query RLS-protected tables. Replaces
Depends(get_db) on the db parameter of those endpoints.
The current_user dep is still declared separately on the endpoint if
the user object is needed in the handler.
"""
return db

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit
from app.models.refresh_token import RefreshToken
@@ -148,7 +149,7 @@ async def update_member_role(
@router.post("/me/transfer-ownership", response_model=AccountResponse)
async def transfer_ownership(
data: TransferOwnershipRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Transfer account ownership to another member (owner only)."""
@@ -377,7 +378,7 @@ async def list_invites(
@router.post("/me/leave")
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)]
):
"""Leave the current account (non-owners only). Creates a personal account."""
@@ -423,7 +424,7 @@ class DeleteAccountRequest(BaseModel):
@router.delete("/me")
async def delete_account(
data: DeleteAccountRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Delete the current account and soft-delete the user (owner only, no other members)."""

View File

@@ -5,10 +5,10 @@ from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload, aliased
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
from app.models.account_invite import AccountInvite
from app.models.tree import Tree
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
from app.schemas.admin import (
MoveUserAccount,
AdminUserCreate,
AdminUserCreateResponse,
AdminPasswordReset,
AdminPasswordResetResponse,
HardDeleteCheckResponse,
AdminUserListItem,
AdminUserListResponse,
AdminAccountMember,
AdminAccountListItem,
AdminAccountListResponse,
AdminAccountOwnerSummary,
AdminAccountSubscriptionSummary,
AdminAccountUsageSummary,
AdminAccountDetailResponse,
AdminAccountInviteSummary,
AdminAccountCreate,
AdminAccountUpdate,
)
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import (
UserDetailResponse, AccountSummary, SubscriptionSummary,
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
)
from app.api.deps import require_admin
from app.core.subscriptions import get_account_usage
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=list[UserResponse])
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
page: Optional[int] = Query(None, ge=1),
size: Optional[int] = Query(None, ge=1, le=100),
search: Optional[str] = Query(None, description="Search by user or account fields"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
@@ -46,23 +69,240 @@ async def list_users(
account_id: Optional[UUID] = Query(None, description="Filter by account"),
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
):
"""List all users (super admin only)."""
query = select(User)
"""List users for super admin global people search."""
resolved_limit = size or limit
resolved_skip = skip
current_page = 1
if page is not None:
resolved_skip = (page - 1) * resolved_limit
current_page = page
elif resolved_limit > 0:
current_page = (resolved_skip // resolved_limit) + 1
count_query = (
select(func.count())
.select_from(User)
.outerjoin(Account, User.account_id == Account.id)
)
query = (
select(
User,
Account.name.label("account_name"),
Account.display_code.label("account_display_code"),
)
.outerjoin(Account, User.account_id == Account.id)
)
if not include_archived:
query = query.where(User.deleted_at.is_(None))
count_query = count_query.where(User.deleted_at.is_(None))
if is_active is not None:
query = query.where(User.is_active == is_active)
count_query = count_query.where(User.is_active == is_active)
if role:
query = query.where(User.role == role)
count_query = count_query.where(User.role == role)
if account_id:
query = query.where(User.account_id == account_id)
count_query = count_query.where(User.account_id == account_id)
if search:
search_term = f"%{search.strip()}%"
search_filter = or_(
User.name.ilike(search_term),
User.email.ilike(search_term),
Account.name.ilike(search_term),
Account.display_code.ilike(search_term),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
result = await db.execute(query)
users = result.scalars().all()
return users
rows = result.all()
items = [
AdminUserListItem(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
is_super_admin=user.is_super_admin,
is_active=user.is_active,
account_id=user.account_id,
account_role=user.account_role,
account_name=account_name,
account_display_code=account_display_code,
created_at=user.created_at,
last_login=user.last_login,
deleted_at=user.deleted_at,
)
for user, account_name, account_display_code in rows
]
return AdminUserListResponse(
items=items,
total=total,
page=current_page,
per_page=resolved_limit,
)
@router.get("/accounts", response_model=AdminAccountListResponse)
async def list_accounts(
db: Annotated[AsyncSession, Depends(get_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:
@@ -71,10 +311,196 @@ def _generate_display_code() -> str:
return ''.join(secrets.choice(chars) for _ in range(8))
async def _generate_unique_display_code(db: AsyncSession) -> str:
"""Generate a unique display code for a new account."""
while True:
display_code = _generate_display_code()
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
if existing.scalar_one_or_none() is None:
return display_code
async def _get_account_detail_payload(
account_id: UUID,
db: AsyncSession,
include_archived: bool = False,
) -> AdminAccountDetailResponse:
owner_user = aliased(User)
result = await db.execute(
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
.where(Account.id == account_id)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_result = await db.execute(members_query)
members = [
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
for member in members_result.scalars().all()
]
invites_result = await db.execute(
select(AccountInvite)
.where(AccountInvite.account_id == account_id)
.order_by(AccountInvite.created_at.desc())
)
invites = [
AdminAccountInviteSummary(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
created_at=invite.created_at,
used_at=invite.used_at,
)
for invite in invites_result.scalars().all()
if invite.used_at is None
]
usage = await get_account_usage(account_id, db)
return AdminAccountDetailResponse(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
),
member_count=len(members),
active_member_count=sum(1 for member in members if member.is_active),
pending_invite_count=len(invites),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members,
invites=invites,
)
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_account(
data: AdminAccountCreate,
db: Annotated[AsyncSession, Depends(get_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)
async def create_user(
data: AdminUserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new user with a temporary password (super admin only).
@@ -199,7 +625,7 @@ async def create_user(
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Get enriched user details (super admin only)."""
@@ -317,7 +743,7 @@ async def get_user(
async def update_user_role(
user_id: UUID,
role_data: RoleUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change user role (super admin only)."""
@@ -349,7 +775,7 @@ async def update_user_role(
async def update_account_role(
user_id: UUID,
data: AccountRoleUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change a user's account role (super admin only)."""
@@ -375,7 +801,7 @@ async def update_account_role(
async def update_super_admin_status(
user_id: UUID,
data: dict,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Promote or demote a user to/from super admin (super admin only)."""
@@ -414,7 +840,7 @@ async def update_super_admin_status(
@router.put("/users/{user_id}/deactivate", response_model=UserResponse)
async def deactivate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Deactivate a user account (super admin only)."""
@@ -443,7 +869,7 @@ async def deactivate_user(
@router.put("/users/{user_id}/activate", response_model=UserResponse)
async def activate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Reactivate a user account (super admin only)."""
@@ -467,7 +893,7 @@ async def activate_user(
async def move_user_account(
user_id: UUID,
data: MoveUserAccount,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Move a user to a different account (super admin only)."""
@@ -516,11 +942,33 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
return user, subscription
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
"""Helper to load account and its subscription."""
account_result = await db.execute(select(Account).where(Account.id == account_id))
account = account_result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
sub_result = await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
subscription = Subscription(
account_id=account.id,
plan="free",
status="active",
)
db.add(subscription)
await db.flush()
return account, subscription
@router.put("/users/{user_id}/subscription/plan")
async def update_user_plan(
user_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
@@ -535,11 +983,36 @@ async def update_user_plan(
return {"plan": subscription.plan, "status": subscription.status}
@router.put("/accounts/{account_id}/subscription/plan")
async def update_account_plan(
account_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_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")
async def extend_user_trial(
user_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for a user's subscription (super admin only)."""
@@ -565,11 +1038,48 @@ async def extend_user_trial(
"current_period_end": subscription.current_period_end}
@router.put("/accounts/{account_id}/subscription/extend-trial")
async def extend_account_trial(
account_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_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)
async def admin_reset_password(
user_id: UUID,
data: AdminPasswordReset,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Admin-triggered password reset (super admin only).
@@ -640,7 +1150,7 @@ async def admin_reset_password(
@router.put("/users/{user_id}/archive", response_model=UserResponse)
async def archive_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive (soft delete) a user (super admin only)."""
@@ -675,7 +1185,7 @@ async def archive_user(
@router.put("/users/{user_id}/restore", response_model=UserResponse)
async def restore_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Restore an archived user (super admin only)."""
@@ -700,7 +1210,7 @@ async def restore_user(
@router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse)
async def hard_delete_check(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
@@ -773,7 +1283,7 @@ async def hard_delete_check(
@router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT)
async def hard_delete_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a user (super admin only). User must be archived first."""
@@ -833,7 +1343,7 @@ async def hard_delete_user(
@router.post("/invites", status_code=status.HTTP_201_CREATED)
async def admin_create_invite(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Quick-invite a user to an account (super admin only).

View File

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

View File

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

View File

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

View File

@@ -519,11 +519,15 @@ async def save_task_lane(
_: None = Depends(require_engineer_or_admin),
):
"""Save the current task lane state including user's in-progress responses."""
session = await db.get(AISession, session_id)
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
payload = {
"questions": [q.model_dump() for q in body.questions],
@@ -762,13 +766,13 @@ async def search_sessions(
limit: int = Query(5, ge=1, le=20),
):
"""Search AI sessions by content using full-text search. Used by Command Palette."""
# Sessions are user-scoped. The list endpoint uses user_id only;
# search must be consistent. Cross-user access requires explicit
# escalation or session sharing — not ambient account membership.
result = await db.execute(
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.account_id == current_user.account_id,
),
AISession.user_id == current_user.id,
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
)
.params(q=q)
@@ -901,7 +905,7 @@ async def get_session(
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return _build_session_detail(session)
@@ -917,6 +921,18 @@ async def get_documentation(
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get auto-generated documentation for a session."""
# Verify session ownership — owner only. Documentation endpoints require direct
# ownership; escalated_to_id / picked_up_by handlers use get_session (read-only).
# This is consistent with stream_documentation which has the same owner-only check.
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Session not found")
try:
return await flowpilot_engine.get_session_documentation(
session_id=session_id,
@@ -942,13 +958,14 @@ async def stream_documentation(
# Verify session ownership
result = await db.execute(
select(AISession).where(AISession.id == session_id)
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
async def event_generator():
try:
@@ -1043,6 +1060,19 @@ async def retry_psa_push_endpoint(
"""Manually retry a failed PSA documentation push."""
from app.models.psa_post_log import PsaPostLog
# Verify the session belongs to the current user
session_result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not session_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Find the latest failed push log for this session
result = await db.execute(
select(PsaPostLog)

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from app.core.config import settings
from app.core.settings_manager import SettingsManager
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.rate_limit import limiter
from app.core.security import (
verify_password,
@@ -67,7 +67,7 @@ def _generate_display_code() -> str:
async def register(
request: Request,
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Register a new user.
@@ -232,7 +232,7 @@ async def register(
async def login(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Login and get access token."""
# Find user by email
@@ -270,7 +270,7 @@ async def login(
async def login_json(
request: Request,
credentials: UserLogin,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Login with JSON body (alternative to form data)."""
result = await db.execute(select(User).where(User.email == credentials.email))
@@ -304,7 +304,7 @@ async def login_json(
async def refresh_token(
request: Request,
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)."""
user_id = payload.get("sub")
@@ -368,7 +368,7 @@ async def get_me(
async def update_me(
data: UserUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Update current user's profile (name, email)."""
update_fields = data.model_fields_set - {"current_password"}
@@ -415,7 +415,7 @@ async def update_me(
@router.post("/logout")
async def logout(
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."""
jti = payload.get("jti")
@@ -438,7 +438,7 @@ async def change_password(
request: Request,
data: ChangePasswordRequest,
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."""
if not verify_password(data.current_password, current_user.password_hash):
@@ -478,7 +478,7 @@ async def change_password(
async def forgot_password(
request: Request,
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)."""
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)
async def verify_reset_token(
data: VerifyResetTokenRequest,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Verify a password reset token is valid."""
payload = decode_token(data.token)
@@ -544,7 +544,7 @@ async def verify_reset_token(
async def reset_password(
request: Request,
data: ResetPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Reset password using a valid reset token."""
payload = decode_token(data.token)
@@ -611,7 +611,7 @@ async def reset_password(
@router.get("/email/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."""
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -623,7 +623,7 @@ async def get_verification_status(
async def send_verification_email(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Send an email verification link to the current user."""
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -662,7 +662,7 @@ async def send_verification_email(
@router.post("/email/verify")
async def verify_email(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Verify an email using a token. Public endpoint."""
token = data.get("token")

View File

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

View 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()

View File

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

View File

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

View 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")

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection
from app.models.session import Session
@@ -98,7 +99,7 @@ async def get_onboarding_status(
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
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)],
) -> OnboardingStatus:
"""Dismiss the onboarding checklist for the current user."""

View File

@@ -91,6 +91,7 @@ async def submit_step_feedback(
new_rating = StepRating(
step_id=step_id,
user_id=current_user.id,
account_id=current_user.account_id,
session_id=session_uuid,
was_helpful=data.was_helpful,
# rating is nullable now — thumbs-only mode

View File

@@ -85,6 +85,7 @@ async def create_session(
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
language=data.language,
)

View File

@@ -197,6 +197,7 @@ async def create_template(
template = ScriptTemplate(
category_id=data.category_id,
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=data.name,
slug=slug,
@@ -364,6 +365,7 @@ async def generate_script(
generation = ScriptGeneration(
template_id=template.id,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
session_id=data.session_id,
ai_session_id=data.ai_session_id,

View File

@@ -143,8 +143,8 @@ async def get_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
return session
@@ -196,6 +196,7 @@ async def start_session(
new_session = Session(
tree_id=tree.id,
user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -234,8 +235,8 @@ async def update_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -281,8 +282,8 @@ async def complete_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -319,8 +320,8 @@ async def update_scratchpad(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
session.scratchpad = data.scratchpad
@@ -348,8 +349,8 @@ async def update_session_variables(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -387,8 +388,8 @@ async def export_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# PDF export — separate path with binary response
@@ -693,6 +694,7 @@ async def prepare_session(
new_session = Session(
tree_id=tree.id,
user_id=data.assigned_to_id or current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -770,6 +772,7 @@ async def batch_launch_sessions(
session = Session(
tree_id=tree.id,
user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -830,8 +833,8 @@ async def link_ticket(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session",
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Unlink
@@ -1102,6 +1105,7 @@ async def psa_post_to_ticket(
# Log to audit trail
log_entry = PsaPostLog(
session_id=session.id,
account_id=session.account_id,
psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id,
note_type=data.note_type,

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.exc import IntegrityError
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.session import Session
from app.models.session_share import SessionShare, SessionShareView
from app.models.user import User
@@ -72,8 +73,8 @@ async def create_share(
if session.user_id != current_user.id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the session owner can create share links"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Require account_id for account-scoped shares
@@ -170,8 +171,8 @@ async def revoke_share(
if share.created_by != current_user.id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the share creator can revoke it"
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found"
)
share.is_active = False
@@ -210,7 +211,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
async def access_share(
share_token: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Access a shared session via share token.

View File

@@ -16,6 +16,7 @@ from app.schemas.step_category import (
)
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_step_category, can_create_step_category
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/step-categories", tags=["step-categories"])
@@ -44,13 +45,13 @@ async def list_step_categories(
elif current_user.account_id:
query = query.where(
or_(
StepCategory.account_id.is_(None), # Global
StepCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
StepCategory.account_id == current_user.account_id # User's account
)
)
else:
# User has no account, only show global categories
query = query.where(StepCategory.account_id.is_(None))
query = query.where(StepCategory.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(StepCategory.display_order, StepCategory.name)
@@ -94,8 +95,8 @@ async def get_step_category(
# Check access: global categories visible to all, account categories only to account members
if category.account_id and category.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this step category"
status_code=status.HTTP_404_NOT_FOUND,
detail="Step category not found"
)
return StepCategoryResponse(
@@ -155,7 +156,7 @@ async def create_step_category(
name=category_data.name,
slug=slug,
description=category_data.description,
account_id=category_data.account_id,
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
display_order=max_order + 1,
created_by=current_user.id
)

View File

@@ -47,10 +47,10 @@ async def get_step_or_404(
raise HTTPException(status_code=404, detail="Step not found")
if check_view and not can_view_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to view this step")
raise HTTPException(status_code=404, detail="Step not found")
if check_edit and not can_edit_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
raise HTTPException(status_code=404, detail="Step not found")
return step
@@ -460,6 +460,7 @@ async def rate_step(
rating = StepRating(
step_id=step_id,
user_id=current_user.id,
account_id=current_user.account_id,
rating=rating_data.rating,
was_helpful=rating_data.was_helpful,
review_text=rating_data.review_text,

View File

@@ -103,6 +103,7 @@ async def create_supporting_data(
item = SessionSupportingData(
session_id=session_id,
account_id=session.account_id,
label=data.label,
data_type=data.data_type,
content=data.content,

View File

@@ -12,6 +12,7 @@ from app.models.user import User
from app.schemas.tag import TagCreate, TagResponse, TagListResponse, TagAssignment
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_tree_tags, can_create_tag
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/tags", tags=["tags"])
@@ -33,13 +34,13 @@ async def list_tags(
if include_account and current_user.account_id:
query = query.where(
or_(
TreeTag.account_id.is_(None), # Global
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global
TreeTag.account_id == current_user.account_id # User's account
)
)
else:
# Only show global tags
query = query.where(TreeTag.account_id.is_(None))
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name)
@@ -71,12 +72,12 @@ async def search_tags(
if include_account and current_user.account_id:
query = query.where(
or_(
TreeTag.account_id.is_(None),
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
TreeTag.account_id == current_user.account_id
)
)
else:
query = query.where(TreeTag.account_id.is_(None))
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name).limit(limit)
@@ -105,8 +106,8 @@ async def get_tag(
# Check access: global tags visible to all, account tags only to account members
if tag.account_id and tag.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tag"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
return TagResponse.model_validate(tag)
@@ -147,7 +148,7 @@ async def create_tag(
new_tag = TreeTag(
name=tag_data.name,
slug=slug,
account_id=tag_data.account_id,
account_id=tag_data.account_id if tag_data.account_id is not None else PLATFORM_ACCOUNT_ID,
created_by=current_user.id
)
db.add(new_tag)
@@ -206,7 +207,7 @@ async def add_tags_to_tree(
tag_query = select(TreeTag).where(
TreeTag.slug == slug,
or_(
TreeTag.account_id.is_(None), # Global tag
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global tag
TreeTag.account_id == tag_account_id # Account tag
)
)
@@ -340,7 +341,7 @@ async def replace_tree_tags(
tag_query = select(TreeTag).where(
TreeTag.slug == slug,
or_(
TreeTag.account_id.is_(None),
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
TreeTag.account_id == tag_account_id
)
)

View File

@@ -18,12 +18,10 @@ async def list_target_lists(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all target lists for the current user's team."""
if not current_user.team_id:
return []
"""List all target lists for the current user's account."""
result = await db.execute(
select(TargetList)
.where(TargetList.team_id == current_user.team_id)
.where(TargetList.account_id == current_user.account_id)
.order_by(TargetList.name)
)
return result.scalars().all()
@@ -36,11 +34,9 @@ async def create_target_list(
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a new target list for the current team."""
if not current_user.team_id:
raise HTTPException(status_code=400, detail="User must belong to a team")
"""Create a new target list for the current account."""
target_list = TargetList(
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=data.name,
description=data.description,
@@ -61,7 +57,7 @@ async def get_target_list(
result = await db.execute(
select(TargetList).where(
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()
@@ -81,7 +77,7 @@ async def update_target_list(
result = await db.execute(
select(TargetList).where(
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()
@@ -91,7 +87,7 @@ async def update_target_list(
if "name" in update_fields and data.name is not None:
target_list.name = data.name
if "description" in update_fields:
target_list.description = data.description # allow setting to None
target_list.description = data.description
if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets]
await db.commit()
@@ -109,7 +105,7 @@ async def delete_target_list(
result = await db.execute(
select(TargetList).where(
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()

View File

@@ -29,6 +29,7 @@ from app.core.subscriptions import check_tree_limit, get_account_subscription, g
from app.core.audit import log_audit
from app.core.config import settings
from app.core.tree_validation import can_publish_tree
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
from app.services.rag_service import index_tree as rag_index_tree
@@ -391,9 +392,10 @@ async def get_tree(
)
if not tree.is_active or not can_access_tree(current_user, tree):
# Always 404, never 403. A 403 confirms the resource exists.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
return build_full_tree_response(tree)
@@ -470,7 +472,7 @@ async def create_tree(
tree_structure=tree_data.tree_structure,
intake_form=intake_form_data,
author_id=service_account_id if is_default else current_user.id,
account_id=None if is_default else current_user.account_id,
account_id=PLATFORM_ACCOUNT_ID if is_default else current_user.account_id,
is_public=True if is_default else tree_data.is_public, # Default trees are always public
is_default=is_default,
status=tree_data.status
@@ -610,9 +612,17 @@ async def update_tree(
)
if not can_edit_tree(current_user, tree):
# If the user can see this tree (same account, team visibility), give a 403 with
# a clear message — returning 404 here would be confusing since GET returns 200.
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
if can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to edit this flow"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Extract tags for separate handling
@@ -1038,6 +1048,7 @@ async def create_tree_share(
# Create share
tree_share = TreeShare(
tree_id=tree.id,
account_id=tree.account_id, # share belongs to the tree's tenant, not the actor
share_token=share_token,
created_by=current_user.id,
allow_forking=share_data.allow_forking,
@@ -1144,9 +1155,17 @@ async def update_tree_visibility(
)
if not can_edit_tree(current_user, tree):
# If the user can see this tree (same account, team visibility), give a 403 with
# a clear message — returning 404 here would be confusing since GET returns 200.
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
if can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to edit this flow"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Update visibility

View File

@@ -255,9 +255,9 @@ async def get_upload_url(
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify the upload belongs to the user's account
# Verify the upload belongs to the user's account — 404 to avoid revealing existence
if upload.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
url = storage_service.get_presigned_url(upload.storage_key)
return {"url": url}
@@ -311,9 +311,9 @@ async def delete_upload(
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify ownership
# Verify ownership — 404 to avoid revealing existence
if upload.uploaded_by != current_user.id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Delete from S3
await storage_service.delete_file(upload.storage_key)

View File

@@ -1,51 +1,91 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
from app.api.endpoints import ratings, analytics
from app.api.endpoints import target_lists
from app.api.endpoints import maintenance_schedules
from app.api.endpoints import feedback
from app.api.endpoints import ai_builder
from app.api.endpoints import ai_fix
from app.api.endpoints import ai_chat
from app.api.endpoints import copilot
from app.api.endpoints import assistant_chat
from app.api.endpoints import survey
from app.api.endpoints import admin_survey
from app.api.endpoints import tree_transfer
from app.api.endpoints import ai_suggestions
from app.api.endpoints import kb_accelerator
from app.api.endpoints import beta_signup
from app.api.endpoints import scripts
from app.api.endpoints import integrations
from app.api.endpoints import onboarding
from app.api.endpoints import branding
from app.api.endpoints import supporting_data
from app.api.endpoints import ai_sessions
from app.api.endpoints import flow_proposals
from app.api.endpoints import flowpilot_analytics
from app.api.endpoints import notifications
from app.api.endpoints import public_templates
from app.api.endpoints import admin_gallery
from app.api.endpoints import uploads
from app.api.endpoints import script_builder
from app.api.endpoints import beta_feedback
from app.api.endpoints import session_branches
from app.api.endpoints import session_handoffs
from app.api.endpoints import session_resolutions
from fastapi import APIRouter, Depends
from app.api.deps import require_tenant_context
from app.api.endpoints import (
admin,
admin_audit,
admin_categories,
admin_dashboard,
admin_feature_flags,
admin_gallery,
admin_plan_limits,
admin_settings,
admin_survey,
ai_builder,
ai_chat,
ai_fix,
ai_sessions,
ai_suggestions,
analytics,
assistant_chat,
auth,
beta_feedback,
beta_signup,
branding,
categories,
copilot,
device_types,
feedback,
flow_proposals,
flowpilot_analytics,
folders,
integrations,
invite,
kb_accelerator,
maintenance_schedules,
network_diagrams,
notifications,
onboarding,
public_templates,
ratings,
scripts,
script_builder,
session_branches,
session_handoffs,
session_resolutions,
sessions,
shared,
shares,
sidebar,
step_categories,
steps,
supporting_data,
survey,
tags,
target_lists,
tree_markdown,
tree_transfer,
trees,
uploads,
webhooks,
accounts,
)
api_router = APIRouter()
# ---------------------------------------------------------------------------
# Public / unauthenticated endpoints — no tenant context
#
# Note: auth.router contains both public endpoints (register, login,
# forgot-password, reset-password, email/verify) and authenticated endpoints
# (GET/PATCH /me, logout, change-password, email/send-verification).
# The authenticated auth endpoints only query the `users` table, which is
# excluded from Phase 1 RLS. They work correctly without tenant context
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
# ---------------------------------------------------------------------------
api_router.include_router(auth.router)
api_router.include_router(trees.router)
api_router.include_router(sidebar.router)
api_router.include_router(sessions.router)
api_router.include_router(invite.router)
api_router.include_router(categories.router)
api_router.include_router(tags.router)
api_router.include_router(folders.router)
api_router.include_router(step_categories.router)
api_router.include_router(steps.router)
api_router.include_router(shared.router) # Public share links (no auth)
api_router.include_router(beta_signup.router)
api_router.include_router(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only
# admin_categories, admin_gallery, admin_dashboard, admin query Phase 1 RLS
# tables and MUST use get_admin_db (migrated in Task 8). The remaining admin
# endpoints (admin_audit, admin_plan_limits, admin_feature_flags,
# admin_settings, admin_survey) are safe until Phase 2 extends RLS.
# ---------------------------------------------------------------------------
api_router.include_router(admin.router)
api_router.include_router(admin_dashboard.router)
api_router.include_router(admin_audit.router)
@@ -53,42 +93,55 @@ api_router.include_router(admin_plan_limits.router)
api_router.include_router(admin_feature_flags.router)
api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router)
api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(ratings.router)
api_router.include_router(analytics.router)
api_router.include_router(target_lists.router)
api_router.include_router(maintenance_schedules.router)
api_router.include_router(feedback.router)
api_router.include_router(ai_builder.router)
api_router.include_router(ai_fix.router)
api_router.include_router(ai_chat.router)
api_router.include_router(copilot.router)
api_router.include_router(assistant_chat.router)
api_router.include_router(survey.router)
api_router.include_router(admin_survey.router)
api_router.include_router(tree_transfer.router)
api_router.include_router(ai_suggestions.router)
api_router.include_router(kb_accelerator.router)
api_router.include_router(beta_signup.router)
api_router.include_router(scripts.router)
api_router.include_router(integrations.router)
api_router.include_router(onboarding.router)
api_router.include_router(branding.router)
api_router.include_router(supporting_data.router)
api_router.include_router(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict
api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict
api_router.include_router(ai_sessions.router)
api_router.include_router(flow_proposals.router)
api_router.include_router(flowpilot_analytics.router)
api_router.include_router(notifications.router)
api_router.include_router(public_templates.router)
api_router.include_router(admin_gallery.router)
api_router.include_router(uploads.router)
api_router.include_router(script_builder.router)
api_router.include_router(beta_feedback.router)
api_router.include_router(session_branches.router)
api_router.include_router(session_handoffs.router)
# ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required
# ---------------------------------------------------------------------------
_tenant_deps = [Depends(require_tenant_context)]
api_router.include_router(trees.router, dependencies=_tenant_deps)
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
api_router.include_router(sessions.router, dependencies=_tenant_deps)
api_router.include_router(invite.router, dependencies=_tenant_deps)
api_router.include_router(categories.router, dependencies=_tenant_deps)
api_router.include_router(tags.router, dependencies=_tenant_deps)
api_router.include_router(folders.router, dependencies=_tenant_deps)
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
api_router.include_router(steps.router, dependencies=_tenant_deps)
api_router.include_router(accounts.router, dependencies=_tenant_deps)
api_router.include_router(shares.router, dependencies=_tenant_deps)
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
api_router.include_router(ratings.router, dependencies=_tenant_deps)
api_router.include_router(analytics.router, dependencies=_tenant_deps)
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
api_router.include_router(feedback.router, dependencies=_tenant_deps)
api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
api_router.include_router(copilot.router, dependencies=_tenant_deps)
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
api_router.include_router(survey.router, dependencies=_tenant_deps)
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
api_router.include_router(scripts.router, dependencies=_tenant_deps)
api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
api_router.include_router(notifications.router, dependencies=_tenant_deps)
api_router.include_router(uploads.router, dependencies=_tenant_deps)
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
api_router.include_router(device_types.router, dependencies=_tenant_deps)

View File

@@ -0,0 +1,38 @@
# backend/app/core/admin_database.py
"""
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
Use ONLY where explicit application-level access control makes database-layer
tenant filtering unnecessary: /admin/* endpoints, internal tooling, and public
endpoints that enforce their own authorization before returning data (e.g.
share access via opaque token + visibility check).
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
admin_engine = create_async_engine(
settings.ADMIN_DATABASE_URL,
echo=settings.DEBUG,
future=True,
)
_admin_session_factory = async_sessionmaker(
admin_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an admin DB session (BYPASSRLS). See module docstring for approved use cases."""
async with _admin_session_factory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -12,10 +12,19 @@ async def log_audit(
resource_type: str,
resource_id: Optional[UUID] = None,
details: Optional[dict] = None,
account_id: Optional[UUID] = None,
) -> None:
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
if account_id is None:
# Derive from the acting user's account as a fallback (one extra query).
from sqlalchemy import select
from app.models.user import User
result = await db.execute(select(User.account_id).where(User.id == user_id))
account_id = result.scalar_one()
entry = AuditLog(
user_id=user_id,
account_id=account_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,

View File

@@ -23,10 +23,33 @@ class Settings(BaseSettings):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
@property
def DATABASE_URL_SYNC(self) -> str:
"""Get sync URL by removing asyncpg prefix from DATABASE_URL."""
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://", 1)
# Sync URL for Alembic migrations. Defaults to DATABASE_URL (sync-converted).
# Set explicitly in .env to use a different role for migrations (e.g. superuser)
# when DATABASE_URL has been switched to the app role.
DATABASE_URL_SYNC: str = ""
@field_validator("DATABASE_URL_SYNC", mode="before")
@classmethod
def default_database_url_sync(cls, v: str, info) -> str:
"""Fall back to sync-converted DATABASE_URL if not explicitly set."""
if not v:
base = info.data.get("DATABASE_URL", "")
return base.replace("postgresql+asyncpg://", "postgresql://", 1)
return v
# Admin database — resolutionflow_admin role, BYPASSRLS.
# Used by /admin/* endpoints. Defaults to DATABASE_URL for local dev.
ADMIN_DATABASE_URL: str = ""
@field_validator("ADMIN_DATABASE_URL", mode="before")
@classmethod
def default_admin_database_url(cls, v: str, info) -> str:
"""Fall back to DATABASE_URL if ADMIN_DATABASE_URL is not set."""
if not v:
return info.data.get("DATABASE_URL", "")
if v.startswith("postgresql://"):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
# JWT Settings
SECRET_KEY: str = _DEFAULT_SECRET_KEY
@@ -105,6 +128,7 @@ class Settings(BaseSettings):
"variable_inference": "fast",
"kb_convert": "standard",
"script_build": "standard",
"network_diagram_generate": "standard",
}
def get_model_for_action(self, action_type: str) -> str:

View File

@@ -1,6 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from .config import settings
from app.core.tenant_context import register_tenant_listener
# Create async engine
engine = create_async_engine(
@@ -16,6 +17,11 @@ async_session_maker = async_sessionmaker(
expire_on_commit=False
)
# Register the RLS tenant context listener on the app engine.
# Fires at the start of every transaction; issues set_config automatically.
# Must NOT be called on admin_engine — admin connections bypass RLS.
register_tenant_listener(engine)
class Base(DeclarativeBase):
"""Base class for all database models."""

View File

@@ -1,10 +1,12 @@
"""
Centralized query filters for ResolutionFlow.
Provides reusable SQLAlchemy filter builders for tree access control
and step visibility, used across multiple endpoint modules.
Provides reusable SQLAlchemy filter builders for tree access control,
step visibility, and the canonical tenant_filter used by all queries
on tenant-scoped tables.
"""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import or_, and_, true as sa_true
@@ -13,6 +15,18 @@ if TYPE_CHECKING:
from app.models.user import User
def tenant_filter(model, account_id: uuid.UUID):
"""Primary app-layer tenant filter.
MUST be used in every SELECT/UPDATE/DELETE on tenant tables.
RLS (Phase 2) is the safety net — this is the primary enforcement.
Usage:
stmt = select(Tree).where(tenant_filter(Tree, current_user.account_id), ...)
"""
return model.account_id == account_id
def build_tree_access_filter(current_user: User):
"""Build the access filter for trees based on user permissions.
@@ -36,10 +50,11 @@ def build_tree_access_filter(current_user: User):
Tree.author_id == current_user.id,
]
if current_user.account_id:
# Team-visible trees: use tenant_filter as the account match
conditions.append(
and_(
Tree.visibility == 'team',
Tree.account_id == current_user.account_id
tenant_filter(Tree, current_user.account_id),
)
)
return or_(*conditions)
@@ -58,11 +73,14 @@ def build_step_visibility_filter(current_user: User):
if current_user.account_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id),
StepLibrary.created_by == current_user.id # Own private steps
and_(
StepLibrary.visibility == 'team',
tenant_filter(StepLibrary, current_user.account_id),
),
StepLibrary.created_by == current_user.id,
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == current_user.id
StepLibrary.created_by == current_user.id,
)

View File

@@ -21,7 +21,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
"""Create batch sessions for a scheduled maintenance run."""
# Import all models first to ensure SQLAlchemy mapper relationships resolve
import app.models # noqa: F401
from app.core.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.session import Session
from app.models.target_list import TargetList
@@ -118,7 +118,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
async def _cleanup_expired_ai_conversations() -> None:
"""Delete expired AI wizard conversations."""
import app.models # noqa: F401
from app.core.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
async with async_session_maker() as db:

View File

@@ -14,10 +14,16 @@ import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import _admin_session_factory
logger = logging.getLogger(__name__)
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
SERVICE_ACCOUNT_NAME = "ResolutionFlow"
# Well-known UUID for the platform account — owns all default/global content.
# Created by migration 3a40fe11b427_create_global_content_tables.
PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
SYSTEM_ACCOUNT_NAME = "ResolutionFlow System"
SYSTEM_ACCOUNT_DISPLAY_CODE = "RF-SYS-1"
@@ -48,40 +54,45 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID:
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
"""Ensure the ResolutionFlow service account exists and return its ID.
Idempotent — safe to call on every startup. Creates the account if it
does not exist. The account has no usable password and is_service_account=True
so it can never log in via normal auth flows.
Idempotent — safe to call on every startup. This lookup must bypass RLS
because startup runs before any request-scoped tenant context exists and
the users table is tenant-isolated in Phase 4. The service account is
normally created by Alembic migration 1490781700bc; the runtime create path
remains as a self-healing fallback for environments that predate that seed.
"""
_ = db # Retained for call-site compatibility in app lifespan startup.
from app.models.user import User
result = await db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
)
user = result.scalar_one_or_none()
async with _admin_session_factory() as admin_db:
result = await admin_db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
)
user = result.scalar_one_or_none()
if user is not None:
if not user.is_service_account:
user.is_service_account = True
await db.commit()
return user.id
if user is not None:
if not user.is_service_account:
user.is_service_account = True
await admin_db.commit()
return user.id
account_id = await _ensure_system_account(db)
account_id = await _ensure_system_account(admin_db)
new_user = User(
id=uuid.uuid4(),
email=SERVICE_ACCOUNT_EMAIL,
name=SERVICE_ACCOUNT_NAME,
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=True,
must_change_password=False,
account_id=account_id,
account_role="engineer",
)
db.add(new_user)
await db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id
new_user = User(
id=uuid.uuid4(),
email=SERVICE_ACCOUNT_EMAIL,
name=SERVICE_ACCOUNT_NAME,
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=True,
must_change_password=False,
account_id=account_id,
account_role="engineer",
)
admin_db.add(new_user)
await admin_db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id

View File

@@ -0,0 +1,92 @@
# backend/app/core/tenant_context.py
"""
Per-request tenant context for row-level security.
Flow:
1. require_tenant_context (FastAPI dep) calls set_current_account_id().
2. The SQLAlchemy transaction-begin listener fires on every new transaction
and calls set_config('app.current_account_id', <id>, true) automatically.
3. PostgreSQL RLS policies read current_setting('app.current_account_id', TRUE)
to filter rows.
The ContextVar is asyncio-task-scoped: each concurrent request has its own value.
set_config with is_local=true is transaction-scoped: it resets on COMMIT or
ROLLBACK, so the listener re-applies it at the start of every transaction.
"""
import contextvars
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import event, or_, text
from sqlalchemy.ext.asyncio import AsyncEngine
if TYPE_CHECKING:
from app.models.user import User
# One slot per async task — each concurrent request gets its own value.
_current_account_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"current_account_id", default=None
)
# Platform account — global content visible to all tenants.
PLATFORM_ACCOUNT_ID = UUID("00000000-0000-0000-0000-000000000001")
def set_current_account_id(account_id: UUID) -> contextvars.Token:
"""Set tenant context for the current request coroutine.
Returns a token so the caller can reset it after the request.
"""
return _current_account_id.set(str(account_id))
def clear_current_account_id(token: contextvars.Token) -> None:
"""Reset the ContextVar to its previous value (call in finally block)."""
_current_account_id.reset(token)
def get_current_account_id() -> str | None:
"""Return the account_id string for the current request, or None."""
return _current_account_id.get()
def register_tenant_listener(engine: AsyncEngine) -> None:
"""Register the transaction-begin listener on the given engine.
Must be called once at application startup, AFTER the engine is created.
The listener issues set_config() at the start of every transaction so that
the setting is re-applied automatically even when a request commits
mid-flight and starts a new transaction.
Do NOT call this on admin_engine — admin connections must never set tenant
context automatically.
"""
@event.listens_for(engine.sync_engine, "begin")
def _on_transaction_begin(conn) -> None: # noqa: ANN001
account_id = _current_account_id.get()
if account_id:
# set_config(name, value, is_local=true) ≡ SET LOCAL.
# Unlike SET LOCAL, set_config IS parameterisable.
conn.execute(
text("SELECT set_config('app.current_account_id', :id, true)"),
{"id": account_id},
)
# If no account_id is set, do nothing. The RLS policy falls back to a
# null-matching UUID and returns zero rows — fail-closed behaviour.
def tenant_filter(Model, current_user: "User"): # noqa: ANN001
"""SQLAlchemy filter clause for tables that contain platform-owned rows.
Use for: tree_tags, tree_categories, step_categories, step_library,
template_trees, platform_steps.
For tenant-only tables (trees, sessions, psa_connections, etc.) use:
Model.account_id == current_user.account_id
directly.
"""
return or_(
Model.account_id == current_user.account_id,
Model.account_id == PLATFORM_ACCOUNT_ID,
)

View File

@@ -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.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.security_headers import SecurityHeadersMiddleware

View File

@@ -54,6 +54,10 @@ from .session_branch import SessionBranch
from .fork_point import ForkPoint
from .session_handoff import SessionHandoff
from .session_resolution_output import SessionResolutionOutput
from .template_tree import TemplateTree
from .platform_step import PlatformStep
from .device_type import DeviceType
from .network_diagram import NetworkDiagram
__all__ = [
"User",
@@ -122,4 +126,8 @@ __all__ = [
"ForkPoint",
"SessionHandoff",
"SessionResolutionOutput",
"TemplateTree",
"PlatformStep",
"DeviceType",
"NetworkDiagram",
]

View File

@@ -50,6 +50,13 @@ class AISessionStep(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Denormalized from ai_sessions.account_id for direct tenant filtering.",
)
step_order: Mapped[int] = mapped_column(
Integer, nullable=False,
comment="Sequential position in the session (0-indexed)",

View File

@@ -28,6 +28,12 @@ class AISuggestion(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),

View File

@@ -20,6 +20,12 @@ class Attachment(Base):
ForeignKey("sessions.id"),
nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

View File

@@ -21,6 +21,12 @@ class AuditLog(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(

View File

@@ -39,10 +39,10 @@ class TreeCategory(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View 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)
)

View File

@@ -1,6 +1,5 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
@@ -11,7 +10,7 @@ class Feedback(Base):
__tablename__ = "feedback"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
feedback_type: Mapped[str] = mapped_column(String(50), nullable=False)

View File

@@ -46,6 +46,12 @@ class UserFolder(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
icon: Mapped[str] = mapped_column(String(50), nullable=False, default="folder")

View File

@@ -23,6 +23,12 @@ class ForkPoint(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_branch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False)
trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
fork_reason: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -23,6 +23,12 @@ class MaintenanceSchedule(Base):
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(

View 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])

View File

@@ -31,6 +31,12 @@ class NotificationLog(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="sent")

View File

@@ -0,0 +1,37 @@
"""Platform step model — platform-owned steps, readable by all users.
No account_id. No RLS. Readable by any authenticated user.
Populated by promoting visibility='public' steps from step_library.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class PlatformStep(Base):
__tablename__ = "platform_steps"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(255), nullable=False)
step_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
source_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("step_library.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -25,6 +25,12 @@ class PsaMemberMapping(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),

View File

@@ -35,6 +35,12 @@ class PsaPostLog(Base):
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ticket_id: Mapped[str] = mapped_column(String(100), nullable=False)
note_type: Mapped[str] = mapped_column(String(50), nullable=False)
content_posted: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -29,6 +29,12 @@ class ScriptBuilderSession(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),

View File

@@ -44,6 +44,12 @@ class ScriptTemplate(Base):
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -97,6 +103,12 @@ class ScriptGeneration(Base):
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True
)

View File

@@ -31,6 +31,12 @@ class Session(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)

View File

@@ -35,6 +35,12 @@ class SessionBranch(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True)
fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)

View File

@@ -27,6 +27,12 @@ class SessionHandoff(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
handed_off_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
intent: Mapped[str] = mapped_column(String(20), nullable=False)
source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)

View File

@@ -23,6 +23,12 @@ class SessionResolutionOutput(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
output_type: Mapped[str] = mapped_column(String(30), nullable=False)
generated_content: Mapped[str] = mapped_column(Text, nullable=False)
structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="For KB: {symptoms, root_cause, steps, tags}")

View File

@@ -38,10 +38,10 @@ class StepCategory(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View File

@@ -46,10 +46,10 @@ class StepLibrary(Base):
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
@@ -143,6 +143,13 @@ class StepRating(Base):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Account of the RATER (not the step owner).",
)
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@@ -187,6 +194,13 @@ class StepUsageLog(Base):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Account of the user who logged this usage.",
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),

View File

@@ -14,6 +14,12 @@ class SessionSupportingData(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
label: Mapped[str] = mapped_column(String(255), nullable=False)
data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -51,10 +51,10 @@ class TreeTag(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View File

@@ -8,7 +8,7 @@ from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
class TargetList(Base):
@@ -17,9 +17,11 @@ class TargetList(Base):
id: Mapped[uuid.UUID] = mapped_column(
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(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True

View File

@@ -0,0 +1,40 @@
"""Template tree model — platform-owned troubleshooting trees, readable by all users.
No account_id. No RLS. Readable by any authenticated user.
Populated by promoting is_default=TRUE trees from the trees table.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class TemplateTree(Base):
__tablename__ = "template_trees"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
tree_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -76,10 +76,10 @@ class Tree(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -37,10 +37,10 @@ class TreeEmbedding(Base):
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
)
chunk_type: Mapped[str] = mapped_column(
String(30),

View File

@@ -25,6 +25,12 @@ class TreeShare(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
share_token: Mapped[str] = mapped_column(
String(64),
unique=True,

View File

@@ -43,10 +43,10 @@ class User(Base):
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Account-based multi-tenancy (new)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="RESTRICT"),
nullable=True,
nullable=False,
index=True
)
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")

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