diff --git a/CLAUDE.md b/CLAUDE.md index d9900094..8550359b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,750 +1,252 @@ # CLAUDE.md - Patherly / ResolutionFlow Project Context -> **Purpose:** This file provides Claude Code with essential context for working on the Patherly project. -> **Last Updated:** February 5, 2026 +> **Last Updated:** February 11, 2026 --- ## Project Overview -**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for Managed Service Provider (MSP) professionals and companies**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes automatically, and generate professional ticket documentation. +**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for MSP professionals**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes, and generate professional ticket documentation. -**Tagline:** "Take the path MOST traveled." +**Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients. -**Target Market:** MSP (Managed Service Provider) companies — IT service providers that manage infrastructure, endpoints, and support for multiple client organizations. - -**Primary User:** Michael Chihlas - Senior Systems Engineer at an MSP - -**Goal:** Michael uses this tool for 50% of his tickets within 3 months. - -**SaaS Context:** All features should be designed with multi-tenant MSP use in mind — teams represent MSP companies, trees are shared within teams, and the permission model supports tiered access (super_admin for platform, team_admin for MSP company, engineer for technicians, viewer for read-only access). +**SaaS Context:** Multi-tenant design — teams represent MSP companies, trees shared within teams, tiered access (super_admin, team_admin, engineer, viewer). ### Branding -The project was rebranded from "Patherly" to "ResolutionFlow" in the frontend (PR #26, commit `cfbd815`). The naming is split: - | Context | Name Used | |---------|-----------| -| Repository / directory name | `patherly` | -| Backend (FastAPI, env vars, APP_NAME) | ResolutionFlow | -| Database / Docker container | `patherly` / `patherly_postgres` | -| Production URLs | `resolutionflow.com` / `api.resolutionflow.com` | -| **Frontend UI (header, login, register)** | **ResolutionFlow** | -| **Browser tab title** | **ResolutionFlow - Decision Tree Platform** | +| Repository / directory / database / Docker | `patherly` / `patherly_postgres` | +| Backend, frontend UI, production URLs | **ResolutionFlow** | -**Brand details:** - -- **Colors:** Monochrome — pure black (`#000`) backgrounds, white text with opacity levels (PR #49) -- **Fonts:** Inter (all text) — loaded via Google Fonts -- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon, white fill) -- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon) -- **CSS utilities:** `glass-card`, `glass-card-hover`, `glass-card-glow`, `glass-stat` (defined in `index.css`) +- **Design:** Monochrome dark-only — black backgrounds, white text with opacity, Inter font +- **CSS utilities:** `glass-card`, `glass-card-hover`, `glass-card-glow`, `glass-stat` (in `index.css`) +- **Logo:** Inline SVG in `BrandLogo.tsx` - **Design system guide:** `docs/plans/Frontend/DESIGN_SYSTEM_GUIDE.md` -- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md) -When adding new frontend pages or components, use "ResolutionFlow" for any user-visible branding. Follow the monochrome design system: black backgrounds, `glass-card` for containers, `text-white` with opacity variants for text hierarchy, white primary buttons, functional color only for status indicators. +When adding new pages/components: use "ResolutionFlow" branding, monochrome design, `glass-card` containers, `text-white` hierarchy, white primary buttons, functional color only for status. --- ## Current State - **Phase:** Phase 2.5 - Step Library Foundation (In Progress) -- **Backend:** Complete (25+ API endpoints, 60+ integration tests, all passing) -- **Frontend:** Core features complete, Tree Editor functional, Settings page added -- **Database:** PostgreSQL with Docker (container name: `patherly_postgres`) - -### What's Complete - -- User authentication (JWT, register, login, refresh, invite codes) -- Trees CRUD with full-text search -- Sessions tracking with decisions -- Export API (Markdown, Text, HTML) -- Tree Editor with form-based editing and visual preview -- Dark-only monochrome design (theme toggle removed in PR #49) -- Markdown rendering in session player and node editor -- 7 comprehensive seed decision trees -- **Tree Organization System:** - - Categories (global + team-specific, admin-managed) - - Tags (author + admin managed, autocomplete) - - User folders (personal tree collections) - - Subfolder hierarchy (max 3 levels deep) - - Right-click context menu for edit/delete/add subfolder - - Cascade delete for subfolders - - Team admin role with scoped permissions - - Filter trees by category, tags, and folders -- **User Preferences (Issue #3):** - - Settings page at `/settings` - - Default export format preference (persisted in localStorage) -- **Step Categories (Issue #5):** - - Database table with 10 seeded global categories - - Full CRUD API at `/api/v1/step-categories` - - Team scoping support (global + team-specific) -- **Step Library Schema (Issue #6):** - - `step_library` table for reusable troubleshooting steps - - `step_ratings` table for user ratings/reviews - - `step_usage_log` table for tracking verified use - - Support for decision/action/solution step types - - Visibility levels: private, team, public -- **Step Library API (Issue #7):** - - Full CRUD at `/api/v1/steps` - - Full-text search endpoint - - Popular tags endpoint - - Rating/review system with verified use tracking -- **Frontend Rebrand (PR #26):** - - Renamed from "Patherly" to "ResolutionFlow" in all user-facing UI - - Custom SVG logo in header and auth pages - - Updated favicon and browser tab title -- **Monochrome Design System (PR #49):** - - Dark-only mode, theme toggle removed - - Glass-morphism cards (`glass-card`, `glass-card-glow` CSS utilities) - - 84 files migrated from themed/colored to monochrome - - CSS variables remapped to monochrome, Tailwind config simplified (single font: Inter) - - Functional color (green/red/yellow/blue) reserved for status indicators only -- **Token Refresh Fix:** - - Silent refresh with single-flight queue (prevents concurrent 401 race conditions) - - Backend `get_refresh_token_payload` dependency extracts refresh token from Authorization header - - Frontend Axios interceptor queues failed requests during refresh, retries after success - - Auth store synced after silent refresh via `setTokens` action -- **RBAC & Permissions:** - - `is_super_admin` boolean on User model (migration 010) - - Role hierarchy: super_admin > team_admin > engineer > viewer - - `role` field values: 'engineer' | 'viewer' (no more 'admin') - - Team Admin = `role='engineer'` + `is_team_admin=True` + valid `team_id` - - Backend centralized: `backend/app/core/permissions.py` - - Frontend hook: `frontend/src/hooks/usePermissions.ts` - - Viewers CAN: browse trees, start sessions, rate steps - - Viewers CANNOT: create/edit trees, steps, tags, categories -- **Security Hardening (Phase A):** - - Registration role field removed from `UserCreate` — hardcoded to `engineer` - - HTML export XSS fix — all user content escaped via `html.escape()` - - Secret key validator — rejects default key when `DEBUG=False` - - Role CHECK constraint on `users` table (migration 011) - - `test_admin` fixture fixed to properly grant `is_super_admin=True` -- **Security Hardening (Phase B):** - - B1: Tree access check on `start_session` via `can_access_tree` from permissions.py - - B2: All inline permission helpers replaced with centralized imports from `permissions.py` - - B3: `require_engineer_or_admin` checks `is_team_admin` before role check - - B4: `is_active` field on User model (migration 012), enforced in `get_current_active_user` - - B5: Admin user management endpoints at `/api/v1/admin/users/*` (6 endpoints) - - B6: Rate limiting on auth endpoints via slowapi (disabled when `DEBUG=True`) - - B7: Refresh token rotation — JTI-based revocation, meaningful logout (migration 013) - - Access token TTL reduced from 15 to 5 minutes - - All endpoints now use `get_current_active_user` (not `get_current_user`) -- **Permissions UX (Phase C):** - - Super admin bypass in tree list filter (`build_tree_access_filter` returns `sa_true()`) - - Audit log table (`audit_logs`) with JSONB details, integrated at admin + tree delete endpoints - - Soft delete for trees (`deleted_at` + `deleted_by` columns, migration 015) - - ProtectedRoute supports optional `requiredRole` prop for role-based route guards - - TreeEditorPage checks `canEditTree()` after fetch, before loading into editor - - Reusable `ConfirmDialog` component + tree delete UI on library page - - CustomStepModal hides "Type My Own" tab for users without `canCreateSteps` -- **Permissions Cleanup (Phase D):** - - Password complexity validation: requires uppercase, lowercase, and digit (min 10 chars) - - Soft delete cascade: cleans up folder/tag junction entries on tree delete - - Debug endpoint `/debug/cors` gated behind `if settings.DEBUG:` - - Tag search escapes SQL wildcards (`%`, `_`) before LIKE query -- **Session Scratchpad (Floating Overlay):** - - Fixed-position overlay panel (420px wide, 55vh tall) on right edge - - Floating button when collapsed, slide-in panel when expanded - - Ctrl+/ keyboard shortcut to toggle - - Auto-save with 1s debounce, markdown preview, localStorage persistence - - Main content adjusts width via padding transition when panel opens -- **Global Thin Scrollbar Styling:** - - 6px thin scrollbars site-wide (Firefox `scrollbar-width: thin` + WebKit pseudo-elements) - - Monochrome colors using CSS variables -- **Admin Panel (Feb 2026):** - - Full admin panel at `/admin/*` with 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories) - - Super admin access: requires `is_super_admin=true` on User model - - Admin API endpoints: `/api/v1/admin/*` (all require `require_admin` dependency) - - Utility script: `backend/make_superadmin_simple.py list|` - promote users to super admin - - ActionMenu component: uses React Portal to avoid overflow clipping in tables - - **API Path Gotcha:** Frontend `apiClient` baseURL is `http://localhost:8000/api/v1` — all API calls should use relative paths WITHOUT `/api/v1/` prefix (e.g., `/admin/users` not `/api/v1/admin/users`) -- **Session Quick Wins (Issues #51-#55, PR #72):** - - Session timer: `useSessionTimer` hook displays elapsed `MM:SS` / `HH:MM:SS` in session header - - Keyboard hints: `Tab` shortcut focuses notes textarea (`id="session-notes"`), hint text updated - - Repeat Last Session: saves `{tree_id, tree_name, client_name, ticket_number}` to localStorage on session start; "Repeat" button on tree library page prefills metadata via `location.state` - - Session auto-recovery: fetches incomplete sessions on tree library mount; resume/dismiss banner (dismissed IDs in `sessionStorage`) - - Copy step to clipboard: copy button on each decision card in `SessionDetailPage` timeline - - Delete tree button: added to `TreeTableView` and `TreeListView` (was missing, only grid view had it) +- **Backend:** Complete (25+ API endpoints, 100+ integration tests) +- **Frontend:** Core features complete, Tree Editor functional +- **Database:** PostgreSQL with Docker, 29+ migrations +- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md) ### What's In Progress - Custom step continuation flow refinements (Phase 2.5) - Tree forking from sessions with custom steps -### Deployment - -- **Production:** Railway (app.patherly.com / api.patherly.com) -- **PR Environments:** Enabled - auto-created for each pull request - --- ## Tech Stack ### Backend - - **Framework:** Python FastAPI - **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg) - **Migrations:** Alembic -- **Auth:** JWT tokens (python-jose) + bcrypt passwords +- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based) - **Validation:** Pydantic v2 ### Frontend - - **Framework:** React 19 + Vite + TypeScript -- **Styling:** Tailwind CSS v3 — monochrome glass-morphism design (dark-only) -- **Fonts:** Inter (all text) via Google Fonts +- **Styling:** Tailwind CSS v3 — monochrome glass-morphism (dark-only) - **State:** Zustand (with immer + zundo for undo/redo) - **Routing:** React Router v7 -- **API Client:** Axios with token interceptors +- **API Client:** Axios with token refresh interceptor - **Icons:** Lucide React --- -## Project Structure +## Key Project Structure ``` patherly/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI entry point -│ │ ├── api/ -│ │ │ ├── endpoints/ -│ │ │ │ ├── auth.py # Auth: register, login, refresh, logout -│ │ │ │ ├── admin.py # Admin user management (6 endpoints) -│ │ │ │ ├── trees.py # Trees CRUD + search -│ │ │ │ ├── sessions.py # Sessions + export -│ │ │ │ ├── invite.py # Invite code management -│ │ │ │ ├── categories.py # Tree categories (global + team) -│ │ │ │ ├── tags.py # Tree tags + autocomplete -│ │ │ │ ├── folders.py # User folders (hierarchy) -│ │ │ │ ├── steps.py # Step library CRUD + search -│ │ │ │ └── step_categories.py # Step categories -│ │ │ ├── deps.py # Auth dependencies -│ │ │ └── router.py -│ │ ├── core/ -│ │ │ ├── config.py # Settings (pydantic-settings) -│ │ │ ├── database.py # Async SQLAlchemy -│ │ │ ├── audit.py # Centralized audit log helper (log_audit) -│ │ │ ├── permissions.py # Centralized RBAC (role checks, content guards) -│ │ │ ├── rate_limit.py # Shared slowapi limiter (disabled in DEBUG) -│ │ │ ├── security.py # JWT + password hashing + token rotation -│ │ │ ├── logging_config.py # Structured logging -│ │ │ └── middleware.py # Request logging +│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, etc.) +│ │ ├── api/deps.py # Auth dependencies +│ │ ├── api/router.py # Route registration +│ │ ├── core/ # config, database, permissions, security, audit, rate_limit │ │ ├── models/ # SQLAlchemy models -│ │ │ ├── user.py # is_team_admin field added -│ │ │ ├── team.py -│ │ │ ├── tree.py # JSONB tree_structure + category_id, tags -│ │ │ ├── session.py # JSONB path_taken, decisions -│ │ │ ├── attachment.py -│ │ │ ├── invite_code.py -│ │ │ ├── category.py # TreeCategory model -│ │ │ ├── tag.py # TreeTag model -│ │ │ ├── folder.py # UserFolder model -│ │ │ ├── step_category.py # StepCategory model -│ │ │ ├── step_library.py # StepLibrary, StepRating, StepUsageLog -│ │ │ ├── refresh_token.py # RefreshToken model (JTI-based revocation) -│ │ │ └── audit_log.py # AuditLog model (JSONB details, indexed) │ │ └── schemas/ # Pydantic schemas -│ ├── alembic/ # Database migrations -│ ├── scripts/ -│ │ ├── seed_data.py -│ │ └── seed_trees.py # 7 comprehensive trees -│ ├── tests/ # pytest integration tests -│ ├── docker-compose.yml # PostgreSQL container -│ ├── requirements.txt -│ └── .env # Environment variables -│ -├── brand-assets/ # Source brand SVGs and guides -│ +│ ├── alembic/ # Database migrations (001-029+) +│ ├── scripts/ # seed_data.py, seed_trees.py +│ └── tests/ # pytest integration tests ├── frontend/ -│ ├── public/ -│ │ └── icons/ # Favicon and PWA icons │ ├── src/ -│ │ ├── main.tsx -│ │ ├── App.tsx -│ │ ├── router.tsx -│ │ ├── assets/brand/ # Brand logos (SVG) -│ │ ├── api/ # Axios API client -│ │ │ ├── client.ts # Axios instance with refresh queue interceptor -│ │ │ ├── auth.ts -│ │ │ ├── trees.ts -│ │ │ └── sessions.ts -│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts, usePermissions, useSessionTimer) -│ │ ├── store/ -│ │ │ ├── authStore.ts # Zustand auth state -│ │ │ ├── themeStore.ts # Unused (dark-only mode, no toggle) -│ │ │ ├── treeEditorStore.ts # Tree editor state (immer + zundo) -│ │ │ └── userPreferencesStore.ts # User preferences (NEW) -│ │ ├── components/ -│ │ │ ├── common/ # Modal, ErrorBoundary, ConfirmDialog, BrandLogo -│ │ │ ├── layout/ # AppLayout, ProtectedRoute -│ │ │ ├── tree-editor/ # Tree editor components -│ │ │ ├── tree-preview/ # Visual tree preview -│ │ │ ├── step-library/ # Step library browser, forms, modals -│ │ │ ├── library/ # Tree library UI components -│ │ │ ├── session/ # Session modals, scratchpad floating overlay -│ │ │ └── ui/ # MarkdownContent -│ │ ├── pages/ -│ │ │ ├── LoginPage.tsx -│ │ │ ├── RegisterPage.tsx -│ │ │ ├── TreeLibraryPage.tsx -│ │ │ ├── TreeNavigationPage.tsx # Core feature -│ │ │ ├── TreeEditorPage.tsx -│ │ │ ├── SessionHistoryPage.tsx -│ │ │ ├── SessionDetailPage.tsx -│ │ │ └── SettingsPage.tsx # User preferences (NEW) -│ │ ├── types/ # TypeScript interfaces -│ │ └── lib/utils.ts # cn() utility for Tailwind -│ ├── package.json -│ ├── tailwind.config.js -│ └── vite.config.ts -│ +│ │ ├── api/ # Axios client + endpoint modules +│ │ ├── components/ # common, layout, tree-editor, session, library, step-library, ui +│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts +│ │ ├── pages/ # All page components +│ │ ├── store/ # Zustand stores (auth, treeEditor, userPreferences) +│ │ └── types/ # TypeScript interfaces +│ └── tailwind.config.js ├── CLAUDE.md # This file -├── CURRENT-STATE.md # Quick status reference +├── CURRENT-STATE.md # Detailed feature status ├── LESSONS-LEARNED.md # Bugs and fixes (READ THIS!) -├── PROGRESS.md # Detailed progress log -├── REBRAND-IMPLEMENTATION-GUIDE.md # Patherly → ResolutionFlow rebrand guide -├── docs/plans/ # Design docs & implementation plans (YYYY-MM-DD--design.md / -implementation.md) -├── 01-PROJECT-OVERVIEW.md # Vision and goals -├── 02-TECHNICAL-ARCHITECTURE.md # System design, API specs -├── 03-DEVELOPMENT-ROADMAP.md # Phases and timeline -├── 04-FEATURE-SPECIFICATIONS.md # Feature details -├── 05-QUESTIONS-AND-ACTION-ITEMS.md -└── PHASE-2.5-PERSONAL-BRANCHING.md # Future feature spec +└── docs/plans/ # Design docs & implementation plans ``` --- ## Environment Variables -### Backend (.env) - -**Required in `backend/.env`:** +### Backend (`backend/.env`) ```bash -# Application APP_NAME=ResolutionFlow -DEBUG=true # Set false in production - -# Database (matches docker-compose.yml) +DEBUG=true DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly - -# JWT Settings - CHANGE THESE IN PRODUCTION -SECRET_KEY= -ACCESS_TOKEN_EXPIRE_MINUTES=5 # Reduced from 15; refresh tokens handle session continuity +SECRET_KEY= +ACCESS_TOKEN_EXPIRE_MINUTES=5 REFRESH_TOKEN_EXPIRE_DAYS=7 - -# Auth -REQUIRE_INVITE_CODE=true # Set false to allow open registration +REQUIRE_INVITE_CODE=true ``` -**Railway-specific (production):** +### Frontend (`frontend/.env.local` - optional) ```bash -ALLOW_RAILWAY_ORIGINS=true # Enables CORS for PR environments -``` - -### Frontend (.env.local - optional) - -```bash -VITE_API_URL=http://localhost:8000 # Override API URL +VITE_API_URL=http://localhost:8000 ``` --- ## Development Commands -### Start Development Environment - ```powershell -# Terminal 1: Start PostgreSQL +# Start PostgreSQL docker start patherly_postgres -# Terminal 2: Backend (from project root) -cd backend -# Windows: +# Backend (from backend/) .\venv\Scripts\Activate -# Linux/Mac: -source venv/bin/activate uvicorn app.main:app --reload -# Terminal 3: Frontend (from project root) -cd frontend +# Frontend (from frontend/) npm run dev -``` -### URLs - -- Frontend: http://localhost:5173 -- Backend API: http://localhost:8000 -- API Docs: http://localhost:8000/api/docs - -### Run Tests - -```powershell -# From project root -cd backend +# Run tests (from backend/) +pytest --override-ini="addopts=" # First time only: create test database docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;" -# Install test dependencies (if not already installed) -pip install -r requirements-dev.txt +# Frontend build +cd frontend && npm run build -# Run tests -pytest --override-ini="addopts=" -``` - -### Frontend Operations - -```powershell -# From project root -cd frontend - -# Build for production -npm run build - -# Preview production build -npm run preview - -# Lint code -npm run lint -``` - -### Run Seed Scripts - -```powershell -# From project root -cd backend -pip install httpx # Required for seed scripts -python -m scripts.seed_trees -``` - -### Database Operations - -```powershell -# From project root -cd backend - -# Run migrations -alembic upgrade head - -# Create new migration +# Database migrations +cd backend && alembic upgrade head alembic revision --autogenerate -m "Description" -# Create migration without DB running (manual mode) -alembic revision -m "Description" # Edit migration file manually -# Migration runs when DB is available - safe to commit without testing locally - -# Find current migration head (check down_revision in latest file) -# Chain mixes numeric IDs (001-008) and hex hashes (e.g., 4cdb5cba1aff) -# Always inspect the actual files — don't assume sequential ordering - -# Access PostgreSQL (no local psql needed) +# Access PostgreSQL docker exec -it patherly_postgres psql -U postgres -d patherly + +# Seed data +cd backend && pip install httpx && python -m scripts.seed_trees ``` +### URLs +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- API Docs: http://localhost:8000/api/docs + --- ## Critical Lessons Learned -**ALWAYS read [LESSONS-LEARNED.md](LESSONS-LEARNED.md) before making changes!** +**Full reference:** [LESSONS-LEARNED.md](LESSONS-LEARNED.md) — read before making changes! -### DateTime Handling (Critical) +### Top Gotchas (most commonly hit) +**1. DateTime Handling — Always timezone-aware:** ```python -# CORRECT - Always use timezone-aware datetimes -from datetime import datetime, timezone -from sqlalchemy import DateTime - +# CORRECT created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - -# WRONG - Never use this -datetime.utcnow() # Deprecated, returns naive datetime +# NEVER use datetime.utcnow() ``` -### React State: Don't Store Object Snapshots - -```tsx -// WRONG - Snapshot won't update when store changes -const [editingNode, setEditingNode] = useState(null) - -// CORRECT - Store ID only, fetch current object each render -const [editingNodeId, setEditingNodeId] = useState(null) -const editingNode = editingNodeId ? findNode(editingNodeId) : null -``` - -### Modal Draft State: Don't Overwrite Store-Managed Fields - -```tsx -// WRONG - Overwrites children with stale snapshot -const handleSave = () => { - updateNode(node.id, draft) // draft.children is stale! -} - -// CORRECT - Exclude store-managed fields -const handleSave = () => { - const { children, ...draftWithoutChildren } = draft - updateNode(node.id, draftWithoutChildren) -} -``` - -### Database Name - -- Database name is `patherly` (not `decision_tree`) -- Update `.env` if you see the old name - -### Virtual Environment - -- Always check for `(venv)` prefix before running pip -- Don't use `--break-system-packages` when venv is active - -### PostgreSQL NULL Casting for UUID Columns - -```sql --- WRONG - PostgreSQL infers NULL as text type -INSERT INTO tree_tags (name, slug, team_id) -SELECT 'tag', 'slug', NULL as team_id -- Error: column is uuid but expression is text - --- CORRECT - Explicitly cast NULL to uuid -INSERT INTO tree_tags (name, slug, team_id) -SELECT 'tag', 'slug', NULL::uuid as team_id -- Works! -``` -Always use `NULL::uuid` when inserting NULL values into UUID columns in raw SQL. - -### SQLAlchemy Async: Avoid Lazy Loading on New Objects - +**2. SQLAlchemy Async — No lazy loading on new objects:** ```python -# WRONG - Triggers lazy load which fails in async context -new_tree = Tree(...) -db.add(new_tree) -await db.flush() -new_tree.tags.append(tag) # MissingGreenlet error! +# WRONG — MissingGreenlet error +new_tree = Tree(...); db.add(new_tree); await db.flush() +new_tree.tags.append(tag) # Lazy load fails! -# CORRECT - Use direct SQL for junction tables -from app.models.tag import tree_tag_assignments -await db.execute( - tree_tag_assignments.insert().values( - tree_id=new_tree.id, - tag_id=tag.id - ) -) +# CORRECT — Use direct SQL for junction tables +await db.execute(tree_tag_assignments.insert().values(tree_id=new_tree.id, tag_id=tag.id)) ``` -Accessing relationships on newly created objects triggers lazy loading, which fails in async SQLAlchemy. Use direct SQL inserts for junction tables instead. -### SQLAlchemy: Multiple FKs to Same Table Require `foreign_keys` +**3. React State — Don't store object snapshots:** +```tsx +// WRONG — snapshot won't update +const [editingNode, setEditingNode] = useState(node) +// CORRECT — store ID, derive object +const [editingNodeId, setEditingNodeId] = useState(node.id) +const editingNode = editingNodeId ? findNode(editingNodeId, tree?.tree_structure) : null +``` -When a model has two FKs to the same table (e.g., Tree has `author_id` and `deleted_by` both pointing to `users`), specify `foreign_keys` on BOTH sides: +**4. Modal Draft State — Exclude store-managed fields:** +```tsx +const { children, ...draftWithoutChildren } = draft +updateNode(node.id, draftWithoutChildren) // Don't overwrite children +``` + +**5. Multiple FKs to same table — Specify `foreign_keys` on BOTH sides:** ```python -# On Tree model author = relationship("User", foreign_keys=[author_id], back_populates="trees") -# On User model -trees = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author") ``` -### React Router: Clear Dirty State Before Navigation +**6. PostgreSQL NULL in UUID columns:** +```sql +SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid +``` +**7. API Path Gotcha:** Frontend `apiClient` baseURL is `http://localhost:8000/api/v1` — use relative paths WITHOUT `/api/v1/` prefix (e.g., `/admin/users` not `/api/v1/admin/users`). + +**8. CORS errors can mask 500s:** Check backend logs first. Also run `alembic upgrade head` after pulling changes. + +**9. Public endpoints with optional auth:** Use manual `_get_optional_user(request, db)` helper, NOT `Optional[User]` param (FastAPI treats it as Pydantic field). + +**10. React Router — Clear dirty state before navigation:** ```tsx -// WRONG - Navigation triggers before dirty flag is cleared -const newTree = await treesApi.create(data) -navigate(`/trees/${newTree.id}/edit`) // Blocker fires here! -markSaved() // Too late - -// CORRECT - Clear dirty state first -const newTree = await treesApi.create(data) -markSaved() // Clear isDirty first -navigate(`/trees/${newTree.id}/edit`) // Blocker won't fire +markSaved() // Clear isDirty BEFORE navigate() +navigate(`/trees/${newTree.id}/edit`) ``` -When using `useBlocker` for unsaved changes, always clear the dirty flag before programmatic navigation. - -### CORS: Include Both allow_origins AND allow_origin_regex - -```python -# WRONG - Custom domains ignored when using regex -if settings.ALLOW_RAILWAY_ORIGINS: - app.add_middleware( - CORSMiddleware, - allow_origin_regex=r"https://.*\.up\.railway\.app", # Only matches Railway domains! - # ... - ) - -# CORRECT - Include both for custom domains + Railway PR environments -if settings.ALLOW_RAILWAY_ORIGINS: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.allowed_origins, # Custom domains like resolutionflow.com - allow_origin_regex=r"https://.*\.up\.railway\.app", # Railway PR domains - # ... - ) -``` -When using `allow_origin_regex` for wildcard patterns, also include `allow_origins` for explicit custom domains. The regex alone won't match custom domains like `resolutionflow.com`. - -### RBAC Permission Checks - -- Backend auth deps: `get_current_active_user` (any logged-in + active), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only) -- Backend: `is_super_admin` replaces all `role == "admin"` checks. Never use `role == "admin"`. -- Frontend: use `usePermissions()` hook for all role/permission checks -- `TreeListItem` includes `team_id` for frontend permission checks (`author_id` and `team_id` are nullable) - -### SQLAlchemy Test Fixtures: Circular FK Handling - -The `users ↔ invite_codes` tables have circular foreign keys. `Base.metadata.drop_all()` fails with `CircularDependencyError`. The test fixture uses `DROP SCHEMA public CASCADE` instead (split into two `sa.text()` calls — asyncpg rejects multi-statement strings). - -### Alembic Migrations: Test Data State Before Writing WHERE Clauses - -Migration 010 had `WHERE role = 'admin'` but the only user already had `role = 'engineer'` (changed by earlier work), so the UPDATE matched zero rows. Always verify actual data values before writing conditional migrations, or use broader conditions. - -### findNode Requires Tree Structure Parameter - -```tsx -// WRONG - Always returns null (structure defaults to undefined) -const node = findNode(nodeId) - -// CORRECT - Pass tree structure explicitly -const node = findNode(nodeId, tree?.tree_structure) -// For custom steps, also check: -const customStep = findCustomStep(nodeId) -``` - -### Frontend/Backend API Path Alignment - -Always verify frontend API paths match backend route definitions. Example: backend serves `/steps/tags/popular` but frontend called `/steps/popular-tags`. Check `backend/app/api/router.py` and endpoint files for actual paths. - -### Custom Step Flow in Tree Navigation - -The custom step insertion flow in `TreeNavigationPage.tsx` chains through multiple modals: - -1. `CustomStepModal` (create/browse) → 2. `PostStepActionModal` (save/use/both) → 3. `ContinuationModal` (pick descendant or build branch) → 4. Custom step view with "Continue to" button - -Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId` -Custom steps are stored in session JSONB (`custom_steps` field) and referenced by UUID in `pathTaken`. -`findNode()` only searches tree structure -- use `findCustomStep()` for custom step UUIDs. - -### Token Refresh: Match Frontend/Backend Contract - -The refresh endpoint must accept tokens the same way the frontend sends them. - -```python -# WRONG - Expects bare string, but frontend sends Authorization header -@router.post("/refresh") -async def refresh_token(refresh_token: str): - payload = decode_token(refresh_token) - -# CORRECT - Use dependency that reads from Authorization header -@router.post("/refresh") -async def refresh_token( - payload: Annotated[dict, Depends(get_refresh_token_payload)], -): -``` - -The frontend Axios interceptor sends `Authorization: Bearer `. The backend must extract it from the header, not expect it as a query/body parameter. - -### CORS Errors Can Mask Server 500s - -When the backend returns a 500 Internal Server Error, CORS headers are not added to the response. The browser reports this as a CORS error, hiding the real cause. Always check backend logs first when debugging CORS issues locally. - -### Run Migrations Before Local Testing - -After cloning or pulling new changes, always run `alembic upgrade head` before starting the backend. Missing migrations cause 500 errors (e.g., `column does not exist`) that manifest as CORS errors in the browser. --- -## API Endpoints Reference +## RBAC & Permissions -**Full API documentation:** http://localhost:8000/api/docs (interactive OpenAPI/Swagger UI) - -**Quick reference:** -- **Auth:** `/api/v1/auth/*` - register, login, refresh, logout, me -- **Trees:** `/api/v1/trees/*` - CRUD, search, categories (supports filters: category_id, tags, folder_id) -- **Sessions:** `/api/v1/sessions/*` - start, track, complete, export (markdown/text/html) -- **Tags:** `/api/v1/tags/*` - manage tree tags, autocomplete search -- **Folders:** `/api/v1/folders/*` - user folders with subfolder hierarchy (max 3 levels deep) -- **Categories:** `/api/v1/categories/*` - tree categories (global + team-specific) -- **Step Categories:** `/api/v1/step-categories/*` - step categories for library -- **Step Library:** `/api/v1/steps/*` - reusable steps, ratings, reviews, full-text search -- **Admin:** `/api/v1/admin/users/*` - user management (list, get, change role, toggle team admin, deactivate, activate) -- **Invite Codes:** `/api/v1/invite-codes/*` - admin management - -**Key constraints:** -- Folder hierarchy: max 3 levels deep (root → child → grandchild) -- Step ratings: 1-5 stars with optional review text -- Categories and tags: support both global and team-specific scoping - -For detailed parameters, request/response schemas, and examples, visit the API docs. +- **Role hierarchy:** super_admin > team_admin > engineer > viewer +- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id` +- **Backend deps:** `get_current_active_user` (any active), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only) +- **Never use** `role == "admin"` — use `is_super_admin` instead +- **Frontend:** `usePermissions()` hook for all permission checks +- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts` --- -## Data Models +## Monochrome Design System -Key JSONB structures stored in PostgreSQL. See `frontend/src/types/` for full TypeScript interfaces. - -- **Tree Structure:** Recursive node tree (`decision`/`action`/`solution` types with `children[]`). See `types/tree.ts`. -- **Session Decisions:** Array of `{node_id, question, answer, notes, timestamp}`. Timestamps are ISO strings, not datetime objects. +- **Backgrounds:** `bg-black`, subtle radial gradients +- **Cards:** `glass-card rounded-2xl` (NOT `bg-card border-border`) +- **Buttons:** Primary: `bg-white text-black hover:bg-white/90`. Secondary: `border border-white/10 text-white/60 hover:bg-white/10` +- **Inputs:** `border-white/10 bg-black/50 text-white` + focus: `border-white/30 ring-white/20` +- **Text:** `text-white` → `text-white/70` → `text-white/40` → `text-white/30` +- **Borders:** `border-white/[0.06]` or `border-white/10` +- **Functional color only:** emerald-400 (success), red-400 (error), yellow-400 (warning), blue-400 (info) --- ## Frontend Patterns -### State Management - -- **Auth:** `useAuthStore` - Zustand with localStorage persistence (includes `setTokens` for silent refresh sync) -- **Theme:** Removed — dark-only mode (no theme toggle) -- **Tree Editor:** `useTreeEditorStore` - Zustand + immer + zundo (undo/redo) -- **User Preferences:** `useUserPreferencesStore` - Zustand with localStorage persistence (export format default) - -### Component Guidelines - -- Use `cn()` from `@/lib/utils` for Tailwind class merging -- Use Lucide icons (no `title` prop - wrap in `` instead) -- Modals: Use fixed header/footer with scrollable body -- Modals: Import and render at end of parent component JSX -- Forms: Show field-level validation errors -- Conditional rendering: Add null checks when node might not exist (`currentNode && currentNode.type`) - -### Monochrome Design System Patterns - -- **Backgrounds:** Pure black (`bg-black`), subtle radial gradients for depth -- **Cards:** `glass-card rounded-2xl` (transparent gradient + backdrop-blur), NOT `bg-card border-border` -- **Buttons:** Primary: `bg-white text-black hover:bg-white/90`. Secondary: `border border-white/10 text-white/60 hover:bg-white/10` -- **Inputs:** `border-white/10 bg-black/50 text-white` + focus: `border-white/30 ring-white/20` -- **Text hierarchy:** `text-white` → `text-white/70` → `text-white/40` → `text-white/30` -- **Borders/dividers:** `border-white/[0.06]` or `border-white/10` -- **Functional color only:** emerald-400 (success), red-400 (error), yellow-400 (warning), blue-400 (info) -- **Reference:** `docs/plans/Frontend/DESIGN_SYSTEM_GUIDE.md` and `docs/plans/Frontend/COMPONENT_EXAMPLES.md` - -### TypeScript Type Organization - -- New type modules: Create in `types/` directory (e.g., `types/step.ts`) -- Export from `types/index.ts` with `export * from './modulename'` -- Import types using `import type { Type } from '@/types'` (type-only import) - -### API Client Pattern - -```typescript -import api from '@/api/client' - -// Token refresh handled automatically by interceptor -// Concurrent 401s are queued — only one refresh request fires at a time -// On refresh failure, user is logged out and redirected to /login -const response = await api.get('/api/v1/trees') -``` - -### Floating Overlay Pattern (Scratchpad) - -The scratchpad uses `position: fixed` with an `onOpenChange` callback so the parent page can adjust layout: - -```tsx -// Child: ScratchpadSidebar.tsx -onOpenChange?: (isOpen: boolean) => void -// Fires when collapsed state changes, parent uses it to add/remove padding - -// Parent: TreeNavigationPage.tsx -const [scratchpadOpen, setScratchpadOpen] = useState(...) -
-
{/* centers in available space */} -``` - -Position overlay at `right-2` (not `right-0`) so it sits inside the page scrollbar, and use full `rounded-lg` (not `rounded-l-lg`). +- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `` for title), modals with fixed header/footer +- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'` +- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning +- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs. --- @@ -759,106 +261,35 @@ Position overlay at `right-2` (not `right-0`) so it sits inside the page scrollb ## Coding Standards -### Python (Backend) +### Python +- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always -- Use type hints everywhere -- Use async/await for database operations -- Use Pydantic for validation -- Log with correlation IDs for tracing -- Always use `DateTime(timezone=True)` for timestamps - -### TypeScript (Frontend) - -- Enable strict mode (when ready) -- Use TypeScript interfaces for all data structures -- Prefer `const` over `let` -- Use functional components with hooks -- Extract reusable logic into custom hooks +### TypeScript +- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks ### Git - -- Commit message format: `type: description` -- Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` +- Format: `type: description` (feat, fix, refactor, docs, test, chore) - Always include `Co-Authored-By: Claude Opus 4.6 ` -- Always create a feature branch BEFORE committing new work (not retroactively after committing to main) -- PR workflow: `git checkout -b feat/feature-name` → commit → `git push -u origin feat/feature-name` → `gh pr create` +- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name` +- Large features: commit per phase with `npm run build` validation -### Commit Strategy for Large Features +--- -- Break implementation into logical phases (foundation → components → integration) -- Commit after each phase with `npm run build` validation -- Phase commits enable easier debugging and rollback -- Example: API clients (Phase 1) → UI components (Phase 2) → Page integration (Phase 3) +## Deployment (Railway) + +- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend) +- Auto-deploys on push to `main` +- PR environments auto-created (need manual domain generation in Railway dashboard) +- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service +- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app` +- Super admin utility: `backend/make_superadmin_simple.py list|` --- ## Future Roadmap -### Phase 2.5 (In Progress) - -- Custom step continuation flow refinements -- Tree forking from sessions with custom steps - -### Phase 3 — File attachments, offline mode, client context, analytics -### Phase 4 — PSA integrations (ConnectWise, Kaseya), PowerShell automation, enterprise SSO - ---- - -## Troubleshooting - -### Backend won't start - -1. Check Docker: `docker ps` - is `patherly_postgres` running? -2. Check `.env` - is DATABASE_URL correct (`patherly` not `decision_tree`)? -3. Check venv: is `(venv)` prefix showing? - -### Frontend compile errors - -1. Check `frontend/src/lib/utils.ts` exists (provides `cn()` function) -2. Run `npm install` to ensure dependencies are installed - -### Tests failing - -1. Create test database: `docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"` -2. Install dev deps: `pip install -r requirements-dev.txt` -3. Ensure pytest-asyncio version: `pip install pytest-asyncio==0.24.0` -4. If `unrecognized arguments: --cov` error: run with `--override-ini="addopts="` or install `pytest-cov` - -### API 500 errors - -1. Check server logs for datetime errors (timezone issue) -2. Ensure all datetimes use `datetime.now(timezone.utc)` - ---- - -## Git Patterns - -**Always gitignore:** -``` -# Secrets and local config -backend/.env -.claude.local.md -.claude/settings.local.json - -# Dependencies -backend/venv/ -frontend/node_modules/ - -# Build artifacts -frontend/dist/ -backend/**/__pycache__/ -*.pyc - -# Railway CLI (local tooling only) -/node_modules/ -/package.json -/package-lock.json - -# IDE -.vscode/ -.idea/ -*.swp -``` +- **Phase 3:** File attachments, offline mode, client context, analytics +- **Phase 4:** PSA integrations (ConnectWise, Kaseya), PowerShell automation, enterprise SSO --- @@ -867,70 +298,7 @@ backend/**/__pycache__/ | What | Where | |------|-------| | API Docs | http://localhost:8000/api/docs | -| Current Status | [CURRENT-STATE.md](CURRENT-STATE.md) | -| Bug Fixes | [LESSONS-LEARNED.md](LESSONS-LEARNED.md) | +| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) | +| Bugs & Fixes | [LESSONS-LEARNED.md](LESSONS-LEARNED.md) | | Feature Specs | [04-FEATURE-SPECIFICATIONS.md](04-FEATURE-SPECIFICATIONS.md) | -| Phase 2.5 Spec | [PHASE-2.5-PERSONAL-BRANCHING.md](PHASE-2.5-PERSONAL-BRANCHING.md) | -| Rebrand Guide | [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md) | - ---- - -## Railway Deployment - -### Production - -- **Frontend:** -- **Backend:** -- **Database:** Railway-managed PostgreSQL -- Deploys automatically on push to `main` - -### PR Environments - -Railway creates isolated preview environments for each pull request. - -**Workflow:** -1. Create feature branch: `git checkout -b feature/my-feature` -2. Make changes, commit, push to origin -3. Open Pull Request on GitHub -4. Railway auto-creates preview environment (backend + frontend + DB) -5. **Generate domains manually** in Railway dashboard: - - Switch to the PR environment - - Click on each service → Settings → Networking → Generate Domain -6. **Set `VITE_API_URL`** on frontend service to point to the PR backend URL - - **IMPORTANT:** Must include `https://` prefix (e.g., `https://patherly-patherly-pr-24.up.railway.app`) -7. Redeploy frontend if needed -8. Test at preview URLs -9. Merge PR → auto-deploys to production -10. Railway cleans up PR environment after merge - -**Environment Variables:** -- PR environments inherit from `production` base environment -- `REQUIRE_INVITE_CODE=true` is inherited (create invite codes in PR DB if needed) -- `DATABASE_URL` is auto-provided for isolated PR database -- `ALLOW_RAILWAY_ORIGINS=true` (shared variable) - enables CORS for all `*.up.railway.app` origins - -**Notes:** -- Each PR gets a fresh database - no existing users/trees -- Migrations run automatically via `releaseCommand` -- Domains must be generated manually for each PR service - -**Debug Endpoints (only when `DEBUG=True`):** -- `/debug/cors` - Check CORS configuration (requires `DEBUG=True` in environment) - -### Railway CLI (Local) - -Railway CLI installed via npm in project root (gitignored). Services: `patherly` (backend), `hopeful-liberation` (frontend), `Postgres` (DB). - -```powershell -# Common commands (run from project root) -./node_modules/.bin/railway service status --all -./node_modules/.bin/railway service logs --service patherly -./node_modules/.bin/railway variables --service patherly -``` - ---- - -## Contact - -**Primary User:** Michael Chihlas -**Communication:** GitHub Issues / Direct chat +| Design System | [docs/plans/Frontend/DESIGN_SYSTEM_GUIDE.md](docs/plans/Frontend/DESIGN_SYSTEM_GUIDE.md) | diff --git a/CURRENT-STATE.md b/CURRENT-STATE.md index d398dad3..8c2fcd4a 100644 --- a/CURRENT-STATE.md +++ b/CURRENT-STATE.md @@ -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:** February 2, 2026 +> **Last Updated:** February 11, 2026 --- @@ -10,334 +10,135 @@ --- -## What's Complete ✅ +## What's Complete ### Backend (100%) -- ✅ FastAPI project structure -- ✅ PostgreSQL database with Docker -- ✅ User authentication (JWT, register, login, refresh) -- ✅ Trees CRUD with full-text search -- ✅ Sessions tracking with decisions -- ✅ Export API (Markdown, Text, HTML) -- ✅ Role-based access control foundation -- ✅ Production-ready logging with correlation IDs -- ✅ 40+ integration tests -- ✅ DateTime timezone handling fixed +- FastAPI project structure with 25+ API endpoints +- PostgreSQL database with Docker, 29+ Alembic migrations +- User authentication (JWT, register, login, refresh, logout, invite codes) +- Refresh token rotation with JTI-based revocation +- Trees CRUD with full-text search (FTS index) +- Sessions tracking with decisions, outcomes, and variables +- Export API (Markdown, Text, HTML) +- Role-based access control (super_admin, team_admin, engineer, viewer) +- Production-ready logging with correlation IDs +- 100+ integration tests +- Rate limiting on auth endpoints (disabled in DEBUG) +- Audit log table with JSONB details +- Soft delete for trees with cascade cleanup ### Frontend (Phase 2 Complete) +- React 19 + Vite + TypeScript + Tailwind setup +- Authentication UI (login, register) +- Tree library/browsing page with grid/list/table views +- Tree navigation interface (session player) +- Session management with history and detail pages +- Export functionality (download) +- **Tree Editor** — Form-based with visual preview, Zustand + immer + zundo (undo/redo) +- **Markdown rendering** in session player and node editor +- **Monochrome Design System** — Dark-only, glass-morphism cards, Inter font, theme toggle removed +- **Tree Organization** — Categories, tags (autocomplete), user folders (3-level hierarchy), filters +- **RBAC & Permissions** — `usePermissions` hook, ProtectedRoute with role guards, permission-based UI hiding +- **Session Scratchpad** — Floating overlay (Ctrl+/), auto-save, markdown preview +- **Admin Panel** — 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories) +- **Session Quick Wins** (Issues #51-#55): + - Session timer (`useSessionTimer` hook, MM:SS / HH:MM:SS) + - Keyboard hints (Tab focuses notes) + - Repeat Last Session (prefills metadata from localStorage) + - Session auto-recovery (resume incomplete sessions) + - Copy step to clipboard + - Delete tree button in all view modes +- **Session Outcomes** — Outcome modal on session completion, step timing tracking +- **Settings page** at `/settings` — Default export format preference -- ✅ React + Vite + TypeScript + Tailwind setup -- ✅ Authentication UI (login, register) -- ✅ Basic layout and navigation -- ✅ Tree library/browsing page -- ✅ Tree navigation interface -- ✅ Session management -- ✅ Export functionality (download) -- ✅ Responsive design -- ✅ Error boundaries -- ✅ **Tree Editor** - Form-based with visual preview - - ✅ Zustand store with immer (undo/redo via zundo) - - ✅ Split-view layout (editor left, preview right) - - ✅ Node CRUD (Decision, Action, Solution types) - - ✅ NodePicker with type-grouped dropdown - - ✅ Dynamic array fields (options, commands, steps) - - ✅ Visual tree preview with solution indicators - - ✅ Shared node detection (multiple sources → same target) - - ✅ Modal with scrollable content, fixed header/footer - - ✅ Markdown preview toggle in description fields -- ✅ **Markdown Rendering** - Session player and node editor - - ✅ `react-markdown` package installed - - ✅ `MarkdownContent` component created - - ✅ Renders bold, italic, lists, code blocks, headers -- ✅ **User Preferences** - Settings page complete - - ✅ Dark/light/system theme toggle - - ✅ Default export format preference - - ✅ Persisted in localStorage - - ✅ Settings page at `/settings` -- ✅ **Tree Organization** - - ✅ Categories (global + team-specific) - - ✅ Tags with autocomplete - - ✅ User folders with subfolder hierarchy (max 3 levels) - - ✅ Right-click context menu for folder operations - - ✅ Filter trees by category, tags, and folders -- ✅ **RBAC & Permissions** - - ✅ Role hierarchy: super_admin > team_admin > engineer > viewer - - ✅ Permission checks in frontend (`usePermissions` hook) - - ✅ Protected routes with role guards - - ✅ Permission-based UI hiding (edit/delete/create actions) -- ✅ **Session Scratchpad** - - ✅ Floating overlay panel (Ctrl+/ to toggle) - - ✅ Auto-save with debounce - - ✅ Markdown preview - - ✅ Included in session exports -- ✅ **Mobile Responsiveness** - - ✅ Touch-friendly buttons and controls - - ✅ Optimized layouts for small screens - - ✅ Responsive navigation and forms -- ✅ **Design Consistency & Polish** - - ✅ Micro-interactions and transitions - - ✅ Global thin scrollbar styling - - ✅ Consistent brand colors and fonts - - ✅ Professional UI/UX polish +### Security Hardening (Phases A-D Complete) +- Registration role hardcoded to `engineer` +- HTML export XSS fix (html.escape) +- Secret key validator (rejects default when DEBUG=False) +- Role CHECK constraint on users table +- Tree access check on session start +- Centralized permissions in `permissions.py` +- `is_active` field on User model, enforced in auth +- Admin user management endpoints (6 endpoints) +- Refresh token rotation with JTI-based revocation +- Password complexity validation (uppercase, lowercase, digit, min 10 chars) +- Soft delete cascade cleanup (folder/tag junctions) +- SQL wildcard escaping in tag search + +### Backend Schema Features (Not Yet in Frontend) +- **Tree Forking** (migration 022) — `parent_tree_id`, `root_tree_id`, `fork_depth`, `fork_reason` +- **Session Sharing** (migration 023) — `session_shares`, `session_share_views`, `allow_public_shares` +- **Tree Sharing** (migration 024) — tree share links +- **Tree Status** (migration 025) — status field on trees +- **Admin Panel Tables** (migration 026) — plan limits, feature flags +- **Session Variables** (migration 028) — variable tracking in sessions +- **Session Outcomes** (migration 029) — outcome tracking ### Documentation - -- ✅ Project overview and architecture docs -- ✅ Development roadmap through Phase 4 -- ✅ Feature specifications (including Phase 2.5) -- ✅ CLAUDE.md for Claude Code context -- ✅ LESSONS-LEARNED.md for avoiding past mistakes -- ✅ REBRAND-IMPLEMENTATION-GUIDE.md -- ✅ Permissions audit design doc -- ✅ Comprehensive project review report -- ✅ Subscription tier architecture plan +- CLAUDE.md (project context for Claude Code) +- LESSONS-LEARNED.md (bugs and fixes reference) +- Design system guide, component examples +- Feature specifications through Phase 4 +- Rebrand implementation guide --- -## What's In Progress 🔄 +## What's In Progress | Task | Status | Notes | |------|--------|-------| | Step Library Frontend | In Progress | Backend complete, frontend UI pending | | Custom Step Flow | In Progress | Integration with tree navigation | -| Tree Forking | Planning | Backend schema complete, UI pending | -| TypeScript strict mode | Warnings exist | tsconfig needs `strict: true` | -| Deployment | **Production** | Deployed on Railway at resolutionflow.com | +| Tree Forking UI | Planning | Backend schema complete (migration 022) | +| Session Sharing UI | Planning | Backend schema complete (migration 023) | --- ## What's Next (Priority Order) ### Immediate (Phase 2.5 Completion) +1. Step Library Frontend UI (browse, search, rate/review) +2. Custom Step Integration in tree navigation +3. Tree Forking UI and workflow +4. Session/Tree Sharing UI -1. ✅ ~~Complete User Preferences~~ **COMPLETE** - Settings page with theme and export format -2. ✅ ~~Tree Organization~~ **COMPLETE** - Categories, tags, folders with hierarchy -3. ✅ ~~RBAC & Permissions~~ **COMPLETE** - Full permission system with role guards -4. ✅ ~~Session Scratchpad~~ **COMPLETE** - Floating overlay with auto-save -5. ✅ ~~Mobile Responsiveness~~ **COMPLETE** - Touch-friendly, responsive layouts -6. Step Library Frontend UI (browse, search, rate/review) -7. Custom Step Integration in tree navigation -8. Tree Forking UI and workflow - -### Soon (Phase 3 Planning) - +### Soon (Phase 3) - File attachments for sessions - Offline capability - Client context system - Advanced analytics dashboard ### Later (Phase 4) - - PSA integrations (ConnectWise, Kaseya) - PowerShell automation framework - Enterprise features (SSO, white-label) --- -## Key Files Reference - -### Backend -``` -backend/ -├── app/ -│ ├── main.py # FastAPI entry point -│ ├── api/v1/endpoints/ # API route handlers -│ │ ├── auth.py -│ │ ├── trees.py -│ │ └── sessions.py -│ ├── models/ # SQLAlchemy models -│ ├── schemas/ # Pydantic schemas -│ └── core/ -│ ├── config.py # Settings -│ ├── security.py # JWT handling -│ └── logging_config.py -├── alembic/ # Database migrations -├── tests/ # pytest tests -└── requirements.txt -``` - -### Frontend -``` -frontend/ -├── src/ -│ ├── main.tsx # Entry point -│ ├── App.tsx # Router setup -│ ├── pages/ # Page components -│ │ └── TreeEditorPage.tsx -│ ├── components/ -│ │ ├── common/ # Modal, etc. -│ │ ├── tree-editor/ # Tree Editor components -│ │ │ ├── TreeEditorLayout.tsx -│ │ │ ├── TreeMetadataForm.tsx -│ │ │ ├── NodeList.tsx -│ │ │ ├── NodeEditorModal.tsx -│ │ │ ├── NodeFormDecision.tsx -│ │ │ ├── NodeFormAction.tsx -│ │ │ ├── NodeFormResolution.tsx -│ │ │ ├── DynamicArrayField.tsx -│ │ │ └── NodePicker.tsx -│ │ └── tree-preview/ # Visual preview -│ │ ├── TreePreviewPanel.tsx -│ │ └── TreePreviewNode.tsx -│ ├── store/ -│ │ ├── authStore.ts -│ │ └── treeEditorStore.ts # Zustand + immer + zundo -│ ├── contexts/ # React contexts (auth) -│ ├── hooks/ # Custom hooks -│ └── api/ # API client -├── tailwind.config.js -└── tsconfig.json -``` - -### Documentation -``` -patherly/ -├── CLAUDE-SETUP.md # Full context for Claude Code -├── CURRENT-STATE.md # This file - quick status -├── LESSONS-LEARNED.md # Bugs and fixes reference -├── 01-PROJECT-OVERVIEW.md -├── 02-TECHNICAL-ARCHITECTURE.md -├── 03-DEVELOPMENT-ROADMAP.md -├── 04-FEATURE-SPECIFICATIONS.md -└── PHASE-2.5-PERSONAL-BRANCHING.md # Detailed Phase 2.5 spec -``` - ---- - ## Environment Quick Reference ### Start Development ```powershell -# Terminal 1: Database docker start patherly_postgres - -# Terminal 2: Backend -cd C:\Dev\Projects\patherly\backend -.\venv\Scripts\activate -uvicorn app.main:app --reload - -# Terminal 3: Frontend -cd C:\Dev\Projects\patherly\frontend -npm run dev +cd backend && .\venv\Scripts\activate && uvicorn app.main:app --reload +cd frontend && npm run dev ``` ### URLs - Frontend: http://localhost:5173 - Backend API: http://localhost:8000 -- API Docs: http://localhost:8000/docs +- API Docs: http://localhost:8000/api/docs ### Run Tests ```powershell -cd C:\Dev\Projects\patherly\backend -.\venv\Scripts\activate -pytest +cd backend && pytest --override-ini="addopts=" ``` --- -## Recent Changes (Feb 5-6, 2026) - -1. **Mobile Responsiveness & Design Polish** (commit `90ff250`): - - Touch-friendly buttons and controls throughout the app - - Responsive layouts optimized for small screens - - Micro-interactions and smooth transitions - - Global thin scrollbar styling (6px, theme-aware) - - Consistent brand colors and professional UI polish -2. **Security Hardening** (Phases A-D complete): - - Registration role field removed (hardcoded to engineer) - - HTML export XSS fixes (all content escaped) - - Secret key validation (rejects default in production) - - Tree access checks on session start - - Refresh token rotation with JTI-based revocation - - Rate limiting on auth endpoints - - Password complexity validation - - Soft delete cascade cleanup - - SQL wildcard escaping in tag search -3. **Permissions & RBAC** (commits `34daa26`, `71ba0b9`, `3e0fb92`): - - Complete role hierarchy (super_admin > team_admin > engineer > viewer) - - Frontend `usePermissions` hook for permission checks - - Protected routes with role-based guards - - Permission-based UI hiding (edit/delete/create actions) - - Audit log table with JSONB details - - Soft delete for trees with `deleted_at` timestamp - - Super admin bypass in tree list filter -4. **Session Scratchpad** (commit `2733a00`): - - Refactored to floating overlay panel (420px wide, 55vh tall) - - Ctrl+/ keyboard shortcut to toggle - - Auto-save with 1s debounce - - Markdown preview support - - Included in session exports (markdown, text, HTML) -5. **Step Library Foundation** (Issues #5, #6, #7): - - Step categories table with 10 seeded categories - - Full step library schema (steps, ratings, usage log) - - Complete CRUD API at `/api/v1/steps` - - Full-text search and popular tags endpoints - - Rating/review system with verified use tracking - -## Previous Changes (Jan 29, 2026) - -1. **Comprehensive Seed Script** (`backend/scripts/seed_trees.py`): - - 7 complete troubleshooting decision trees with 10-20+ nodes each - - **Tier 1 (Help Desk)**: Password Reset, Outlook/Email, VPN Connection, Printer Problems - - **Tier 2 (Desktop Support)**: Slow Computer, Network Connectivity - - **Tier 3 (Systems)**: File Share Access Problems - - Real PowerShell commands in action nodes - - Professional ticket documentation in solution nodes -2. **Markdown Rendering** in Session Player and Node Editor: - - Installed `react-markdown` package - - Created `MarkdownContent` component (`frontend/src/components/ui/MarkdownContent.tsx`) - - Updated `TreeNavigationPage.tsx` to render descriptions with markdown - - Added markdown preview toggle in `NodeFormAction.tsx` and `NodeFormResolution.tsx` - - Supports: bold, italic, lists, code blocks, headers, blockquotes -3. Updated LESSONS-LEARNED.md with: - - httpx installation requirement for seed scripts - - Email validation rejecting `.local` TLD (RFC 6761) - -## Previous Changes (Jan 28, 2026) - -1. Fixed DateTime timezone bugs in all models -2. Added production logging system -3. Created 40+ integration tests -4. Added Phase 2.5 specifications (Personal Branching, Step Library) -5. Added User Preferences to MVP scope -6. Created LESSONS-LEARNED.md -7. Created CURRENT-STATE.md (this file) -8. **Tree Editor Implementation**: - - Zustand store with immer middleware and zundo for undo/redo - - Form-based node editing with type-specific forms - - NodePicker dropdown grouped by node type (Decision/Action/Solution) - - Visual tree preview with recursive rendering - - Solution connection indicators (green checkmark badges) - - Shared node detection showing when multiple nodes link to same target - - Modal component with scrollable body, fixed header/footer - ---- - ## Blockers / Known Issues | Issue | Workaround | Status | |-------|------------|--------| | pytest-asyncio version conflict | Use 0.24.0 | Documented | | No local psql on Windows | Use `docker exec` | Documented | - ---- - -## Session Handoff Notes - -*Update this section at the end of each coding session:* - -**Last Session (Feb 5-6, 2026):** -- Updated CURRENT-STATE.md to reflect Phase 2.5 progress -- All Phase 2 items complete (Tree Editor, RBAC, Permissions, UI polish) -- Step Library backend complete (schema, API, search, ratings) -- Mobile responsiveness and design consistency complete -- Security hardening (Phases A-D) complete -- Next: Step Library frontend UI, custom step flow integration, tree forking UI - -**Previous Session (Feb 2-5, 2026):** -- Mobile responsiveness improvements (touch-friendly, responsive layouts) -- Security hardening phases A-D (permissions, token rotation, validation) -- RBAC system with audit logs and soft delete -- Session scratchpad refactored to floating overlay -- Global thin scrollbar styling -- Documentation updates (project review, subscription tiers) diff --git a/backend/alembic/versions/030_enhance_invite_codes.py b/backend/alembic/versions/030_enhance_invite_codes.py new file mode 100644 index 00000000..e4adb5c1 --- /dev/null +++ b/backend/alembic/versions/030_enhance_invite_codes.py @@ -0,0 +1,61 @@ +"""enhance invite codes with plan assignment and email + +Revision ID: 030 +Revises: 029 +Create Date: 2026-02-12 + +Adds email, assigned_plan, trial_duration_days, and email_sent_at columns +to invite_codes table for plan-aware invite code creation. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '030' +down_revision: Union[str, None] = '029' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('invite_codes', sa.Column('email', sa.String(255), nullable=True)) + op.add_column('invite_codes', sa.Column('assigned_plan', sa.String(50), nullable=False, server_default='free')) + op.add_column('invite_codes', sa.Column('trial_duration_days', sa.Integer(), nullable=True)) + op.add_column('invite_codes', sa.Column('email_sent_at', sa.DateTime(timezone=True), nullable=True)) + + op.create_index('ix_invite_codes_email', 'invite_codes', ['email']) + + # Plan must be free/pro/team + op.create_check_constraint( + 'ck_invite_codes_assigned_plan', + 'invite_codes', + "assigned_plan IN ('free', 'pro', 'team')" + ) + + # Trial duration 1-90 days or NULL + op.create_check_constraint( + 'ck_invite_codes_trial_duration', + 'invite_codes', + "trial_duration_days IS NULL OR (trial_duration_days >= 1 AND trial_duration_days <= 90)" + ) + + # Free plan cannot have trial duration + op.create_check_constraint( + 'ck_invite_codes_free_no_trial', + 'invite_codes', + "assigned_plan != 'free' OR trial_duration_days IS NULL" + ) + + +def downgrade() -> None: + op.drop_constraint('ck_invite_codes_free_no_trial', 'invite_codes', type_='check') + op.drop_constraint('ck_invite_codes_trial_duration', 'invite_codes', type_='check') + op.drop_constraint('ck_invite_codes_assigned_plan', 'invite_codes', type_='check') + op.drop_index('ix_invite_codes_email', table_name='invite_codes') + op.drop_column('invite_codes', 'email_sent_at') + op.drop_column('invite_codes', 'trial_duration_days') + op.drop_column('invite_codes', 'assigned_plan') + op.drop_column('invite_codes', 'email') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6d353506..0f1aa145 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -65,14 +65,36 @@ async def get_refresh_token_payload( async def get_current_active_user( - current_user: Annotated[User, Depends(get_current_user)] + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], ) -> User: - """Ensure user is active (not disabled).""" + """Ensure user is active (not disabled). Auto-downgrades expired trials.""" if not current_user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account has been deactivated" ) + + # Lightweight trial expiry check + if current_user.account_id: + from app.models.subscription import Subscription + from datetime import datetime, timezone + result = await db.execute( + select(Subscription).where(Subscription.account_id == current_user.account_id) + ) + subscription = result.scalar_one_or_none() + if ( + subscription + and subscription.status == "trialing" + and subscription.current_period_end + and subscription.current_period_end < datetime.now(timezone.utc) + ): + subscription.plan = "free" + subscription.status = "active" + subscription.current_period_end = None + subscription.current_period_start = None + await db.commit() + return current_user diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 47da5193..a2394f4a 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -1,15 +1,26 @@ +from datetime import datetime, timezone, timedelta 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 app.core.database import get_db from app.core.audit import log_audit from app.models.user import User from app.models.account import Account +from app.models.subscription import Subscription +from app.models.session import Session +from app.models.audit_log import AuditLog +from app.models.invite_code import InviteCode from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate from app.schemas.admin import MoveUserAccount +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 router = APIRouter(prefix="/admin", tags=["admin"]) @@ -42,13 +53,13 @@ async def list_users( return users -@router.get("/users/{user_id}", response_model=UserResponse) +@router.get("/users/{user_id}", response_model=UserDetailResponse) async def get_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)] ): - """Get user details (super admin only).""" + """Get enriched user details (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() @@ -58,7 +69,104 @@ async def get_user( detail="User not found" ) - return user + # Account + subscription + account_summary = None + subscription_summary = None + if user.account_id: + acc_result = await db.execute(select(Account).where(Account.id == user.account_id)) + account = acc_result.scalar_one_or_none() + if account: + account_summary = AccountSummary( + id=account.id, name=account.name, + display_code=getattr(account, "display_code", None), + ) + sub_result = await db.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + ) + subscription = sub_result.scalar_one_or_none() + if subscription: + subscription_summary = SubscriptionSummary( + id=subscription.id, plan=subscription.plan, status=subscription.status, + current_period_start=subscription.current_period_start, + current_period_end=subscription.current_period_end, + ) + + # Recent sessions (latest 10 + total) + total_sessions_result = await db.execute( + select(func.count()).select_from(Session).where(Session.user_id == user_id) + ) + total_sessions = total_sessions_result.scalar() or 0 + + sessions_result = await db.execute( + select(Session).options(selectinload(Session.tree)) + .where(Session.user_id == user_id) + .order_by(Session.started_at.desc()) + .limit(10) + ) + sessions = sessions_result.scalars().all() + recent_sessions = [ + SessionSummary( + id=s.id, + tree_name=s.tree.name if s.tree else None, + started_at=s.started_at, + completed_at=s.completed_at, + outcome=s.outcome, + ) + for s in sessions + ] + + # Recent audit logs (latest 10 + total) + total_audits_result = await db.execute( + select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id) + ) + total_audit_logs = total_audits_result.scalar() or 0 + + audits_result = await db.execute( + select(AuditLog).where(AuditLog.user_id == user_id) + .order_by(AuditLog.created_at.desc()) + .limit(10) + ) + audits = audits_result.scalars().all() + recent_audit_logs = [ + AuditLogSummary( + id=a.id, action=a.action, resource_type=a.resource_type, + resource_id=str(a.resource_id) if a.resource_id else None, + created_at=a.created_at, details=a.details, + ) + for a in audits + ] + + # Invite code used + invite_code_used = None + if user.invite_code_id: + ic_result = await db.execute( + select(InviteCode).where(InviteCode.id == user.invite_code_id) + ) + ic = ic_result.scalar_one_or_none() + if ic: + creator_email = None + if ic.created_by_id: + creator_result = await db.execute( + select(User.email).where(User.id == ic.created_by_id) + ) + creator_email = creator_result.scalar_one_or_none() + invite_code_used = InviteCodeUsedSummary( + code=ic.code, assigned_plan=ic.assigned_plan, + trial_duration_days=ic.trial_duration_days, + created_by_email=creator_email, + ) + + return UserDetailResponse( + id=user.id, email=user.email, full_name=user.name, + role=user.role, is_active=user.is_active, + is_super_admin=user.is_super_admin, + is_team_admin=getattr(user, "is_team_admin", False), + created_at=user.created_at, + account=account_summary, subscription=subscription_summary, + invite_code_used=invite_code_used, + recent_sessions=recent_sessions, total_sessions=total_sessions, + recent_audit_logs=recent_audit_logs, total_audit_logs=total_audit_logs, + ) @router.put("/users/{user_id}/role", response_model=UserResponse) @@ -198,3 +306,69 @@ async def move_user_account( await db.commit() await db.refresh(user) return user + + +async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User, Subscription]: + """Helper to load user and their subscription.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + if not user.account_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no account") + sub_result = await db.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + ) + subscription = sub_result.scalar_one_or_none() + if not subscription: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found") + return user, subscription + + +@router.put("/users/{user_id}/subscription/plan") +async def update_user_plan( + user_id: UUID, + data: SubscriptionPlanUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Change a user's subscription plan (super admin only).""" + if data.plan not in ("free", "pro", "team"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") + user, subscription = await _get_user_subscription(user_id, db) + old_plan = subscription.plan + 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, "user_id": str(user_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)], + current_user: Annotated[User, Depends(require_admin)], +): + """Extend or start a trial for a user's 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") + user, subscription = await _get_user_subscription(user_id, db) + + now = datetime.now(timezone.utc) + if subscription.status == "trialing" and subscription.current_period_end: + # Extend existing trial + new_end = subscription.current_period_end + timedelta(days=data.days) + else: + # Start new trial + 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(), "user_id": str(user_id)}) + await db.commit() + return {"plan": subscription.plan, "status": subscription.status, + "current_period_end": subscription.current_period_end} diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 44384c73..281deb70 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -1,6 +1,6 @@ import secrets import string -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordRequestForm @@ -92,38 +92,39 @@ async def register( detail="Account invite code has expired" ) - # Validate platform invite code if required (skip if account invite was provided) + # Validate platform invite code (skip if account invite was provided) invite_code_record = None - if not account_invite_record and settings.REQUIRE_INVITE_CODE: - if not user_data.invite_code: + if not account_invite_record: + if settings.REQUIRE_INVITE_CODE and not user_data.invite_code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required" ) - # Look up invite code (case-insensitive) - result = await db.execute( - select(InviteCode).where(InviteCode.code == user_data.invite_code.upper()) - ) - invite_code_record = result.scalar_one_or_none() - - if not invite_code_record: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid invite code" + if user_data.invite_code: + # Look up invite code (case-insensitive) — applies plan/trial regardless of REQUIRE_INVITE_CODE + result = await db.execute( + select(InviteCode).where(InviteCode.code == user_data.invite_code.upper()) ) + invite_code_record = result.scalar_one_or_none() - if invite_code_record.is_used: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invite code has already been used" - ) + if not invite_code_record: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid invite code" + ) - if invite_code_record.is_expired: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invite code has expired" - ) + if invite_code_record.is_used: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invite code has already been used" + ) + + if invite_code_record.is_expired: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invite code has expired" + ) # Check if email already exists result = await db.execute(select(User).where(User.email == user_data.email)) @@ -175,10 +176,24 @@ async def register( # Now set account owner and create subscription new_account.owner_id = new_user.id + # Apply plan/trial from invite code if present + sub_plan = "free" + sub_status = "active" + period_start = None + period_end = None + if invite_code_record and invite_code_record.assigned_plan: + sub_plan = invite_code_record.assigned_plan + if invite_code_record.trial_duration_days: + sub_status = "trialing" + period_start = datetime.now(timezone.utc) + period_end = period_start + timedelta(days=invite_code_record.trial_duration_days) + new_subscription = Subscription( account_id=new_account.id, - plan="free", - status="active", + plan=sub_plan, + status=sub_status, + current_period_start=period_start, + current_period_end=period_end, ) db.add(new_subscription) diff --git a/backend/app/api/endpoints/invite.py b/backend/app/api/endpoints/invite.py index 8fc0f325..c44563bf 100644 --- a/backend/app/api/endpoints/invite.py +++ b/backend/app/api/endpoints/invite.py @@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db from app.core.rate_limit import limiter +from app.core.audit import log_audit +from app.core.email import EmailService from app.models.user import User from app.models.invite_code import InviteCode from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation @@ -23,9 +25,35 @@ async def create_invite_code( invite_code = InviteCode( created_by_id=current_user.id, expires_at=invite_data.expires_at, - note=invite_data.note + note=invite_data.note, + email=invite_data.email, + assigned_plan=invite_data.assigned_plan, + trial_duration_days=invite_data.trial_duration_days, ) db.add(invite_code) + await db.flush() + + # Send invite email if email provided + email_sent = False + if invite_data.email: + email_sent = await EmailService.send_invite_email( + to_email=invite_data.email, + code=invite_code.code, + plan=invite_data.assigned_plan, + trial_days=invite_data.trial_duration_days, + ) + if email_sent: + invite_code.email_sent_at = datetime.now(timezone.utc) + + await log_audit( + db, current_user.id, "invite.create", "invite_code", invite_code.id, + { + "code": invite_code.code, + "plan": invite_data.assigned_plan, + "email": invite_data.email, + "email_sent": email_sent, + }, + ) await db.commit() await db.refresh(invite_code) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2041cf9c..4f43e6e4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -52,6 +52,15 @@ class Settings(BaseSettings): # Registration REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration + # Email (Resend) + RESEND_API_KEY: Optional[str] = None + FROM_EMAIL: str = "ResolutionFlow " + + @property + def email_enabled(self) -> bool: + """Check if email sending is configured.""" + return self.RESEND_API_KEY is not None + # Stripe STRIPE_SECRET_KEY: Optional[str] = None STRIPE_PUBLISHABLE_KEY: Optional[str] = None diff --git a/backend/app/core/email.py b/backend/app/core/email.py new file mode 100644 index 00000000..1c66bd86 --- /dev/null +++ b/backend/app/core/email.py @@ -0,0 +1,105 @@ +import logging +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Best-effort email delivery via Resend. Never raises on failure.""" + + @staticmethod + async def send_invite_email( + to_email: str, + code: str, + plan: str, + trial_days: int | None = None, + signup_url: str = "https://resolutionflow.com/register", + ) -> bool: + if not settings.email_enabled: + logger.warning("Email not sent — RESEND_API_KEY not configured") + return False + + try: + import resend + + resend.api_key = settings.RESEND_API_KEY + + plan_label = plan.capitalize() + trial_text = f" with a {trial_days}-day free trial" if trial_days else "" + subject = f"You're invited to ResolutionFlow ({plan_label} plan{trial_text})" + + html = _render_invite_html( + code=code, + plan_label=plan_label, + trial_days=trial_days, + signup_url=signup_url, + ) + + resend.Emails.send( + { + "from": settings.FROM_EMAIL, + "to": [to_email], + "subject": subject, + "html": html, + } + ) + logger.info("Invite email sent to %s", to_email) + return True + + except Exception: + logger.exception("Failed to send invite email to %s", to_email) + return False + + +def _render_invite_html( + code: str, + plan_label: str, + trial_days: int | None, + signup_url: str, +) -> str: + trial_section = "" + if trial_days: + trial_section = f""" + +

+ Your {trial_days}-day free trial starts when you register. + After your trial ends, your account will revert to the Free plan. +

+ """ + + return f""" + + + + +
+ + + + + {trial_section} + + +
+

ResolutionFlow

+

Decision Tree Platform for MSP Professionals

+
+

+ You've been invited to join ResolutionFlow on the {plan_label} plan. +

+
+
+

Your Invite Code

+

{code}

+
+
+ + Create Your Account + +
+

+ Enter the code above during registration, or click the button to get started. +

+
+
+""" diff --git a/backend/app/models/invite_code.py b/backend/app/models/invite_code.py index 4f2c6615..bfa9f0e0 100644 --- a/backend/app/models/invite_code.py +++ b/backend/app/models/invite_code.py @@ -46,6 +46,13 @@ class InviteCode(Base): DateTime(timezone=True), nullable=True ) + email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True) + assigned_plan: Mapped[str] = mapped_column(String(50), nullable=False, server_default="free") + trial_duration_days: Mapped[Optional[int]] = mapped_column(nullable=True) + email_sent_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) note: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -84,3 +91,11 @@ class InviteCode(Base): def is_valid(self) -> bool: """Check if the invite code is valid (not used and not expired).""" return not self.is_used and not self.is_expired + + @property + def has_trial(self) -> bool: + return self.trial_duration_days is not None and self.trial_duration_days > 0 + + @property + def email_sent(self) -> bool: + return self.email_sent_at is not None diff --git a/backend/app/schemas/invite_code.py b/backend/app/schemas/invite_code.py index 1e812c82..851403c8 100644 --- a/backend/app/schemas/invite_code.py +++ b/backend/app/schemas/invite_code.py @@ -1,13 +1,22 @@ from datetime import datetime -from typing import Optional +from typing import Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field, model_validator class InviteCodeCreate(BaseModel): """Schema for creating a new invite code.""" expires_at: Optional[datetime] = Field(None, description="Optional expiration time") note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for") + email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery") + assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration") + trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)") + + @model_validator(mode="after") + def free_plan_no_trial(self): + if self.assigned_plan == "free" and self.trial_duration_days is not None: + raise ValueError("Free plan cannot have a trial duration") + return self class InviteCodeResponse(BaseModel): @@ -23,6 +32,12 @@ class InviteCodeResponse(BaseModel): is_used: bool is_expired: bool is_valid: bool + email: Optional[str] = None + assigned_plan: str = "free" + trial_duration_days: Optional[int] = None + email_sent_at: Optional[datetime] = None + has_trial: bool = False + email_sent: bool = False class Config: from_attributes = True diff --git a/backend/app/schemas/subscription.py b/backend/app/schemas/subscription.py index 9b832926..80889fa0 100644 --- a/backend/app/schemas/subscription.py +++ b/backend/app/schemas/subscription.py @@ -38,3 +38,15 @@ class SubscriptionDetails(BaseModel): subscription: SubscriptionResponse limits: PlanLimitsResponse usage: UsageResponse + + +class SubscriptionPlanUpdate(BaseModel): + plan: str # free, pro, team + + model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}} + + +class ExtendTrialRequest(BaseModel): + days: int # 1-90 + + model_config = {"json_schema_extra": {"examples": [{"days": 14}]}} diff --git a/backend/app/schemas/user_detail.py b/backend/app/schemas/user_detail.py new file mode 100644 index 00000000..c65177be --- /dev/null +++ b/backend/app/schemas/user_detail.py @@ -0,0 +1,72 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel + + +class AccountSummary(BaseModel): + id: UUID + name: str + display_code: Optional[str] = None + + model_config = {"from_attributes": True} + + +class SubscriptionSummary(BaseModel): + id: UUID + plan: str + status: str + current_period_start: Optional[datetime] = None + current_period_end: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +class SessionSummary(BaseModel): + id: UUID + tree_name: Optional[str] = None + started_at: datetime + completed_at: Optional[datetime] = None + outcome: Optional[str] = None + + model_config = {"from_attributes": True} + + +class AuditLogSummary(BaseModel): + id: UUID + action: str + resource_type: Optional[str] = None + resource_id: Optional[str] = None + created_at: datetime + details: Optional[dict] = None + + model_config = {"from_attributes": True} + + +class InviteCodeUsedSummary(BaseModel): + code: str + assigned_plan: str + trial_duration_days: Optional[int] = None + created_by_email: Optional[str] = None + + model_config = {"from_attributes": True} + + +class UserDetailResponse(BaseModel): + id: UUID + email: str + full_name: Optional[str] = None + role: str + is_active: bool + is_super_admin: bool + is_team_admin: bool + created_at: datetime + + account: Optional[AccountSummary] = None + subscription: Optional[SubscriptionSummary] = None + invite_code_used: Optional[InviteCodeUsedSummary] = None + + recent_sessions: list[SessionSummary] = [] + total_sessions: int = 0 + recent_audit_logs: list[AuditLogSummary] = [] + total_audit_logs: int = 0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 984c1be7..28975527 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,5 +25,8 @@ slowapi==0.1.9 # Payments stripe==14.3.0 +# Email +resend==2.21.0 + # Utilities python-dotenv==1.0.1 diff --git a/backend/tests/test_invite_plan.py b/backend/tests/test_invite_plan.py new file mode 100644 index 00000000..d33c8b99 --- /dev/null +++ b/backend/tests/test_invite_plan.py @@ -0,0 +1,227 @@ +"""Tests for enhanced invite codes with plan assignment and trial durations.""" + +import pytest +from datetime import datetime, timezone, timedelta +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.invite_code import InviteCode +from app.models.subscription import Subscription +from app.models.user import User + + +class TestInviteCodeCreation: + """Test invite code creation with plan/trial fields.""" + + @pytest.mark.asyncio + async def test_create_invite_with_plan( + self, client: AsyncClient, admin_auth_headers: dict + ): + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "pro", "note": "Beta tester"}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["assigned_plan"] == "pro" + assert data["has_trial"] is False + assert data["trial_duration_days"] is None + + @pytest.mark.asyncio + async def test_create_invite_with_trial( + self, client: AsyncClient, admin_auth_headers: dict + ): + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "pro", "trial_duration_days": 14}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["assigned_plan"] == "pro" + assert data["trial_duration_days"] == 14 + assert data["has_trial"] is True + + @pytest.mark.asyncio + async def test_create_invite_with_email( + self, client: AsyncClient, admin_auth_headers: dict + ): + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "team", "email": "beta@example.com"}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["email"] == "beta@example.com" + # Email not sent because RESEND_API_KEY not configured + assert data["email_sent"] is False + + @pytest.mark.asyncio + async def test_free_plan_rejects_trial( + self, client: AsyncClient, admin_auth_headers: dict + ): + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "free", "trial_duration_days": 14}, + headers=admin_auth_headers, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_trial_duration_bounds( + self, client: AsyncClient, admin_auth_headers: dict + ): + # Too low + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "pro", "trial_duration_days": 0}, + headers=admin_auth_headers, + ) + assert response.status_code == 422 + + # Too high + response = await client.post( + "/api/v1/invites", + json={"assigned_plan": "pro", "trial_duration_days": 91}, + headers=admin_auth_headers, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_default_plan_is_free( + self, client: AsyncClient, admin_auth_headers: dict + ): + response = await client.post( + "/api/v1/invites", + json={}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + assert response.json()["assigned_plan"] == "free" + + +class TestRegistrationWithInvitePlan: + """Test that registration applies invite code plan/trial to subscription.""" + + @pytest.mark.asyncio + async def test_register_with_pro_trial_invite( + self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession + ): + # Create a pro trial invite + resp = await client.post( + "/api/v1/invites", + json={"assigned_plan": "pro", "trial_duration_days": 14}, + headers=admin_auth_headers, + ) + code = resp.json()["code"] + + # Register with the invite code + reg_resp = await client.post( + "/api/v1/auth/register", + json={ + "email": "trial_user@example.com", + "password": "SecurePass1", + "name": "Trial User", + "invite_code": code, + }, + ) + assert reg_resp.status_code == 201 + user_id = reg_resp.json()["id"] + + # Check subscription + user = (await test_db.execute( + select(User).where(User.id == user_id) + )).scalar_one() + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + )).scalar_one() + assert sub.plan == "pro" + assert sub.status == "trialing" + assert sub.current_period_end is not None + assert sub.current_period_end > datetime.now(timezone.utc) + + @pytest.mark.asyncio + async def test_register_with_team_no_trial( + self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession + ): + # Create team invite without trial + resp = await client.post( + "/api/v1/invites", + json={"assigned_plan": "team"}, + headers=admin_auth_headers, + ) + code = resp.json()["code"] + + reg_resp = await client.post( + "/api/v1/auth/register", + json={ + "email": "team_user@example.com", + "password": "SecurePass1", + "name": "Team User", + "invite_code": code, + }, + ) + assert reg_resp.status_code == 201 + user_id = reg_resp.json()["id"] + + user = (await test_db.execute( + select(User).where(User.id == user_id) + )).scalar_one() + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + )).scalar_one() + assert sub.plan == "team" + assert sub.status == "active" + + +class TestAdminSubscriptionManagement: + """Test admin subscription plan change and trial extension endpoints.""" + + @pytest.mark.asyncio + async def test_change_user_plan( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + user_id = test_user["user_data"]["id"] + response = await client.put( + f"/api/v1/admin/users/{user_id}/subscription/plan", + json={"plan": "pro"}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json()["plan"] == "pro" + + @pytest.mark.asyncio + async def test_extend_trial( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + user_id = test_user["user_data"]["id"] + response = await client.put( + f"/api/v1/admin/users/{user_id}/subscription/extend-trial", + json={"days": 14}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "trialing" + assert data["current_period_end"] is not None + + @pytest.mark.asyncio + async def test_enriched_user_detail( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + user_id = test_user["user_data"]["id"] + response = await client.get( + f"/api/v1/admin/users/{user_id}", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + data = response.json() + # Should have enriched fields + assert "subscription" in data + assert "account" in data + assert "recent_sessions" in data + assert "total_sessions" in data + assert "recent_audit_logs" in data + assert "total_audit_logs" in data diff --git a/docs/plans/2026-02-11-invite-codes-admin-panel.md b/docs/plans/2026-02-11-invite-codes-admin-panel.md new file mode 100644 index 00000000..d97203a8 --- /dev/null +++ b/docs/plans/2026-02-11-invite-codes-admin-panel.md @@ -0,0 +1,173 @@ +# Admin Panel: Invite Codes + User Management Enhancement + +## Context + +The admin panel has basic invite code CRUD and user listing, but lacks: +- **Plan assignment on invite codes** — all registrations get "free" plan +- **Email delivery** — admin must manually copy/send codes +- **Trial duration** — no time-limited plan access for beta testers +- **User detail page** — no way to view/manage a user's subscription, activity, or trial + +This change enables the admin to create invite codes tied to specific plans (free/pro/team) with optional trial durations, send branded invite emails via Resend, and manage user subscriptions from a detailed user page. + +--- + +## Phase 1: Database Migration (030) + +**New file:** `backend/alembic/versions/030_enhance_invite_codes.py` + +Add columns to `invite_codes`: +- `email` (String(255), nullable, indexed) +- `assigned_plan` (String(50), nullable, default `'free'`, CHECK `free/pro/team`) +- `trial_duration_days` (Integer, nullable) +- `email_sent_at` (DateTime(timezone=True), nullable) + +**Update:** `backend/app/models/invite_code.py` — add fields + `has_trial` and `email_sent` properties + +--- + +## Phase 2: Resend Email Integration + +**New file:** `backend/app/core/email.py` +- `EmailService` class with `send_invite_email(to, code, plan, trial_days)` +- Graceful degradation: if `RESEND_API_KEY` not set, log warning, skip sending +- Email failure doesn't block invite creation (best-effort) + +**New file:** `backend/app/templates/invite_email.html` +- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button +- Shows invite code, plan name, trial duration if applicable, signup link + +**Update:** `backend/app/core/config.py` — add `RESEND_API_KEY`, `FROM_EMAIL`, `email_enabled` property +**Update:** `backend/requirements.txt` — add `resend` + +**Env vars:** `RESEND_API_KEY`, `FROM_EMAIL=ResolutionFlow ` + +--- + +## Phase 3: Backend API Changes + +### Invite code enhancements + +**Update:** `backend/app/schemas/invite_code.py` +- `InviteCodeCreate`: add `email`, `assigned_plan`, `trial_duration_days` +- `InviteCodeResponse`: add new fields + computed `has_trial`, `email_sent` + +**Update:** `backend/app/api/endpoints/invite.py` +- `create_invite_code`: accept new fields, send email if email provided, set `email_sent_at`, audit log + +### Registration plan assignment + +**Update:** `backend/app/api/endpoints/auth.py` (lines 178-183) +- When `invite_code_record` has `assigned_plan`/`trial_duration_days`, apply to new subscription +- Set `plan=invite_code_record.assigned_plan`, `status='trialing'` if trial, calculate `current_period_end` + +### Subscription management endpoints + +**Update:** `backend/app/api/endpoints/admin.py` +- `PUT /admin/users/{id}/subscription/plan` — change plan +- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial +- `GET /admin/users/{id}/detail` — enhanced user detail with account, subscription, sessions, audit logs, invite code used + +**New file:** `backend/app/schemas/subscription.py` — `SubscriptionPlanUpdate`, `ExtendTrialRequest`, `SubscriptionResponse` +**New file:** `backend/app/schemas/user_detail.py` — `UserDetailResponse`, `SessionSummary`, `AuditLogSummary`, `AccountSummary` + +### Trial expiry on login (lightweight) + +**Update:** `backend/app/api/deps.py` — in `get_current_active_user`, check if subscription is trialing and expired → auto-downgrade to free + +--- + +## Phase 4: Frontend Types & API Client + +**Update:** `frontend/src/types/admin.ts` +- Enhanced `InviteCodeResponse` with email/plan/trial fields +- New: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary` + +**Update:** `frontend/src/api/admin.ts` +- Enhanced `createInviteCode` with new fields +- New: `getUserDetail`, `updateUserSubscriptionPlan`, `extendUserTrial` + +--- + +## Phase 5: Frontend — Enhanced Invite Codes Page + +**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx` + +Create form additions: +- Email input (optional, validated) +- Plan selector dropdown (Free / Pro / Team) +- Trial duration input (number of days, shown only if plan != free) + +Table additions: +- "Recipient" column (email or "—") +- "Plan" column with badge +- "Trial" column (days or "—") +- "Email Sent" indicator + +--- + +## Phase 6: Frontend — User Detail Page + +**New file:** `frontend/src/pages/admin/UserDetailPage.tsx` + +Sections: +1. **Header** — name, email, role badges, active status +2. **Account & Subscription card** — plan, status, trial end date, account display code +3. **Admin Actions card** — Change Role, Change Plan, Extend Trial, Activate/Deactivate (modal-based) +4. **Recent Sessions tab** — tree name, started, completed, outcome +5. **Audit Logs tab** — action, resource, timestamp, expandable details +6. **Invite Code card** — code used, plan assigned, who created it + +**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId` +**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable → navigate to detail page + +--- + +## Implementation Order + +1. Migration 030 (invite code fields) +2. Model update (invite_code.py) +3. Resend integration (email.py, config.py, template, requirements.txt) +4. Backend schemas (invite_code, subscription, user_detail) +5. Backend API (invite.py, auth.py, admin.py, deps.py) +6. Backend tests +7. Frontend types + API client +8. Frontend invite codes page enhancement +9. Frontend user detail page +10. End-to-end testing + +--- + +## Files to Create +- `backend/alembic/versions/030_enhance_invite_codes.py` +- `backend/app/core/email.py` +- `backend/app/templates/invite_email.html` +- `backend/app/schemas/subscription.py` +- `backend/app/schemas/user_detail.py` +- `frontend/src/pages/admin/UserDetailPage.tsx` + +## Files to Modify +- `backend/app/models/invite_code.py` +- `backend/app/schemas/invite_code.py` +- `backend/app/api/endpoints/invite.py` +- `backend/app/api/endpoints/auth.py` (lines 178-183) +- `backend/app/api/endpoints/admin.py` +- `backend/app/api/deps.py` +- `backend/app/core/config.py` +- `backend/requirements.txt` +- `frontend/src/types/admin.ts` +- `frontend/src/api/admin.ts` +- `frontend/src/pages/admin/InviteCodesPage.tsx` +- `frontend/src/pages/admin/UsersPage.tsx` +- `frontend/src/router.tsx` + +--- + +## Verification + +1. **Backend tests:** Create invite with plan+trial → register with code → verify subscription has correct plan/status/period_end +2. **Email test:** Mock Resend, verify template renders, verify email_sent_at set on success +3. **Trial expiry:** Create expired trial → login → verify auto-downgrade to free +4. **Admin UI:** Create invite with email+plan+trial → verify email sent → register → verify in user detail page → change plan → extend trial +5. **Build:** `cd frontend && npm run build` passes +6. **Full test suite:** `cd backend && pytest --override-ini="addopts="` passes diff --git a/docs/plans/2026-02-12-admin-invite-user-management-enhancement.md b/docs/plans/2026-02-12-admin-invite-user-management-enhancement.md new file mode 100644 index 00000000..ece18433 --- /dev/null +++ b/docs/plans/2026-02-12-admin-invite-user-management-enhancement.md @@ -0,0 +1,253 @@ +# Admin Panel: Invite Codes + User Management Enhancement + +Date: 2026-02-12 +Status: Proposed + +## Summary +Enhance admin capabilities to: +1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations. +2. Send invite emails via Resend (best-effort, non-blocking). +3. Apply invite-assigned plan/trial on registration. +4. Give admins a detailed user management view with subscription/session/audit context. +5. Support admin subscription actions (change plan, extend/start trial). +6. Auto-downgrade expired trials during authenticated access checks. + +## Goals +- Remove manual invite-code sharing workflow. +- Support controlled beta onboarding with plan + trial at invite level. +- Enable operational admin workflows for account/subscription lifecycle. +- Keep backward compatibility where practical and avoid unsafe breaking changes. + +## Non-Goals +- Stripe billing workflow redesign. +- Full historical pagination for user-detail sessions/audits in this iteration. +- Rework of account invite (`/accounts/me/invites`) flow. + +## Key Decisions Locked +- Invite API path standardization: use `/invites` (frontend and backend aligned). +- User detail endpoint: enrich existing `GET /admin/users/{id}`. +- Invite `email` is advisory only (no strict email-match enforcement at registration). +- Invite plan/trial applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false`. +- Trial duration bounds: `1..90` days. +- Extend trial endpoint may convert non-trialing subscriptions to `trialing`. +- User detail payload includes recent summaries (latest 10 sessions + latest 10 audit logs) plus total counts. + +## Scope by Phase + +## Phase 1: Database Migration (`030`) +Create `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`). + +Add to `invite_codes`: +- `email`: `String(255)`, nullable, indexed. +- `assigned_plan`: `String(50)`, non-null, server default `'free'`. +- `trial_duration_days`: `Integer`, nullable. +- `email_sent_at`: `DateTime(timezone=True)`, nullable. + +Constraints: +- `assigned_plan IN ('free','pro','team')`. +- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`. +- Optional consistency guard: `assigned_plan='free'` implies `trial_duration_days IS NULL`. + +Update model `backend/app/models/invite_code.py`: +- Add mapped columns above. +- Add computed properties: + - `has_trial: bool` (`trial_duration_days is not None and > 0`) + - `email_sent: bool` (`email_sent_at is not None`) + +## Phase 2: Resend Email Integration +Create `backend/app/core/email.py`: +- `EmailService.send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`. +- Returns `False` if `RESEND_API_KEY` missing. +- Catches provider failures and returns `False` (logs warning/error). +- Never blocks invite creation. + +Create `backend/app/templates/invite_email.html`: +- Monochrome branded HTML. +- Invite code, plan, optional trial text, signup CTA button. + +Update `backend/app/core/config.py`: +- `RESEND_API_KEY: Optional[str] = None` +- `FROM_EMAIL: str = "ResolutionFlow "` +- `email_enabled` property. + +Update `backend/requirements.txt`: +- Add `resend` package. + +## Phase 3: Backend Schemas + Endpoints + +### Invite code schemas +Update `backend/app/schemas/invite_code.py`: +- `InviteCodeCreate` adds: + - `email: Optional[EmailStr]` + - `assigned_plan: Literal['free','pro','team'] = 'free'` + - `trial_duration_days: Optional[int]` (1..90) +- `InviteCodeResponse` adds: + - `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at` + - computed flags `has_trial`, `email_sent`. + +### Invite endpoints +Update `backend/app/api/endpoints/invite.py`: +- `POST /invites` accepts new fields. +- Creates invite with plan/trial/email metadata. +- If email provided, attempts send: + - on success: set `email_sent_at`. + - on failure: invite still returns 201. +- Add audit log for invite creation with delivery result. +- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible. + +### Registration plan assignment +Update `backend/app/api/endpoints/auth.py`: +- If invite code is supplied and valid, load it and apply invite plan/trial regardless of `REQUIRE_INVITE_CODE`. +- For non-account-invite registrations: + - create subscription `plan=invite_code.assigned_plan` (fallback `free`). + - if `trial_duration_days` set: + - `status='trialing'` + - `current_period_start=now` + - `current_period_end=now + trial_duration_days`. + - else `status='active'`. +- Preserve account-invite join flow behavior. +- Mark invite as used post user creation. + +### Admin subscription + detail endpoints +Update `backend/app/api/endpoints/admin.py`: +- Enrich `GET /admin/users/{id}` response: + - base user fields + - account summary + - subscription summary + - recent sessions (10) + total count + - recent audit logs (10) + total count + - invite code used summary +- Add: + - `PUT /admin/users/{id}/subscription/plan` + - `PUT /admin/users/{id}/subscription/extend-trial` + +### Trial expiry check +Update `backend/app/api/deps.py`: +- In `get_current_active_user`, check account subscription. +- If `status='trialing'` and expired, auto-downgrade: + - `plan='free'`, `status='active'` + - clear/normalize trial period fields + - commit before returning user. + +## Phase 4: Backend Schema Additions +Use existing file `backend/app/schemas/subscription.py` (do not duplicate): +- Add `SubscriptionPlanUpdate`. +- Add `ExtendTrialRequest`. +- Keep/extend `SubscriptionResponse` as needed. + +Create `backend/app/schemas/user_detail.py`: +- `AccountSummary` +- `SessionSummary` +- `AuditLogSummary` +- `InviteCodeUsedSummary` +- `UserDetailResponse` (superset for enriched `/admin/users/{id}`). + +## Phase 5: Frontend Types + API Client +Update `frontend/src/types/admin.ts`: +- Invite response fields for email/plan/trial/email-sent metadata. +- New detail types: + - `UserDetail` + - `SubscriptionDetail` + - `SessionSummary` + - `AuditLogSummary` + - `AccountSummary`. + +Update `frontend/src/api/admin.ts`: +- Switch invite endpoints to `/invites`. +- Enhance `createInviteCode` payload. +- Add: + - `getUserDetail(userId)` + - `updateUserSubscriptionPlan(userId, plan)` + - `extendUserTrial(userId, days)`. + +## Phase 6: Frontend Invite Codes Page +Update `frontend/src/pages/admin/InviteCodesPage.tsx`: +- Create form fields: + - optional email + - plan selector (Free/Pro/Team) + - trial days input when plan != free +- Table additions: + - recipient + - plan badge + - trial column + - email sent indicator +- Preserve existing create/copy/delete actions and status badges. + +## Phase 7: Frontend User Detail Page +Create `frontend/src/pages/admin/UserDetailPage.tsx`: +- Header: name/email/role/active. +- Account & subscription card. +- Admin actions: + - change role + - change plan + - extend/start trial + - activate/deactivate +- Tabs: + - recent sessions + - audit logs +- Invite code card: + - code, assigned plan, creator. + +Update `frontend/src/router.tsx`: +- Add route `admin/users/:userId`. + +Update `frontend/src/pages/admin/UsersPage.tsx`: +- Make rows navigate to detail. +- Ensure action menu clicks do not trigger row navigation. + +## API / Interface Changes + +### Modified +- `POST /invites` + - new request fields: `email`, `assigned_plan`, `trial_duration_days`. +- `GET /invites` + - new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`. +- `GET /admin/users/{id}` + - enriched response with account/subscription/recent activity details. + +### Added +- `PUT /admin/users/{id}/subscription/plan` +- `PUT /admin/users/{id}/subscription/extend-trial` + +## Test Plan + +## Backend tests +1. Invite create with `assigned_plan + trial_duration_days` persists correctly. +2. Invite create with email: +- Resend success sets `email_sent_at`. +- Resend failure still returns 201 and does not set `email_sent_at`. +3. Registration with invite applies correct subscription plan/status/period fields. +4. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial. +5. Expired trial auto-downgrades on authenticated request. +6. Admin plan update endpoint updates subscription + audit logs. +7. Admin extend-trial endpoint converts/extends correctly + audit logs. +8. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps. + +## Frontend verification +1. Create invite with email + plan + trial from admin UI. +2. Confirm invite table renders recipient/plan/trial/email-sent. +3. Open user detail from users table. +4. Change plan and extend trial from detail page. +5. Confirm updated values refresh in UI. +6. `npm run build` passes. + +## Commands +- `cd backend && pytest --override-ini="addopts="` +- `cd frontend && npm run build` + +## Risks and Mitigations +- Endpoint drift (`/invite-codes` vs `/invites`): update admin API client and validate all admin invite calls. +- Subscription side-effects in auth/deps: centralize trial-expiry logic and cover with tests. +- Payload growth for user detail: cap related arrays at 10 and include totals. +- Email provider outages: best-effort send with logging, no invite creation failure. + +## Rollout +1. Deploy migration and backend changes. +2. Validate admin invite creation and registration path in staging. +3. Deploy frontend with new invite/user-detail UI. +4. Monitor audit logs and invite email delivery behavior post-release. + +## Assumptions +- Existing admin access control (`require_admin`) remains unchanged. +- Plan limits for `free/pro/team` are already configured in `plan_limits`. +- No mandatory template engine addition is required for this email template rendering path. diff --git a/docs/plans/2026-02-12-admin-invite-user-mgmt.md b/docs/plans/2026-02-12-admin-invite-user-mgmt.md new file mode 100644 index 00000000..904b9f1f --- /dev/null +++ b/docs/plans/2026-02-12-admin-invite-user-mgmt.md @@ -0,0 +1,390 @@ +# Admin Panel: Invite Codes + User Management Enhancement + +**Date:** 2026-02-12 +**Status:** Proposed — Combined Plan + +--- + +## Summary + +Enhance admin capabilities to: + +1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations. +2. Send invite emails via Resend (best-effort, non-blocking). +3. Apply invite-assigned plan/trial on registration. +4. Give admins a detailed user management view with subscription, session, and audit context. +5. Support admin subscription actions (change plan, extend/start trial). +6. Auto-downgrade expired trials during authenticated access checks. + +--- + +## Goals + +- Remove manual invite-code sharing workflow. +- Support controlled beta onboarding with plan + trial at invite level. +- Enable operational admin workflows for account/subscription lifecycle. +- Keep backward compatibility where practical and avoid unsafe breaking changes. + +--- + +## Non-Goals + +- Stripe billing workflow redesign. +- Full historical pagination for user-detail sessions/audits in this iteration. +- Rework of account invite (`/accounts/me/invites`) flow. + +--- + +## Key Decisions Locked + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Invite API path | Standardize on `/invites` | Already in use (`router = APIRouter(prefix="/invites")`). Update CLAUDE.md which incorrectly references `/invite-codes`. | +| User detail endpoint | Enrich existing `GET /admin/users/{id}` | One endpoint, richer response. No reason for admin to get a "lite" version. | +| Invite email matching | Advisory only (no strict enforcement) | The invite code itself is the security gate. Email is for admin tracking. Strict matching creates friction during beta. | +| Invite plan/trial application | Applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false` | Ensures plan/trial always carries through regardless of registration policy. | +| Trial duration bounds | 1–90 days | 90 days covers any realistic beta period. Protects against typos. Admin can always extend after expiry. | +| Extend trial behavior | May convert non-trialing subscriptions to `trialing` | Admin should have maximum control. Covers scenarios like forgotten trial assignment or second chances. | +| User detail payload | Recent summaries (latest 10 sessions + 10 audit logs) + total counts | Balances useful at-a-glance admin view with response performance. Full history via future paginated endpoints. | + +--- + +## Phase 1: Database Migration (030) + +**New file:** `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`) + +Add columns to `invite_codes`: + +- `email`: `String(255)`, nullable, indexed. +- `assigned_plan`: `String(50)`, non-null, server default `'free'`. +- `trial_duration_days`: `Integer`, nullable. +- `email_sent_at`: `DateTime(timezone=True)`, nullable. + +Database constraints: + +- `assigned_plan IN ('free', 'pro', 'team')`. +- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`. +- Consistency guard: `assigned_plan = 'free'` implies `trial_duration_days IS NULL`. + +**Update:** `backend/app/models/invite_code.py` + +- Add mapped columns for all new fields. +- Add computed properties: + - `has_trial: bool` — `trial_duration_days is not None and > 0` + - `email_sent: bool` — `email_sent_at is not None` + +--- + +## Phase 2: Resend Email Integration + +**New file:** `backend/app/core/email.py` + +- `EmailService` class with `send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`. +- Returns `False` if `RESEND_API_KEY` not set (graceful degradation). +- Catches provider failures, returns `False`, logs warning/error. +- Never blocks invite creation (best-effort delivery). + +**New file:** `backend/app/templates/invite_email.html` + +- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button. +- Shows invite code, plan name, trial duration if applicable, signup link. + +**Update:** `backend/app/core/config.py` + +- Add `RESEND_API_KEY: Optional[str] = None` +- Add `FROM_EMAIL: str = "ResolutionFlow "` +- Add `email_enabled` computed property. + +**Update:** `backend/requirements.txt` — add `resend` package. + +**Env vars required:** `RESEND_API_KEY`, `FROM_EMAIL` (has default). + +**Prerequisite:** DNS records (SPF, DKIM) must be configured in Resend for `resolutionflow.com` domain before production email delivery will work. + +--- + +## Phase 3: Backend Schemas + Endpoints + +### 3a. Invite Code Schemas + +**Update:** `backend/app/schemas/invite_code.py` + +`InviteCodeCreate` — add fields: + +- `email: Optional[EmailStr]` +- `assigned_plan: Literal['free', 'pro', 'team'] = 'free'` +- `trial_duration_days: Optional[int]` (validated 1–90) + +`InviteCodeResponse` — add fields: + +- `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at` +- Computed flags: `has_trial`, `email_sent` + +### 3b. Invite Endpoints + +**Update:** `backend/app/api/endpoints/invite.py` + +- `POST /invites` — accept new fields (email, assigned_plan, trial_duration_days). + - Create invite with plan/trial/email metadata. + - If email provided, attempt send via EmailService. + - On send success: set `email_sent_at`. + - On send failure: invite still returns 201. + - Add audit log entry for invite creation with delivery result. +- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible. + +### 3c. Registration Plan Assignment + +**Update:** `backend/app/api/endpoints/auth.py` (registration endpoint, around lines 178–183) + +- If invite code is supplied and valid, load it and apply invite plan/trial **regardless of `REQUIRE_INVITE_CODE` setting**. +- For non-account-invite registrations: + - Create subscription with `plan = invite_code.assigned_plan` (fallback `'free'`). + - If `trial_duration_days` is set: + - `status = 'trialing'` + - `current_period_start = now` + - `current_period_end = now + trial_duration_days` + - Else: `status = 'active'`. +- Preserve existing account-invite join flow behavior. +- Mark invite as used after user creation. + +### 3d. Admin Subscription + User Detail Endpoints + +**Update:** `backend/app/api/endpoints/admin.py` + +Enrich `GET /admin/users/{id}` response to include: + +- Base user fields (name, email, role, active status). +- Account summary (account name, display code). +- Subscription summary (plan, status, trial end date). +- Recent sessions: latest 10 + total count. +- Recent audit logs: latest 10 + total count. +- Invite code used summary (code, assigned plan, who created it). + +Add new endpoints: + +- `PUT /admin/users/{id}/subscription/plan` — change user's plan. +- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial, or convert to trialing if not already. + +Both endpoints should create audit log entries. + +### 3e. Trial Expiry Check + +**Update:** `backend/app/api/deps.py` — in `get_current_active_user` + +- Check account subscription status. +- If `status = 'trialing'` and `current_period_end < now`: + - Set `plan = 'free'`, `status = 'active'`. + - Clear/normalize trial period fields. + - Commit before returning user. + +**Note:** This is a lightweight login-time check. Users with active JWT sessions will retain access until token refresh. Acceptable for beta; revisit if stricter enforcement needed later. + +--- + +## Phase 4: Backend Schema Additions + +**Check first:** Verify whether `backend/app/schemas/subscription.py` already exists. If it does, extend it. If not, create it. + +Schemas needed in `backend/app/schemas/subscription.py`: + +- `SubscriptionPlanUpdate` — for plan change requests. +- `ExtendTrialRequest` — for trial extension requests. +- `SubscriptionResponse` — for subscription state in responses. + +**New file:** `backend/app/schemas/user_detail.py` + +- `AccountSummary` +- `SessionSummary` +- `AuditLogSummary` +- `InviteCodeUsedSummary` +- `UserDetailResponse` (superset response for enriched `/admin/users/{id}`) + +--- + +## Phase 5: Frontend Types + API Client + +**Update:** `frontend/src/types/admin.ts` + +- Enhanced `InviteCodeResponse` with email, plan, trial, email-sent fields. +- New types: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`. + +**Update:** `frontend/src/api/admin.ts` + +- Ensure invite endpoints target `/invites` (not `/invite-codes`). +- Enhance `createInviteCode` payload with new fields. +- Add: `getUserDetail(userId)`, `updateUserSubscriptionPlan(userId, plan)`, `extendUserTrial(userId, days)`. + +--- + +## Phase 6: Frontend — Enhanced Invite Codes Page + +**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx` + +Create form additions: + +- Email input (optional, validated). +- Plan selector dropdown (Free / Pro / Team). +- Trial duration input (number of days, shown only when plan ≠ free). + +Table additions: + +- "Recipient" column (email or "—"). +- "Plan" column with badge. +- "Trial" column (days or "—"). +- "Email Sent" indicator. + +Preserve existing create/copy/delete actions and status badges. + +--- + +## Phase 7: Frontend — User Detail Page + +**New file:** `frontend/src/pages/admin/UserDetailPage.tsx` + +Sections: + +- **Header** — name, email, role badges, active status. +- **Account & Subscription card** — plan, status, trial end date, account display code. +- **Admin Actions card** — Change Role, Change Plan, Extend/Start Trial, Activate/Deactivate (modal-based). +- **Recent Sessions tab** — tree name, started, completed, outcome. +- **Audit Logs tab** — action, resource, timestamp, expandable details. +- **Invite Code card** — code used, plan assigned, who created it. + +**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId`. + +**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable to navigate to detail page. Ensure action menu clicks (dropdowns, buttons) don't trigger row navigation. + +--- + +## File Inventory + +### Files to Create + +| File | Phase | +|------|-------| +| `backend/alembic/versions/030_enhance_invite_codes.py` | 1 | +| `backend/app/core/email.py` | 2 | +| `backend/app/templates/invite_email.html` | 2 | +| `backend/app/schemas/subscription.py` (verify doesn't exist first) | 4 | +| `backend/app/schemas/user_detail.py` | 4 | +| `frontend/src/pages/admin/UserDetailPage.tsx` | 7 | + +### Files to Modify + +| File | Phase | What Changes | +|------|-------|-------------| +| `backend/app/models/invite_code.py` | 1 | Add new columns + computed properties | +| `backend/app/core/config.py` | 2 | Add RESEND_API_KEY, FROM_EMAIL, email_enabled | +| `backend/requirements.txt` | 2 | Add resend package | +| `backend/app/schemas/invite_code.py` | 3a | Add email, plan, trial fields to create/response | +| `backend/app/api/endpoints/invite.py` | 3b | Accept new fields, send email, audit log | +| `backend/app/api/endpoints/auth.py` | 3c | Apply invite plan/trial on registration (lines ~178–183) | +| `backend/app/api/endpoints/admin.py` | 3d | Enrich user detail, add subscription endpoints | +| `backend/app/api/deps.py` | 3e | Trial expiry check in get_current_active_user | +| `frontend/src/types/admin.ts` | 5 | Enhanced invite + new detail types | +| `frontend/src/api/admin.ts` | 5 | New API functions, fix invite path | +| `frontend/src/pages/admin/InviteCodesPage.tsx` | 6 | Form + table enhancements | +| `frontend/src/pages/admin/UsersPage.tsx` | 7 | Clickable rows → detail page | +| `frontend/src/router.tsx` | 7 | Add user detail route | + +### Also Update (Housekeeping) + +| File | What Changes | +|------|-------------| +| `CLAUDE.md` | Fix invite codes endpoint reference from `/invite-codes` to `/invites` | + +--- + +## API / Interface Changes + +### Modified Endpoints + +- `POST /invites` — new request fields: `email`, `assigned_plan`, `trial_duration_days`. +- `GET /invites` — new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`. +- `GET /admin/users/{id}` — enriched response with account/subscription/recent activity details. + +### New Endpoints + +- `PUT /admin/users/{id}/subscription/plan` +- `PUT /admin/users/{id}/subscription/extend-trial` + +--- + +## Implementation Order + +1. Migration 030 (invite code fields) +2. Model update (invite_code.py) +3. Resend integration (email.py, config.py, template, requirements.txt) +4. Backend schemas (invite_code, subscription, user_detail) +5. Backend API (invite.py, auth.py, admin.py, deps.py) +6. Backend tests +7. Frontend types + API client +8. Frontend invite codes page enhancement +9. Frontend user detail page +10. End-to-end testing + +--- + +## Test Plan + +### Backend Tests + +1. Invite create with `assigned_plan` + `trial_duration_days` persists correctly. +2. Invite create with email — Resend success sets `email_sent_at`. +3. Invite create with email — Resend failure still returns 201, does not set `email_sent_at`. +4. Registration with invite applies correct subscription plan/status/period fields. +5. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial when code provided. +6. Expired trial auto-downgrades on authenticated request. +7. Admin plan update endpoint updates subscription + creates audit log. +8. Admin extend-trial endpoint converts/extends correctly + creates audit log. +9. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps (10 sessions, 10 audit logs). +10. Trial duration validation rejects values outside 1–90 range. +11. Free plan invite rejects trial_duration_days (consistency guard). + +### Frontend Verification + +1. Create invite with email + plan + trial from admin UI. +2. Confirm invite table renders recipient/plan/trial/email-sent columns. +3. Open user detail from users table (click row). +4. Change plan and extend trial from detail page. +5. Confirm updated values refresh in UI. +6. `cd frontend && npm run build` passes. + +### Commands + +``` +cd backend && pytest --override-ini="addopts=" +cd frontend && npm run build +``` + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +|------|-----------| +| Endpoint drift (`/invite-codes` vs `/invites`) | Update CLAUDE.md and admin API client. Verify all admin invite calls use `/invites`. | +| Subscription side-effects in auth/deps | Centralize trial-expiry logic. Cover with targeted tests. | +| Payload growth for user detail | Cap related arrays at 10 items, include total counts. | +| Email provider outages | Best-effort send with logging. Invite creation never fails due to email. | +| DNS not configured for Resend | Document as prerequisite. Email gracefully degrades when API key missing. | +| `subscription.py` may already exist | Verify before creating. Extend if present, create if not. | +| JWT session outlives trial expiry | Acceptable for beta. Document as known limitation. | + +--- + +## Rollout + +1. Deploy migration and backend changes. +2. Validate admin invite creation and registration path in staging. +3. Deploy frontend with new invite/user-detail UI. +4. Monitor audit logs and invite email delivery behavior post-release. + +--- + +## Assumptions + +- Existing admin access control (`require_admin`) remains unchanged. +- Plan limits for `free/pro/team` are already configured in `plan_limits`. +- No mandatory template engine addition is required for email template rendering. +- Alembic `env.py` already imports `InviteCode` model (per LESSONS-LEARNED.md). diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 37f3a466..080d16e4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -13,6 +13,9 @@ import type { AccountFeatureOverrideCreate, AdminCategory, GlobalCategoryCreate, + InviteCodeResponse, + InviteCodeCreateRequest, + UserDetailResponse, } from '@/types/admin' export const adminApi = { @@ -38,13 +41,21 @@ export const adminApi = { moveUserAccount: (id: string, display_code: string) => api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data), - // Invite Codes (existing endpoints) + // Users - detail + subscription + getUserDetail: (id: string) => + api.get(`/admin/users/${id}`).then(r => r.data), + updateUserSubscriptionPlan: (id: string, plan: string) => + api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data), + extendUserTrial: (id: string, days: number) => + api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data), + + // Invite Codes listInviteCodes: (params?: Record) => - api.get('/invite-codes', { params }).then(r => r.data), - createInviteCode: (data?: { expires_at?: string }) => - api.post('/invite-codes', data || {}).then(r => r.data), - deleteInviteCode: (id: string) => - api.delete(`/invite-codes/${id}`), + api.get('/invites', { params }).then(r => r.data), + createInviteCode: (data: InviteCodeCreateRequest = {}) => + api.post('/invites', data).then(r => r.data), + deleteInviteCode: (code: string) => + api.delete(`/invites/${code}`), // Audit Logs listAuditLogs: (params?: Record) => diff --git a/frontend/src/pages/admin/InviteCodesPage.tsx b/frontend/src/pages/admin/InviteCodesPage.tsx index 398849bf..c572aa14 100644 --- a/frontend/src/pages/admin/InviteCodesPage.tsx +++ b/frontend/src/pages/admin/InviteCodesPage.tsx @@ -1,33 +1,45 @@ import { useState, useEffect, useCallback } from 'react' -import { Plus, Copy, Trash2, Ticket } from 'lucide-react' +import { Plus, Copy, Trash2, Ticket, Mail, MailCheck } from 'lucide-react' import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin' import type { Column } from '@/components/admin' import { Modal } from '@/components/common/Modal' import { adminApi } from '@/api/admin' import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' +import type { InviteCodeResponse, InviteCodeCreateRequest } from '@/types/admin' -interface InviteCode { - id: string - code: string - created_by_id: string - used_by_id: string | null - is_active: boolean - expires_at: string | null - created_at: string +const PLAN_OPTIONS = [ + { value: 'free', label: 'Free' }, + { value: 'pro', label: 'Pro' }, + { value: 'team', label: 'Team' }, +] as const + +const planBadgeVariant = (plan: string): 'success' | 'destructive' | 'warning' | 'default' => { + switch (plan) { + case 'pro': return 'success' + case 'team': return 'warning' + default: return 'default' + } } export function InviteCodesPage() { - const [codes, setCodes] = useState([]) + const [codes, setCodes] = useState([]) const [loading, setLoading] = useState(true) const [createOpen, setCreateOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // Form state + const [email, setEmail] = useState('') const [expiresInDays, setExpiresInDays] = useState('') + const [assignedPlan, setAssignedPlan] = useState<'free' | 'pro' | 'team'>('free') + const [trialDays, setTrialDays] = useState('') + const [note, setNote] = useState('') const fetchCodes = useCallback(async () => { setLoading(true) try { const data = await adminApi.listInviteCodes() - setCodes(Array.isArray(data) ? data : data.items || []) + setCodes(Array.isArray(data) ? data : []) } catch { toast.error('Failed to load invite codes') } finally { @@ -37,18 +49,36 @@ export function InviteCodesPage() { useEffect(() => { fetchCodes() }, [fetchCodes]) + const resetForm = () => { + setEmail('') + setExpiresInDays('') + setAssignedPlan('free') + setTrialDays('') + setNote('') + } + const handleCreate = async () => { + setCreating(true) try { - const expiresAt = expiresInDays - ? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString() - : undefined - await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined) + const data: InviteCodeCreateRequest = {} + if (expiresInDays) { + data.expires_at = new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString() + } + if (email.trim()) data.email = email.trim() + if (note.trim()) data.note = note.trim() + data.assigned_plan = assignedPlan + if (assignedPlan !== 'free' && trialDays) { + data.trial_duration_days = parseInt(trialDays) + } + await adminApi.createInviteCode(data) toast.success('Invite code created') setCreateOpen(false) - setExpiresInDays('') + resetForm() fetchCodes() } catch { toast.error('Failed to create invite code') + } finally { + setCreating(false) } } @@ -57,9 +87,9 @@ export function InviteCodesPage() { toast.success('Code copied to clipboard') } - const handleDelete = async (id: string) => { + const handleDelete = async (code: string) => { try { - await adminApi.deleteInviteCode(id) + await adminApi.deleteInviteCode(code) toast.success('Invite code deleted') fetchCodes() } catch { @@ -67,7 +97,12 @@ export function InviteCodesPage() { } } - const columns: Column[] = [ + const inputClass = cn( + 'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', + 'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20' + ) + + const columns: Column[] = [ { key: 'code', header: 'Code', @@ -75,14 +110,48 @@ export function InviteCodesPage() { {c.code} ), }, + { + key: 'email', + header: 'Recipient', + render: (c) => c.email ? ( +
+ {c.email_sent ? ( + + ) : ( + + )} + {c.email} +
+ ) : ( + + ), + }, + { + key: 'plan', + header: 'Plan', + render: (c) => ( + + {c.assigned_plan.charAt(0).toUpperCase() + c.assigned_plan.slice(1)} + + ), + }, + { + key: 'trial', + header: 'Trial', + render: (c) => c.has_trial ? ( + {c.trial_duration_days}d + ) : ( + + ), + }, { key: 'status', header: 'Status', render: (c) => { - if (c.used_by_id) return Used - if (!c.is_active) return Inactive - if (c.expires_at && new Date(c.expires_at) < new Date()) return Expired - return Active + if (c.is_used) return Used + if (c.is_expired) return Expired + if (c.is_valid) return Active + return Inactive }, }, { @@ -114,12 +183,12 @@ export function InviteCodesPage() { icon: , onClick: () => handleCopy(c.code), }, - { + ...(!c.is_used ? [{ label: 'Delete', icon: , - onClick: () => handleDelete(c.id), + onClick: () => handleDelete(c.code), destructive: true, - }, + }] : []), ]} /> ), }, @@ -129,7 +198,7 @@ export function InviteCodesPage() {
setCreateOpen(true)} @@ -160,27 +229,73 @@ export function InviteCodesPage() { setCreateOpen(false)} + onClose={() => { setCreateOpen(false); resetForm() }} title="Create Invite Code" size="sm" footer={
} >
+
+ + setEmail(e.target.value)} + placeholder="Optional — will send invite email" + className={inputClass} + /> +
+ +
+ + +
+ + {assignedPlan !== 'free' && ( +
+ + setTrialDays(e.target.value)} + placeholder="e.g. 14 (1-90)" + min={1} + max={90} + className={inputClass} + /> +

Leave empty for no trial — account gets full plan immediately.

+
+ )} +
setExpiresInDays(e.target.value)} placeholder="Leave empty for no expiry" - className={cn( - 'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', - 'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20' - )} + className={inputClass} + /> +
+ +
+ + setNote(e.target.value)} + placeholder="Optional note (e.g. who this is for)" + className={inputClass} />
diff --git a/frontend/src/pages/admin/UserDetailPage.tsx b/frontend/src/pages/admin/UserDetailPage.tsx new file mode 100644 index 00000000..26be7266 --- /dev/null +++ b/frontend/src/pages/admin/UserDetailPage.tsx @@ -0,0 +1,422 @@ +import { useState, useEffect, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket } from 'lucide-react' +import { StatusBadge } from '@/components/admin' +import { Modal } from '@/components/common/Modal' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { UserDetailResponse } from '@/types/admin' + +const PLAN_OPTIONS = ['free', 'pro', 'team'] as const + +export function UserDetailPage() { + const { userId } = useParams<{ userId: string }>() + const navigate = useNavigate() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + // Modal state + const [planModalOpen, setPlanModalOpen] = useState(false) + const [selectedPlan, setSelectedPlan] = useState('') + const [trialModalOpen, setTrialModalOpen] = useState(false) + const [trialDays, setTrialDays] = useState('14') + const [activeTab, setActiveTab] = useState<'sessions' | 'audit'>('sessions') + + const fetchUser = useCallback(async () => { + if (!userId) return + setLoading(true) + try { + const data = await adminApi.getUserDetail(userId) + setUser(data) + } catch { + toast.error('Failed to load user details') + } finally { + setLoading(false) + } + }, [userId]) + + useEffect(() => { fetchUser() }, [fetchUser]) + + const handleChangePlan = async () => { + if (!userId || !selectedPlan) return + try { + await adminApi.updateUserSubscriptionPlan(userId, selectedPlan) + toast.success(`Plan changed to ${selectedPlan}`) + setPlanModalOpen(false) + fetchUser() + } catch { + toast.error('Failed to change plan') + } + } + + const handleExtendTrial = async () => { + if (!userId || !trialDays) return + try { + await adminApi.extendUserTrial(userId, parseInt(trialDays)) + toast.success(`Trial extended by ${trialDays} days`) + setTrialModalOpen(false) + fetchUser() + } catch { + toast.error('Failed to extend trial') + } + } + + const handleToggleActive = async () => { + if (!userId || !user) return + try { + if (user.is_active) { + await adminApi.deactivateUser(userId) + toast.success('User deactivated') + } else { + await adminApi.activateUser(userId) + toast.success('User activated') + } + fetchUser() + } catch { + toast.error('Failed to update user status') + } + } + + const inputClass = cn( + 'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', + 'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20' + ) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return ( +
User not found
+ ) + } + + const fmt = (d: string | null) => d ? new Date(d).toLocaleDateString() : '—' + const fmtFull = (d: string | null) => d ? new Date(d).toLocaleString() : '—' + + return ( +
+ {/* Header */} +
+ +
+

+ {user.full_name || user.email} +

+

{user.email}

+
+
+ {user.is_super_admin && ( + + Super Admin + + )} + + {user.is_active ? 'Active' : 'Inactive'} + + {user.role} +
+
+ + {/* Account & Subscription */} +
+
+

+ Account & Subscription +

+
+ {user.account && ( + <> +
+
Account
+
{user.account.name}
+
+ {user.account.display_code && ( +
+
Display Code
+
{user.account.display_code}
+
+ )} + + )} + {user.subscription ? ( + <> +
+
Plan
+
+ {user.subscription.plan.charAt(0).toUpperCase() + user.subscription.plan.slice(1)} +
+
+
+
Status
+
+ + {user.subscription.status} + +
+
+ {user.subscription.current_period_end && ( +
+
Period End
+
{fmt(user.subscription.current_period_end)}
+
+ )} + + ) : ( +
No subscription
+ )} +
+
Joined
+
{fmt(user.created_at)}
+
+
+
+ + {/* Admin Actions */} +
+

+ Admin Actions +

+
+ + + +
+
+
+ + {/* Invite Code Used */} + {user.invite_code_used && ( +
+

+ + Invite Code Used +

+
+
+
Code
+
{user.invite_code_used.code}
+
+
+
Plan Assigned
+
+ {user.invite_code_used.assigned_plan.charAt(0).toUpperCase() + user.invite_code_used.assigned_plan.slice(1)} +
+
+
+
Trial Days
+
{user.invite_code_used.trial_duration_days ?? '—'}
+
+
+
Created By
+
{user.invite_code_used.created_by_email ?? '—'}
+
+
+
+ )} + + {/* Tabs: Sessions / Audit Logs */} +
+
+ + +
+ +
+ {activeTab === 'sessions' && ( + user.recent_sessions.length > 0 ? ( + + + + + + + + + + + {user.recent_sessions.map(s => ( + + + + + + + ))} + +
TreeStartedCompletedOutcome
{s.tree_name ?? '—'}{fmtFull(s.started_at)}{fmtFull(s.completed_at)} + {s.outcome ? ( + + {s.outcome} + + ) : ( + + )} +
+ ) : ( +
No sessions yet
+ ) + )} + + {activeTab === 'audit' && ( + user.recent_audit_logs.length > 0 ? ( + + + + + + + + + + {user.recent_audit_logs.map(a => ( + + + + + + ))} + +
ActionResourceTime
{a.action}{a.resource_type ?? '—'}{fmtFull(a.created_at)}
+ ) : ( +
No audit logs yet
+ ) + )} +
+
+ + {/* Change Plan Modal */} + setPlanModalOpen(false)} + title="Change Subscription Plan" + size="sm" + footer={ +
+ + +
+ } + > +
+ + +
+
+ + {/* Extend Trial Modal */} + setTrialModalOpen(false)} + title={user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'} + size="sm" + footer={ +
+ + +
+ } + > +
+ + setTrialDays(e.target.value)} + min={1} + max={90} + className={inputClass} + /> +

1-90 days. Will convert to trialing status if not already.

+
+
+
+ ) +} + +export default UserDetailPage diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx index 9a0082b8..1b2ab00b 100644 --- a/frontend/src/pages/admin/UsersPage.tsx +++ b/frontend/src/pages/admin/UsersPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react' -import { UserCheck, UserX, Shield, ArrowRightLeft } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink } from 'lucide-react' import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin' import type { Column } from '@/components/admin' import { Modal } from '@/components/common/Modal' @@ -21,6 +22,7 @@ interface AdminUser { } export function UsersPage() { + const navigate = useNavigate() const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') @@ -140,6 +142,11 @@ export function UsersPage() { className: 'w-12', render: (u) => ( , + onClick: () => navigate(`/admin/users/${u.id}`), + }, { label: 'Change Role', icon: , diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fe0ae2b0..ab400a01 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -21,6 +21,7 @@ const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage')) const AdminLayout = lazy(() => import('@/components/admin/AdminLayout')) const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage')) const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage')) +const AdminUserDetailPage = lazy(() => import('@/pages/admin/UserDetailPage')) const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage')) const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage')) @@ -143,6 +144,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'users/:userId', + element: ( + }> + + + ), + }, { path: 'invite-codes', element: ( diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index f67cac61..69e3f25a 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -128,3 +128,89 @@ export interface GlobalCategoryCreate { slug: string description?: string | null } + +// Invite code types (enhanced) +export interface InviteCodeResponse { + id: string + code: string + created_by_id: string + used_by_id: string | null + expires_at: string | null + note: string | null + created_at: string + used_at: string | null + is_used: boolean + is_expired: boolean + is_valid: boolean + email: string | null + assigned_plan: string + trial_duration_days: number | null + email_sent_at: string | null + has_trial: boolean + email_sent: boolean +} + +export interface InviteCodeCreateRequest { + expires_at?: string | null + note?: string | null + email?: string | null + assigned_plan?: 'free' | 'pro' | 'team' + trial_duration_days?: number | null +} + +// User detail types +export interface AccountSummary { + id: string + name: string + display_code: string | null +} + +export interface SubscriptionSummary { + id: string + plan: string + status: string + current_period_start: string | null + current_period_end: string | null +} + +export interface SessionSummary { + id: string + tree_name: string | null + started_at: string + completed_at: string | null + outcome: string | null +} + +export interface AuditLogSummary { + id: string + action: string + resource_type: string | null + resource_id: string | null + created_at: string + details: Record | null +} + +export interface InviteCodeUsedSummary { + code: string + assigned_plan: string + trial_duration_days: number | null + created_by_email: string | null +} + +export interface UserDetailResponse { + id: string + email: string + full_name: string | null + role: string + is_active: boolean + is_super_admin: boolean + is_team_admin: boolean + created_at: string + account: AccountSummary | null + subscription: SubscriptionSummary | null + invite_code_used: InviteCodeUsedSummary | null + recent_sessions: SessionSummary[] + total_sessions: number + recent_audit_logs: AuditLogSummary[] + total_audit_logs: number +}