Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14bd4e33fb | |||
| 7a5a48fb4c | |||
| 6bf0746b94 | |||
| 7a7daf8100 | |||
| 3cc0b2e1b4 | |||
| 2e6c2c5c17 | |||
| 0d4cbe24dd | |||
| 9174c88a2e | |||
| 32c64f9c03 | |||
| aec40d03ac |
@@ -0,0 +1,106 @@
|
|||||||
|
# T02: Company + Contact + Import/Export System
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Project: LeoCRM (greenfield rewrite, Option C)
|
||||||
|
- Repo: /a0/usr/workdir/dev-projects/leocrm
|
||||||
|
- T01 COMPLETE: auth, multi-tenant, RBAC, sessions, audit, notifications all working
|
||||||
|
- T01 commit: 7a7daf8 (pushed to Forgejo)
|
||||||
|
- Tech: FastAPI + SQLAlchemy 2.0 async + PostgreSQL + Redis + Pydantic v2
|
||||||
|
|
||||||
|
## Existing T01 Code to Build On
|
||||||
|
- `app/models/company.py` (40 lines) — Company model skeleton, needs Contact + CompanyContact models
|
||||||
|
- `app/routes/companies.py` (210 lines) — Company CRUD skeleton, needs expansion + Contact routes
|
||||||
|
- `app/schemas/company.py` (23 lines) — Company schema, needs Contact schemas
|
||||||
|
- `app/core/db/__init__.py` — Engine, Session, Base, TenantMixin, set_tenant_context
|
||||||
|
- `app/deps.py` — get_current_user, require_admin, get_tenant_id
|
||||||
|
- `app/core/audit.py` — log_audit function
|
||||||
|
- `app/core/notifications.py` — create_notification
|
||||||
|
- `tests/conftest.py` — Test fixtures with TRUNCATE CASCADE (DO NOT modify truncate list without checking table names)
|
||||||
|
|
||||||
|
## Requirements (25)
|
||||||
|
F-COMP-01..08, F-CONT-01..07, F-DATA-01..04, F-MIG-01, F-CORE-06, F-CORE-11, F-CORE-13, F-SEARCH-01, F-TEST-01
|
||||||
|
|
||||||
|
## Acceptance Criteria (24)
|
||||||
|
1. GET /api/v1/companies → 200 + paginated (total/page/page_size)
|
||||||
|
2. GET /api/v1/companies?search=Tech → 200 + FTS results (tsvector)
|
||||||
|
3. GET /api/v1/companies?industry=IT&sort_by=name&sort_order=asc → 200 + filtered+sorted
|
||||||
|
4. POST /api/v1/companies valid → 201 + company object
|
||||||
|
5. POST /api/v1/companies missing name → 422
|
||||||
|
6. GET /api/v1/companies/{id} → 200 + detail inkl. contacts array
|
||||||
|
7. PUT /api/v1/companies/{id} → 200 + updated
|
||||||
|
8. DELETE /api/v1/companies/{id} → 204, deleted_at gesetzt (soft-delete)
|
||||||
|
9. DELETE /api/v1/companies/{id}?cascade=true → 204, company + links geloescht
|
||||||
|
10. POST /api/v1/companies/{id}/contacts/{cid} → 200, N:M link
|
||||||
|
11. DELETE /api/v1/companies/{id}/contacts/{cid} → 204, N:M unlink
|
||||||
|
12. GET /api/v1/companies/export?format=csv → 200 + text/csv
|
||||||
|
13. GET /api/v1/companies/export?format=xlsx → 200 + openxmlformats
|
||||||
|
14. GET /api/v1/contacts → 200 + paginated
|
||||||
|
15. POST /api/v1/contacts mit company_ids array → 201 + N:M links
|
||||||
|
16. GET /api/v1/contacts/{id} → 200 + detail inkl. companies array
|
||||||
|
17. PUT /api/v1/contacts/{id} → 200
|
||||||
|
18. DELETE /api/v1/contacts/{id} → 204, soft-delete
|
||||||
|
19. DELETE /api/v1/contacts/{id}?gdpr=true → 204, hard-delete + deletion_log
|
||||||
|
20. POST /api/v1/import CSV + entity_type=companies → 200 + result
|
||||||
|
21. POST /api/v1/import/preview CSV → 200 + dry-run (no DB changes)
|
||||||
|
22. GET /api/v1/companies/{id}/emails → 200 (empty array, mail plugin inactive)
|
||||||
|
23. Audit log entry on every company/contact mutation
|
||||||
|
24. Soft-deleted company not in GET list (deleted_at IS NULL filter)
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
### New Files:
|
||||||
|
- `app/models/contact.py` — Contact model + CompanyContact (N:M join table)
|
||||||
|
- `app/schemas/contact.py` — Contact schemas (create/update/read/list)
|
||||||
|
- `app/services/company_service.py` — Company CRUD + search + filter + pagination
|
||||||
|
- `app/services/contact_service.py` — Contact CRUD + N:M linking
|
||||||
|
- `app/services/import_export_service.py` — CSV import/export, XLSX export, dry-run preview
|
||||||
|
- `app/routes/contacts.py` — Contact CRUD + N:M endpoints
|
||||||
|
- `app/routes/import_export.py` — Import/export endpoints
|
||||||
|
- `tests/test_companies.py` — Company CRUD + search + filter + export tests
|
||||||
|
- `tests/test_contacts.py` — Contact CRUD + N:M + GDPR delete tests
|
||||||
|
- `tests/test_import_export.py` — CSV import + preview + export tests
|
||||||
|
|
||||||
|
### Modify:
|
||||||
|
- `app/models/company.py` — Add soft-delete (deleted_at), FTS tsvector, ensure TenantMixin
|
||||||
|
- `app/routes/companies.py` — Expand to full CRUD + search + filter + export + N:M endpoints
|
||||||
|
- `app/schemas/company.py` — Add pagination, search, filter schemas
|
||||||
|
- `app/models/__init__.py` — Register Contact, CompanyContact
|
||||||
|
- `app/routes/__init__.py` — Register contacts + import_export routers
|
||||||
|
- `app/schemas/__init__.py` — Register contact schemas
|
||||||
|
- `app/services/__init__.py` — Register new services
|
||||||
|
- `tests/conftest.py` — Add contacts, company_contacts to TRUNCATE list
|
||||||
|
- `alembic/versions/` — New migration for contacts + company_contacts + FTS indexes
|
||||||
|
|
||||||
|
## Dependencies to Install
|
||||||
|
- `openpyxl>=3.1` — XLSX export (add to requirements.txt)
|
||||||
|
|
||||||
|
## Forbidden Patterns
|
||||||
|
- NO JWT tokens (session-based auth from T01)
|
||||||
|
- NO SQLite (PostgreSQL only)
|
||||||
|
- NO wildcard CORS
|
||||||
|
- NO raw SQL without tenant context (use set_config or ORM filtering)
|
||||||
|
- NO hardcoded secrets
|
||||||
|
- NO legacy code reuse
|
||||||
|
- NO .test TLD emails (Pydantic v2 rejects — use .com)
|
||||||
|
- NO `SET LOCAL` with bound params (use `SELECT set_config()` instead)
|
||||||
|
- NO raising HTTPException in middleware (return JSONResponse)
|
||||||
|
- NO POST without status_code=201
|
||||||
|
|
||||||
|
## Test Spec
|
||||||
|
- Commands: `cd /a0/usr/workdir/dev-projects/leocrm && source venv/bin/activate && python -m pytest tests/test_companies.py tests/test_contacts.py tests/test_import_export.py -v --tb=short`
|
||||||
|
- Coverage: `python -m pytest tests/test_companies.py tests/test_contacts.py tests/test_import_export.py --cov=app/routes/companies --cov=app/routes/contacts --cov=app/services --cov-report=term-missing`
|
||||||
|
- Target: 85% for new modules
|
||||||
|
- All 24 ACs must pass
|
||||||
|
|
||||||
|
## Token Rule
|
||||||
|
- Use `text_editor:read` for MODIFY, `text_editor:write` for NEW, `code_execution_tool:terminal` for test runs
|
||||||
|
- Reference files by path, not inline
|
||||||
|
- Multi-file output: separate files, not one big file
|
||||||
|
|
||||||
|
## JSON Tool Examples
|
||||||
|
Use this format for all tool calls:
|
||||||
|
```json
|
||||||
|
{"tool_name":"text_editor","tool_args":{"action":"write","path":"/a0/usr/workdir/dev-projects/leocrm/app/models/contact.py","content":"..."}}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"tool_name":"code_execution_tool","tool_args":{"runtime":"terminal","session":0,"code":"cd /a0/usr/workdir/dev-projects/leocrm && source venv/bin/activate && python -m pytest tests/test_companies.py -v --tb=short"}}
|
||||||
|
```
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# T03: Plugin System Framework
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Project: LeoCRM (greenfield rewrite, Option C)
|
||||||
|
- Repo: /a0/usr/workdir/dev-projects/leocrm
|
||||||
|
- T01 COMPLETE: auth, multi-tenant, RBAC, sessions, audit, notifications
|
||||||
|
- T01 commit: 7a7daf8 (pushed to Forgejo)
|
||||||
|
- Tech: FastAPI + SQLAlchemy 2.0 async + PostgreSQL + Redis + Pydantic v2
|
||||||
|
|
||||||
|
## Existing T01 Code to Build On
|
||||||
|
- `app/core/event_bus.py` — Event bus (0% coverage, needs integration)
|
||||||
|
- `app/core/service_container.py` — DI container (0% coverage, needs integration)
|
||||||
|
- `app/core/db/__init__.py` — Engine, Session, Base, TenantMixin, set_tenant_context
|
||||||
|
- `app/deps.py` — get_current_user, require_admin, get_tenant_id
|
||||||
|
- `app/core/audit.py` — log_audit function
|
||||||
|
- `app/main.py` — create_app factory, mounts routers, middleware
|
||||||
|
- `app/routes/__init__.py` — router aggregator
|
||||||
|
- `tests/conftest.py` — Test fixtures with TRUNCATE CASCADE
|
||||||
|
|
||||||
|
## Requirements (7)
|
||||||
|
F-PLUGIN-01: Plugin-System für Module — Module als Plugins, Daten austauschbar
|
||||||
|
F-PLUGIN-02: Plugin-Schnittstellen-Definition — API-Contract, Lifecycle-Hooks, Manifest, Abhängigkeiten
|
||||||
|
F-CORE-01: Multi-Tenant-Architektur
|
||||||
|
F-CORE-03: RBAC
|
||||||
|
F-CORE-04: Audit-Log
|
||||||
|
F-CORE-05: Event-System
|
||||||
|
F-TEST-01: Test-Coverage
|
||||||
|
|
||||||
|
## Acceptance Criteria (14)
|
||||||
|
1. GET /api/v1/plugins → 200 + list of plugins with status
|
||||||
|
2. POST /api/v1/plugins/{name}/install → 200, plugin status=installed, migrations run
|
||||||
|
3. POST /api/v1/plugins/{name}/activate → 200, plugin status=active, routes registered
|
||||||
|
4. POST /api/v1/plugins/{name}/deactivate → 200, plugin status=inactive, routes unregistered
|
||||||
|
5. DELETE /api/v1/plugins/{name} → 200, plugin removed
|
||||||
|
6. DELETE /api/v1/plugins/{name}?remove_data=true → 200, plugin tables dropped
|
||||||
|
7. GET /api/v1/plugins/manifest → 200 + manifest schema documentation
|
||||||
|
8. Plugin activation registers event listeners on event bus
|
||||||
|
9. Plugin deactivation unregisters event listeners
|
||||||
|
10. Plugin migration creates tables with tenant_id column
|
||||||
|
11. Plugin migration validator rejects tables without tenant_id
|
||||||
|
12. Plugin DB migrations tracked in plugin_migrations table
|
||||||
|
13. Activating already-active plugin → idempotent (200, no error)
|
||||||
|
14. Deactivating inactive plugin → idempotent (200)
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
### New Files:
|
||||||
|
- `app/models/plugin.py` — Plugin, PluginMigration (plugin_migrations tracking table)
|
||||||
|
- `app/schemas/plugin.py` — Plugin schemas (manifest, status, install/activate response)
|
||||||
|
- `app/services/plugin_service.py` — Plugin lifecycle: discover, install, activate, deactivate, uninstall
|
||||||
|
- `app/routes/plugins.py` — Plugin endpoints (list/install/activate/deactivate/uninstall/manifest)
|
||||||
|
- `app/plugins/__init__.py` — Plugin package init
|
||||||
|
- `app/plugins/base.py` — BasePlugin abstract class with lifecycle hooks
|
||||||
|
- `app/plugins/manifest.py` — PluginManifest Pydantic schema (name, version, dependencies, routes, events, migrations)
|
||||||
|
- `app/plugins/registry.py` — Plugin registry (in-memory + DB-backed status)
|
||||||
|
- `app/plugins/migration_runner.py` — Plugin DB migration runner + validator (tenant_id check)
|
||||||
|
- `app/plugins/builtins/__init__.py` — Built-in plugins directory (empty for now, just structure)
|
||||||
|
- `tests/test_plugins.py` — Plugin lifecycle tests (install/activate/deactivate/uninstall/idempotent)
|
||||||
|
|
||||||
|
### Modify:
|
||||||
|
- `app/models/__init__.py` — Register Plugin, PluginMigration
|
||||||
|
- `app/routes/__init__.py` — Register plugins router
|
||||||
|
- `app/schemas/__init__.py` — Register plugin schemas
|
||||||
|
- `app/services/__init__.py` — Register plugin_service
|
||||||
|
- `app/main.py` — Initialize plugin registry on startup (discover builtins)
|
||||||
|
- `app/core/event_bus.py` — Ensure register/unregister listener API works for plugins
|
||||||
|
- `app/core/service_container.py` — Ensure plugins can receive db, cache, event_bus, storage, notifications
|
||||||
|
- `tests/conftest.py` — Add plugins, plugin_migrations to TRUNCATE list
|
||||||
|
- `alembic/versions/` — New migration for plugins + plugin_migrations tables
|
||||||
|
|
||||||
|
## Plugin Lifecycle Design
|
||||||
|
```
|
||||||
|
discovered → installed → active → inactive → uninstalled
|
||||||
|
↑ ↓
|
||||||
|
└──────────────────────┘ (can re-activate)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Manifest Schema (Pydantic v2)
|
||||||
|
```python
|
||||||
|
class PluginManifest(BaseModel):
|
||||||
|
name: str # unique identifier
|
||||||
|
version: str # semver
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
dependencies: list[str] = [] # other plugin names required
|
||||||
|
routes: list[dict] = [] # route definitions
|
||||||
|
events: list[str] = [] # event names to listen
|
||||||
|
migrations: list[str] = [] # migration file names
|
||||||
|
permissions: list[str] = [] # required permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
## BasePlugin Abstract Class
|
||||||
|
```python
|
||||||
|
class BasePlugin(ABC):
|
||||||
|
manifest: PluginManifest
|
||||||
|
|
||||||
|
async def on_install(self, db, service_container): ...
|
||||||
|
async def on_activate(self, db, service_container, event_bus): ...
|
||||||
|
async def on_deactivate(self, db, service_container, event_bus): ...
|
||||||
|
async def on_uninstall(self, db, service_container): ...
|
||||||
|
def get_routes(self) -> list[APIRouter]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forbidden Patterns
|
||||||
|
- NO JWT tokens (session-based auth from T01)
|
||||||
|
- NO SQLite (PostgreSQL only)
|
||||||
|
- NO wildcard CORS
|
||||||
|
- NO raw SQL without tenant context
|
||||||
|
- NO hardcoded secrets
|
||||||
|
- NO .test TLD emails (use .com)
|
||||||
|
- NO `SET LOCAL` with bound params (use `SELECT set_config()`)
|
||||||
|
- NO raising HTTPException in middleware (return JSONResponse)
|
||||||
|
- NO POST without status_code=201 (where applicable)
|
||||||
|
- NO plugin tables without tenant_id column (validator enforces)
|
||||||
|
|
||||||
|
## Test Spec
|
||||||
|
- Commands: `cd /a0/usr/workdir/dev-projects/leocrm && source venv/bin/activate && python -m pytest tests/test_plugins.py -v --tb=short`
|
||||||
|
- Coverage: `python -m pytest tests/test_plugins.py --cov=app/plugins --cov-report=term-missing`
|
||||||
|
- Target: 85% for plugin modules
|
||||||
|
- All 14 ACs must pass
|
||||||
|
|
||||||
|
## Token Rule
|
||||||
|
- Use `text_editor:read` for MODIFY, `text_editor:write` for NEW, `code_execution_tool:terminal` for test runs
|
||||||
|
- Reference files by path, not inline
|
||||||
|
- Multi-file output: separate files, not one big file
|
||||||
|
|
||||||
|
## JSON Tool Examples
|
||||||
|
```json
|
||||||
|
{"tool_name":"text_editor","tool_args":{"action":"write","path":"/a0/usr/workdir/dev-projects/leocrm/app/plugins/base.py","content":"..."}}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"tool_name":"code_execution_tool","tool_args":{"runtime":"terminal","session":0,"code":"cd /a0/usr/workdir/dev-projects/leocrm && source venv/bin/activate && python -m pytest tests/test_plugins.py -v --tb=short"}}
|
||||||
|
```
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# T07a — Frontend Core SPA — Shell, Auth, Routing, i18n, UI Library, Accessibility
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Frontend Directory
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm/frontend/
|
||||||
|
|
||||||
|
## Tech Stack (CONFIRMED from architecture.md)
|
||||||
|
- React 18 + Vite + TypeScript
|
||||||
|
- React Router v6
|
||||||
|
- TanStack Query (React Query v5) for server state
|
||||||
|
- Zustand for client state
|
||||||
|
- react-i18next for i18n (de/en)
|
||||||
|
- React Hook Form + Zod for forms
|
||||||
|
- Tailwind CSS with design tokens from prototype
|
||||||
|
- Vitest for testing
|
||||||
|
|
||||||
|
## Backend API (already running, T01-T03 complete)
|
||||||
|
- Base URL: http://localhost:8000
|
||||||
|
- Auth: session cookie (leocrm_session), SameSite=strict
|
||||||
|
- CORS: http://localhost:5173 (Vite dev) allowed
|
||||||
|
- Endpoints available: /api/v1/auth/login, /api/v1/auth/logout, /api/v1/auth/me, /api/v1/auth/password-reset/request, /api/v1/auth/password-reset/confirm, /api/v1/users, /api/v1/companies, /api/v1/contacts, /api/v1/notifications, /api/v1/plugins, /health
|
||||||
|
|
||||||
|
## Requirements (23)
|
||||||
|
F-AUTH-01, F-AUTH-02, F-AUTH-03, F-AUTH-05, F-AUTH-07, F-CORE-06, F-CORE-07, F-CORE-08, F-CORE-09, F-CORE-13, F-A11Y-01, F-A11Y-02, F-A11Y-03, F-INT-01, F-NAV-01, F-UI-01 through F-UI-06, F-UI-08, F-TEST-01
|
||||||
|
|
||||||
|
## Acceptance Criteria (27)
|
||||||
|
1. Login page renders with email+password form
|
||||||
|
2. Login with valid credentials → redirect to Dashboard
|
||||||
|
3. Login with invalid credentials → error toast shown
|
||||||
|
4. Password reset request page renders and submits
|
||||||
|
5. Password reset confirm page renders with token validation
|
||||||
|
6. App shell renders with sidebar (plugin menu), topbar (tenant switcher, search, notifications, user menu), content area
|
||||||
|
7. Router navigates between routes without page reload (SPA)
|
||||||
|
8. Protected routes redirect to /login when not authenticated
|
||||||
|
9. Tenant switcher shows current tenant and allows switching
|
||||||
|
10. API client sends session cookie automatically via axios interceptor
|
||||||
|
11. API client handles 401 → redirect to login
|
||||||
|
12. API client handles 422 → display validation errors
|
||||||
|
13. i18n: German locale loads by default
|
||||||
|
14. i18n: English locale switchable via settings
|
||||||
|
15. UI Library: Button renders with variants (primary, secondary, danger, ghost)
|
||||||
|
16. UI Library: Input renders with label, error, helper text
|
||||||
|
17. UI Library: Modal opens/closes with backdrop click and ESC
|
||||||
|
18. UI Library: Toast notifications appear and auto-dismiss
|
||||||
|
19. UI Library: Table renders with sortable headers
|
||||||
|
20. UI Library: Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog render correctly
|
||||||
|
21. Accessibility: All interactive elements have ARIA labels
|
||||||
|
22. Accessibility: Keyboard navigation works (Tab, Enter, Escape, Arrow keys)
|
||||||
|
23. Accessibility: 44px minimum touch targets on mobile
|
||||||
|
24. Accessibility: prefers-reduced-motion respected
|
||||||
|
25. Vite dev server starts without errors
|
||||||
|
26. Production build (npm run build) succeeds with 0 errors
|
||||||
|
27. TypeScript: tsc --noEmit passes with 0 errors
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
- frontend/package.json (React 18, Vite, TanStack Query, Zustand, react-i18next, React Hook Form, Zod, Tailwind CSS, Vitest, axios)
|
||||||
|
- frontend/vite.config.ts
|
||||||
|
- frontend/tsconfig.json, tsconfig.node.json
|
||||||
|
- frontend/tailwind.config.js, postcss.config.js
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/src/main.tsx — React entry point with providers
|
||||||
|
- frontend/src/App.tsx — Router + providers setup
|
||||||
|
- frontend/src/api/client.ts — axios instance with interceptors (cookie, 401, 422)
|
||||||
|
- frontend/src/api/hooks.ts — TanStack Query hooks for auth, users, companies, contacts, notifications
|
||||||
|
- frontend/src/store/authStore.ts — Zustand auth store
|
||||||
|
- frontend/src/store/uiStore.ts — Zustand UI store (theme, sidebar, locale)
|
||||||
|
- frontend/src/i18n/index.ts — react-i18next setup
|
||||||
|
- frontend/src/i18n/locales/de.json, en.json
|
||||||
|
- frontend/src/components/ui/Button.tsx
|
||||||
|
- frontend/src/components/ui/Input.tsx
|
||||||
|
- frontend/src/components/ui/Select.tsx
|
||||||
|
- frontend/src/components/ui/Modal.tsx
|
||||||
|
- frontend/src/components/ui/Toast.tsx (ToastContainer + useToast)
|
||||||
|
- frontend/src/components/ui/Table.tsx
|
||||||
|
- frontend/src/components/ui/Card.tsx
|
||||||
|
- frontend/src/components/ui/Badge.tsx
|
||||||
|
- frontend/src/components/ui/Avatar.tsx
|
||||||
|
- frontend/src/components/ui/Pagination.tsx
|
||||||
|
- frontend/src/components/ui/EmptyState.tsx
|
||||||
|
- frontend/src/components/ui/Skeleton.tsx
|
||||||
|
- frontend/src/components/ui/ConfirmDialog.tsx
|
||||||
|
- frontend/src/components/layout/AppShell.tsx — Sidebar + TopBar + ContentArea
|
||||||
|
- frontend/src/components/layout/Sidebar.tsx — Plugin menu, navigation
|
||||||
|
- frontend/src/components/layout/TopBar.tsx — Tenant switcher, search, notifications, user menu
|
||||||
|
- frontend/src/pages/Login.tsx
|
||||||
|
- frontend/src/pages/PasswordResetRequest.tsx
|
||||||
|
- frontend/src/pages/PasswordResetConfirm.tsx
|
||||||
|
- frontend/src/pages/Dashboard.tsx (placeholder)
|
||||||
|
- frontend/src/pages/Settings.tsx (locale switch, theme)
|
||||||
|
- frontend/src/routes/index.tsx — Route definitions with guards
|
||||||
|
- frontend/src/routes/ProtectedRoute.tsx — Auth guard
|
||||||
|
- frontend/src/hooks/useAuth.ts
|
||||||
|
- frontend/src/hooks/useTenant.ts
|
||||||
|
- frontend/src/index.css — Tailwind directives + design tokens
|
||||||
|
- frontend/src/__tests__/shell/ (AppShell, Router, Sidebar, TopBar tests)
|
||||||
|
- frontend/src/__tests__/auth/ (Login, PasswordReset tests)
|
||||||
|
- frontend/src/__tests__/ui/ (Button, Input, Modal, Toast, Table, etc. tests)
|
||||||
|
- frontend/vitest.config.ts (or merge into vite.config.ts)
|
||||||
|
- frontend/src/test/setup.ts — Vitest setup (jsdom, i18n, mocks)
|
||||||
|
|
||||||
|
## Design Tokens (from prototype)
|
||||||
|
- Reference: https://webspace.media-on.de/leocrm-prototype-x7k2p9/
|
||||||
|
- Primary color, secondary, accent, danger, warning, success
|
||||||
|
- Spacing scale, border radius, shadows
|
||||||
|
- Typography: font families, sizes, weights
|
||||||
|
- Define as CSS custom properties in index.css + Tailwind config
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- All components must have ARIA labels for interactive elements
|
||||||
|
- 44px minimum touch targets on mobile (Tailwind min-h-[44px] min-w-[44px])
|
||||||
|
- prefers-reduced-motion: use Tailwind motion-safe/motion-reduce variants
|
||||||
|
- Session cookie: axios with withCredentials: true
|
||||||
|
- 401 handler: redirect to /login, clear auth store
|
||||||
|
- 422 handler: extract validation errors, display in form
|
||||||
|
- i18n: German default, English switchable
|
||||||
|
- No Lorem Ipsum — use real German/English content
|
||||||
|
- Test with Vitest + jsdom + @testing-library/react
|
||||||
|
- Coverage target: 80%
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/ --reporter=verbose
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npm run build
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx tsc --noEmit
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
1. Complete frontend/ directory with all files listed above
|
||||||
|
2. All 27 ACs covered by tests
|
||||||
|
3. npm run build succeeds with 0 errors
|
||||||
|
4. tsc --noEmit passes with 0 errors
|
||||||
|
5. Report: test results, AC coverage, files, bugs
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# T09 — KI-Copilot API + Hybrid Workflow Engine Backend
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Backend Directory
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm/app/
|
||||||
|
|
||||||
|
## Tech Stack (existing)
|
||||||
|
- FastAPI + SQLAlchemy 2.0 + asyncpg + Pydantic v2 + ARQ
|
||||||
|
- PostgreSQL 18 on localhost:5432 (user/db: leocrm/leocrm + leocrm_test)
|
||||||
|
- Redis on localhost:6379
|
||||||
|
- venv at /opt/venv (already activated)
|
||||||
|
- T01-T03 complete (103 tests pass, commit 7a5a48f)
|
||||||
|
|
||||||
|
## Requirements (5)
|
||||||
|
F-AI-01, F-WF-01, F-CORE-01, F-CORE-06, F-TEST-01
|
||||||
|
|
||||||
|
## Acceptance Criteria (22)
|
||||||
|
### KI-Copilot (7 ACs)
|
||||||
|
1. POST /api/v1/ai/copilot/query mit NL input → 200 + proposed_actions array
|
||||||
|
2. POST /api/v1/ai/copilot/execute mit proposed action → 200 + API result (RBAC enforced)
|
||||||
|
3. POST /api/v1/ai/copilot/execute als viewer mit delete action → 403 (RBAC blocks)
|
||||||
|
4. GET /api/v1/ai/copilot/history → 200 + paginated conversation history
|
||||||
|
5. Copilot action logged in audit_log with entity_type=ai_copilot
|
||||||
|
6. Copilot respects tenant isolation: cross-tenant → 404
|
||||||
|
7. Copilot respects field-level permissions: hidden fields not in response
|
||||||
|
|
||||||
|
### Workflow Engine (15 ACs)
|
||||||
|
8. POST /api/v1/workflows mit valid steps JSONB → 201 + workflow definition
|
||||||
|
9. GET /api/v1/workflows → 200 + paginated list
|
||||||
|
10. GET /api/v1/workflows/{id} → 200 + workflow detail with steps
|
||||||
|
11. PATCH /api/v1/workflows/{id} → 200, updated
|
||||||
|
12. DELETE /api/v1/workflows/{id} → 204
|
||||||
|
13. POST /api/v1/workflows/{id}/instances → 201, instance created with status=pending
|
||||||
|
14. GET /api/v1/workflows/instances?status=in_progress → 200 + filtered list
|
||||||
|
15. GET /api/v1/workflows/instances/{id} → 200 + current_step_index + history
|
||||||
|
16. POST /api/v1/workflows/instances/{id}/advance (approve) → 200, step advanced
|
||||||
|
17. POST /api/v1/workflows/instances/{id}/advance (reject) → 200, status=rejected, initiator notified
|
||||||
|
18. POST /api/v1/workflows/instances/{id}/cancel → 200, status=cancelled
|
||||||
|
19. Event-triggered workflow: publish event → workflow instance auto-starts
|
||||||
|
20. workflow_step_history entry created on every step transition
|
||||||
|
21. Code-engine workflow: onboarding workflow runs on user creation
|
||||||
|
22. Approval step timeout → auto-reject after configured hours (tested with mock timer)
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
### KI-Copilot
|
||||||
|
- app/models/ai_conversation.py — AIConversation, AIMessage models (tenant-scoped)
|
||||||
|
- app/schemas/ai_copilot.py — CopilotQueryRequest, CopilotAction, CopilotExecuteRequest, CopilotHistoryResponse
|
||||||
|
- app/services/ai_copilot_service.py — NL→API translation, LLM client, RBAC enforcement, audit logging
|
||||||
|
- app/routes/ai_copilot.py — POST /query, POST /execute, GET /history
|
||||||
|
- app/ai/__init__.py
|
||||||
|
- app/ai/llm_client.py — Configurable LLM client (AI_MODEL, AI_API_KEY env vars)
|
||||||
|
- app/ai/action_mapper.py — Maps NL intents to API calls
|
||||||
|
|
||||||
|
### Workflow Engine
|
||||||
|
- app/models/workflow.py — Workflow, WorkflowInstance, WorkflowStepHistory models (tenant-scoped)
|
||||||
|
- app/schemas/workflow.py — WorkflowCreate, WorkflowResponse, InstanceCreate, InstanceResponse, AdvanceRequest
|
||||||
|
- app/services/workflow_service.py — CRUD workflows, instance lifecycle (start/advance/approve/reject/cancel)
|
||||||
|
- app/routes/workflows.py — Workflow CRUD + instance endpoints
|
||||||
|
- app/workflows/__init__.py
|
||||||
|
- app/workflows/code/__init__.py — Code-engine workflows
|
||||||
|
- app/workflows/code/onboarding.py — Onboarding workflow (runs on user creation)
|
||||||
|
- app/workflows/engine.py — Workflow execution engine (step processing, conditions, approvals)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- tests/test_ai_copilot.py — 7 AC tests + edge cases
|
||||||
|
- tests/test_workflows.py — 15 AC tests + edge cases
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- alembic/versions/0004_ai_workflows.py — ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history tables (all tenant-scoped with RLS)
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
- app/main.py — Register ai_copilot + workflows routers
|
||||||
|
- app/models/__init__.py — Add new model imports
|
||||||
|
- app/routes/__init__.py — Add new router imports
|
||||||
|
- app/schemas/__init__.py — Add new schema imports
|
||||||
|
- app/services/__init__.py — Add new service imports
|
||||||
|
- tests/conftest.py — Add new tables to TRUNCATE list
|
||||||
|
- app/core/event_bus.py — Add workflow event trigger integration (if not already present)
|
||||||
|
|
||||||
|
## LLM Client Design
|
||||||
|
- Read AI_MODEL and AI_API_KEY from environment
|
||||||
|
- If not set, use mock/stub mode (returns predefined actions for tests)
|
||||||
|
- Support OpenAI-compatible API (default)
|
||||||
|
- NL → proposed API calls: method, path, body, description
|
||||||
|
- Never execute directly — always return proposed actions for user confirmation
|
||||||
|
|
||||||
|
## Workflow Engine Design
|
||||||
|
- Step types: action, approval, notification, condition
|
||||||
|
- Workflow definition: JSONB steps array
|
||||||
|
- Instance lifecycle: pending → in_progress → completed/rejected/cancelled
|
||||||
|
- Event bus integration: subscribe to events, auto-start workflows with matching trigger
|
||||||
|
- Code-engine: hardcoded workflows in app/workflows/code/ (onboarding on user.created event)
|
||||||
|
- Approval timeout: configurable hours, auto-reject via ARQ scheduled job or mock timer in tests
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
- All POST routes MUST have status_code=201 (except execute/advance/cancel which are actions → 200)
|
||||||
|
- Use set_config() for tenant context, NOT SET LOCAL
|
||||||
|
- Use .com emails in tests, NOT .test
|
||||||
|
- All new tables MUST have tenant_id column + RLS policies
|
||||||
|
- Update tests/conftest.py TRUNCATE list with new tables
|
||||||
|
- Create Alembic migration 0004 for all new tables
|
||||||
|
- Copilot MUST enforce RBAC (same middleware, same permissions)
|
||||||
|
- Copilot MUST respect tenant isolation and field-level permissions
|
||||||
|
- Audit log entity_type=ai_copilot for all copilot actions
|
||||||
|
- Workflow mutations MUST be logged in workflow_step_history
|
||||||
|
- Idempotent where applicable
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_ai_copilot.py tests/test_workflows.py -v --tb=short
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/ -v --tb=short (full suite regression)
|
||||||
|
|
||||||
|
## Coverage Target
|
||||||
|
80% for new modules
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
1. All files listed above
|
||||||
|
2. Alembic migration 0004
|
||||||
|
3. tests/test_ai_copilot.py + tests/test_workflows.py covering all 22 ACs
|
||||||
|
4. Report: test results, AC coverage, files, bugs
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Current Status — LeoCRM
|
||||||
|
|
||||||
|
**Phase**: 3 — Implementation
|
||||||
|
**Status**: T03 COMPLETE — 103/103 tests pass, 85.92% plugin coverage
|
||||||
|
**Commit**: 7a5a48f (pushed to Forgejo)
|
||||||
|
**Date**: 2026-06-29 01:20 CEST
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
- T01 ✅ Core Infrastructure + Auth (29 tests)
|
||||||
|
- T02 ✅ Companies + Contacts + Import/Export (27 tests)
|
||||||
|
- T03 ✅ Plugin System Framework (47 tests)
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
- Delegate T07a (Frontend SPA Shell) + T09 (KI-Copilot API) in parallel
|
||||||
|
- Awaiting user approval
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Next Steps — LeoCRM
|
||||||
|
|
||||||
|
## Current: T03 COMPLETE ✅ (commit 7a5a48f, pushed)
|
||||||
|
|
||||||
|
## Phase 3 Implementation Progress
|
||||||
|
- T01 ✅ Core Infrastructure + Multi-Tenant + Auth (commit 7a7daf8)
|
||||||
|
- T02 ✅ Company + Contact + Import/Export (commit 6bf0746)
|
||||||
|
- T03 ✅ Plugin System Framework (commit 7a5a48f)
|
||||||
|
|
||||||
|
## Next: T07a ∥ T09 (Parallel)
|
||||||
|
### T07a — Frontend SPA Shell
|
||||||
|
- Deps: T01 ✅
|
||||||
|
- Reqs: 23 features, 27 ACs
|
||||||
|
- Vue 3 + Vite + Pinia + Vue Router + TailwindCSS
|
||||||
|
- Auth flow, layout, navigation, dashboard, settings
|
||||||
|
|
||||||
|
### T09 — KI-Copilot API + Hybrid Workflow Engine Backend
|
||||||
|
- Deps: T01 ✅, T02 ✅
|
||||||
|
- Reqs: 5 features, 22 ACs
|
||||||
|
- LLM integration, workflow engine, task automation
|
||||||
|
|
||||||
|
## After T07a + T09: T07b (Frontend Feature Pages)
|
||||||
|
## After T07b: T10 (Monitoring, Performance, Documentation)
|
||||||
|
|
||||||
|
## v1 Execution Order
|
||||||
|
T01 ✅ → T02 ✅ → T03 ✅ → (T07a ∥ T09) → T07b → T10
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
## T03 — Plugin System Framework — COMPLETE ✅
|
||||||
|
**Date**: 2026-06-29 01:20
|
||||||
|
**Commit**: 7a5a48f (pushed to Forgejo)
|
||||||
|
**Tests**: 47/47 T03 tests pass, 103/103 full suite pass
|
||||||
|
**Coverage**: 85.92% for plugin modules (target: 85% ✅)
|
||||||
|
**Migration**: 0003_plugin_system.py applied (plugins + plugin_migrations tables)
|
||||||
|
|
||||||
|
### Files Created (12 new)
|
||||||
|
- app/plugins/__init__.py, manifest.py, base.py, registry.py, migration_runner.py
|
||||||
|
- app/plugins/builtins/__init__.py, test_sample.py, migrations/0001_test_plugin.sql, migrations/0001_bad_migration.sql
|
||||||
|
- app/models/plugin.py, app/schemas/plugin.py, app/services/plugin_service.py, app/routes/plugins.py
|
||||||
|
- alembic/versions/0003_plugin_system.py
|
||||||
|
- tests/test_plugins.py (47 tests, 14 ACs + 33 unit tests)
|
||||||
|
|
||||||
|
### Files Modified (8)
|
||||||
|
- app/main.py (plugins router + registry init in lifespan)
|
||||||
|
- app/models/__init__.py, app/routes/__init__.py, app/schemas/__init__.py, app/services/__init__.py
|
||||||
|
- tests/conftest.py (plugin tables in TRUNCATE list)
|
||||||
|
|
||||||
|
### Bugs Fixed by Subagent
|
||||||
|
1. Unterminated f-string in registry.py
|
||||||
|
2. Migration runner DB connection visibility (now uses session's own connection)
|
||||||
|
3. Route unregistration by path match (FastAPI wraps routes differently)
|
||||||
|
4. Dollar-quote SQL splitting (flush after closing $$)
|
||||||
|
5. AC11 assertion type (dict vs string for HTTPException detail)
|
||||||
|
|
||||||
|
### Verification (Orchestrator Independent)
|
||||||
|
- pytest tests/test_plugins.py -v: 47/47 PASS
|
||||||
|
- pytest tests/ -v: 103/103 PASS (zero regressions)
|
||||||
|
- Coverage: 85.92% (manifest 100%, base 88%, registry 88%, migration_runner 79%)
|
||||||
|
- Migration 0003 applied via alembic upgrade head
|
||||||
|
- No forbidden patterns found
|
||||||
|
- Pushed to Forgejo: 6bf0746..7a5a48f
|
||||||
|
|
||||||
|
### Next: T07a (Frontend SPA Shell) ∥ T09 (KI-Copilot API) — parallel delegation
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
AUTH_SECRET=test-secret-with-at-least-thirty-two-characters-for-development
|
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./dev.db
|
REDIS_URL=redis://localhost:6379/0
|
||||||
JWT_ALGORITHM=HS256
|
|
||||||
JWT_EXPIRY_HOURS=24
|
|
||||||
BCRYPT_ROUNDS=4
|
|
||||||
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
|
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|||||||
+33
-25
@@ -1,34 +1,42 @@
|
|||||||
# CRM System v1.0 - Environment Variables Template
|
# LeoCRM v1.0 - Environment Variables Template
|
||||||
# Copy this file to .env and fill in real values
|
|
||||||
# .env is gitignored, NEVER commit it
|
|
||||||
|
|
||||||
# === REQUIRED ===
|
# === REQUIRED ===
|
||||||
# JWT signing secret. MUST be at least 32 characters.
|
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
REDIS_URL=redis://localhost:6379/0
|
||||||
# In production, this is auto-injected by Coolify as SERVICE_BASE64_64.
|
|
||||||
AUTH_SECRET=replace-me-with-a-secure-random-string-at-least-32-chars-long
|
|
||||||
|
|
||||||
# === OPTIONAL (with defaults) ===
|
# === OPTIONAL (with defaults) ===
|
||||||
|
|
||||||
# Database URL. Driver-agnostic (aiosqlite or asyncpg).
|
# Environment: development | production | testing
|
||||||
# Dev default: SQLite file ./dev.db
|
|
||||||
# Prod example: postgresql+asyncpg://user:pass@host:5432/dbname
|
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./dev.db
|
|
||||||
|
|
||||||
# JWT settings
|
|
||||||
JWT_ALGORITHM=HS256
|
|
||||||
JWT_EXPIRY_HOURS=24
|
|
||||||
|
|
||||||
# Password hashing rounds (bcrypt)
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
# CORS allowed origins (comma-separated, NO wildcards)
|
|
||||||
# Dev: http://localhost:5500,http://localhost:8000
|
|
||||||
# Prod: https://crm.media-on.de
|
|
||||||
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
|
|
||||||
|
|
||||||
# Environment: development | production
|
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|
||||||
# Log level: DEBUG | INFO | WARNING | ERROR
|
# Log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Database pool
|
||||||
|
DB_POOL_SIZE=10
|
||||||
|
DB_MAX_OVERFLOW=20
|
||||||
|
DB_ECHO=false
|
||||||
|
|
||||||
|
# Session settings
|
||||||
|
SESSION_TTL_SECONDS=28800
|
||||||
|
SESSION_COOKIE_NAME=leocrm_session
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
SESSION_COOKIE_SAMESITE=strict
|
||||||
|
SESSION_COOKIE_HTTPONLY=true
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated, NO wildcards)
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_LOGIN_MAX=5
|
||||||
|
RATE_LIMIT_LOGIN_WINDOW=900
|
||||||
|
RATE_LIMIT_RESET_MAX=3
|
||||||
|
RATE_LIMIT_RESET_WINDOW=3600
|
||||||
|
RATE_LIMIT_RESET_CONFIRM_MAX=5
|
||||||
|
RATE_LIMIT_RESET_CONFIRM_WINDOW=3600
|
||||||
|
RATE_LIMIT_GENERAL_MAX=60
|
||||||
|
RATE_LIMIT_GENERAL_WINDOW=60
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
# LeoCRM — AGENTS.md
|
||||||
|
|
||||||
|
**Projekt:** leocrm
|
||||||
|
**Erstellt:** 2026-06-28
|
||||||
|
**Status:** Draft — ready for implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Build & Test Commands
|
||||||
|
|
||||||
|
### Backend (Python / FastAPI)
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Dev Server
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database Migrations (Alembic)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
# Generate migration after model changes
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
# Rollback one migration
|
||||||
|
alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run All Backend Tests
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Specific Test File
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest tests/test_auth.py -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Tests with Coverage
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest --cov=app --cov-report=term-missing --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Tests with Grep Filter
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest -k 'tenant or auth' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Type Checking
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
mypy app/ --ignore-missing-imports
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linting
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
ruff check app/
|
||||||
|
ruff format app/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (React / Vite / TypeScript)
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Dev Server
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build Production
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run All Frontend Tests
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx vitest run --reporter=verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Tests with Coverage
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx vitest run --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Tests in Watch Mode (dev)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx vitest watch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Type Checking
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linting
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx eslint src/ --ext .ts,.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose (Full Stack)
|
||||||
|
|
||||||
|
#### Build All Services
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Start All Services
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### View Logs
|
||||||
|
```bash
|
||||||
|
docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stop All Services
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Validate Compose Config
|
||||||
|
```bash
|
||||||
|
docker compose config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npx playwright install
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test Rules
|
||||||
|
|
||||||
|
### TDD (Test-Driven Development)
|
||||||
|
|
||||||
|
- **Red-Green-Refactor:** Write failing test first → implement minimum code to pass → refactor.
|
||||||
|
- **Every new endpoint gets a test BEFORE implementation.**
|
||||||
|
- **Every bug fix starts with a reproduction test.**
|
||||||
|
|
||||||
|
### Coverage Targets
|
||||||
|
|
||||||
|
| Layer | Coverage Target | Measured By |
|
||||||
|
|-------|----------------|-------------|
|
||||||
|
| Backend Core (app/core/) | 85% | pytest-cov |
|
||||||
|
| Backend Models+Services | 85% | pytest-cov |
|
||||||
|
| Backend Routes | 85% | pytest-cov |
|
||||||
|
| Backend Plugins | 80% | pytest-cov |
|
||||||
|
| Frontend Components | 75% | vitest coverage |
|
||||||
|
| Frontend Plugin UI | 70% | vitest coverage |
|
||||||
|
| E2E (critical paths) | 100% of defined specs | Playwright |
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
```
|
||||||
|
backend/tests/
|
||||||
|
├── conftest.py — Fixtures: test client, test DB, auth helpers, seed data
|
||||||
|
├── test_auth.py — Auth endpoints, RBAC, password reset
|
||||||
|
├── test_tenant.py — Tenant isolation, cross-tenant access
|
||||||
|
├── test_companies.py — Company CRUD, search, filter, pagination, soft-delete
|
||||||
|
├── test_contacts.py — Contact CRUD, N:M links, GDPR delete
|
||||||
|
├── test_import_export.py — CSV import/export, XLSX export, dry-run preview
|
||||||
|
├── test_plugins.py — Plugin lifecycle, event bus, migrations
|
||||||
|
├── test_dms.py — DMS folders, files, upload, shares, permissions
|
||||||
|
├── test_calendar.py — Entries, recurrence, kanban, ICS, resources
|
||||||
|
├── test_mail.py — Accounts, IMAP sync, send, threading, rules, PGP
|
||||||
|
├── test_tags.py — Tag CRUD, assignment, bulk
|
||||||
|
├── test_notifications.py — Notification CRUD, unread count
|
||||||
|
├── test_health.py — Health endpoint
|
||||||
|
├── test_ai_copilot.py — KI-Copilot API, RBAC enforcement, history
|
||||||
|
├── test_workflows.py — Workflow CRUD, instances, approval/rejection, event triggers
|
||||||
|
├── test_monitoring.py — Extended health, Prometheus metrics, alerting
|
||||||
|
└── test_performance.py — 200k seed, list <500ms, FTS <500ms, streaming export
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
```
|
||||||
|
frontend/src/__tests__/
|
||||||
|
├── components/ — UI component unit tests (Button, Input, Modal, Table, etc.)
|
||||||
|
├── features/ — Feature integration tests (CompanyList, ContactForm, etc.)
|
||||||
|
├── hooks/ — Custom hook tests (useDebounce, usePagination, etc.)
|
||||||
|
├── plugins/ — Plugin UI tests (DMS, Calendar, Mail, Tags)
|
||||||
|
└── search/ — Global search tests
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── auth.spec.ts — Login → logout flow
|
||||||
|
├── company-crud.spec.ts — Create → edit → delete company
|
||||||
|
├── contact-crud.spec.ts — Create → link to company → delete
|
||||||
|
├── search.spec.ts — Global search
|
||||||
|
└── plugin-toggle.spec.ts — Activate/deactivate plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Conventions
|
||||||
|
|
||||||
|
- **Test names:** `test_<action>_<condition>_<expected_result>` (e.g., `test_login_with_invalid_credentials_returns_401`)
|
||||||
|
- **Test structure:** Arrange → Act → Assert (AAA pattern)
|
||||||
|
- **Fixtures:** Use `conftest.py` for shared fixtures. No fixture duplication across files.
|
||||||
|
- **Test DB:** Use in-memory or ephemeral PostgreSQL (via testcontainers or pytest-postgresql). NEVER test against production DB.
|
||||||
|
- **Mocking:** Mock external services (SMTP, IMAP, OnlyOffice) in tests. Use `unittest.mock.AsyncMock` for async mocks.
|
||||||
|
- **Assertions:** Use pytest's native `assert` for backend, `expect()` from `@testing-library/jest-dom` for frontend.
|
||||||
|
- **No flaky tests:** Tests must be deterministic. Use explicit waits, not sleeps.
|
||||||
|
- **Test isolation:** Each test must be independent. No test depends on another test's side effects.
|
||||||
|
|
||||||
|
### Don't Modify Tests Rule
|
||||||
|
|
||||||
|
- **NEVER modify existing tests to make them pass.** If a test fails, fix the code, not the test.
|
||||||
|
- **Exception:** If the test itself is wrong (testing incorrect behavior), document why and get approval before changing.
|
||||||
|
- **Test files are owned by the QA process, not the implementer.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Conventions
|
||||||
|
|
||||||
|
### Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── main.py — FastAPI app entry point, lifespan, middleware registration
|
||||||
|
├── config.py — Pydantic Settings (reads from env vars)
|
||||||
|
├── deps.py — FastAPI dependency injection (auth, db, tenant, permissions)
|
||||||
|
├── core/ — Core infrastructure (cross-cutting concerns)
|
||||||
|
│ ├── db/ — SQLAlchemy engine, session factory, base model
|
||||||
|
│ ├── tenant.py — TenantMixin, ORM auto-filter, tenant context
|
||||||
|
│ ├── auth.py — Session auth, password hashing (bcrypt), RBAC
|
||||||
|
│ ├── event_bus.py — Async in-process event bus
|
||||||
|
│ ├── service_container.py — DI container
|
||||||
|
│ ├── storage.py — File storage (local/S3)
|
||||||
|
│ ├── cache.py — Redis cache wrapper
|
||||||
|
│ ├── jobs.py — ARQ job queue integration
|
||||||
|
│ ├── notifications.py — Notification service
|
||||||
|
│ └── audit.py — Audit log middleware
|
||||||
|
├── models/ — SQLAlchemy ORM models (one file per domain)
|
||||||
|
├── schemas/ — Pydantic schemas (request/response, one file per domain)
|
||||||
|
├── services/ — Business logic (one file per domain)
|
||||||
|
├── routes/ — FastAPI routers (one file per domain)
|
||||||
|
├── plugins/ — Plugin system
|
||||||
|
│ ├── registry.py — Plugin discovery, registration
|
||||||
|
│ ├── manifest.py — Plugin manifest Pydantic schema
|
||||||
|
│ ├── lifecycle.py — Install/activate/deactivate/uninstall
|
||||||
|
│ ├── migrations.py — Plugin DB migration runner
|
||||||
|
│ ├── ui_registry.py — Plugin UI component registration
|
||||||
|
│ └── builtins/ — Built-in plugins
|
||||||
|
│ ├── dms/ — DMS plugin
|
||||||
|
│ ├── calendar/ — Calendar plugin
|
||||||
|
│ ├── mail/ — Mail plugin
|
||||||
|
│ └── tags/ — Tags plugin
|
||||||
|
└── utils/ — Shared utilities (validation, export, import)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Naming Conventions
|
||||||
|
|
||||||
|
- **Files:** `snake_case.py` (e.g., `company_service.py`)
|
||||||
|
- **Classes:** `PascalCase` (e.g., `CompanyService`, `CompanyModel`)
|
||||||
|
- **Functions/Methods:** `snake_case` (e.g., `get_company_by_id`)
|
||||||
|
- **Constants:** `UPPER_SNAKE_CASE` (e.g., `SESSION_TIMEOUT_HOURS`)
|
||||||
|
- **Models:** `<Entity>Model` suffix or just `<Entity>` (e.g., `Company`, `Contact`)
|
||||||
|
- **Schemas:** `<Entity>Create`, `<Entity>Update`, `<Entity>Read`, `<Entity>List` (Pydantic)
|
||||||
|
- **Services:** `<Entity>Service` (e.g., `CompanyService`)
|
||||||
|
- **Routers:** `<entity>_router` variable, file name `<entity>_router.py`
|
||||||
|
- **Tests:** `test_<domain>.py` (e.g., `test_companies.py`)
|
||||||
|
|
||||||
|
### Backend Code Conventions
|
||||||
|
|
||||||
|
- **Async first:** All route handlers and service methods are `async def`.
|
||||||
|
- **Type hints:** All function signatures have type hints (Python 3.12+ syntax).
|
||||||
|
- **Docstrings:** All public functions/classes have docstrings (Google style).
|
||||||
|
- **Error handling:** Use FastAPI `HTTPException` with proper status codes. Never raise generic `Exception`.
|
||||||
|
- **Validation:** Pydantic schemas validate input. Never validate in routes directly.
|
||||||
|
- **Tenant scoping:** Never query without tenant filter (ORM auto-filter handles this, but be aware).
|
||||||
|
- **UUID:** All IDs are UUID. Never use integer auto-increment.
|
||||||
|
- **Timestamps:** All datetime fields are `TIMESTAMPTZ`. Never use naive datetime.
|
||||||
|
- **Soft-delete:** Use `deleted_at IS NULL` filter. Never hard-delete without explicit `gdpr=true` flag.
|
||||||
|
- **Audit:** All mutations must create audit log entries. Use the audit middleware/decorator.
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── main.tsx — React entry point
|
||||||
|
├── App.tsx — Root component, router, providers
|
||||||
|
├── api/ — API client (axios), interceptors, endpoint definitions
|
||||||
|
├── components/ — Shared UI components
|
||||||
|
│ ├── layout/ — Shell, Sidebar, TopBar, ContentArea
|
||||||
|
│ ├── ui/ — Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar
|
||||||
|
│ └── shared/ — EmptyState, LoadingState, ConfirmDialog, Pagination, Skeleton
|
||||||
|
├── features/ — Feature modules (one folder per feature)
|
||||||
|
│ ├── auth/ — Login, PasswordReset
|
||||||
|
│ ├── companies/ — CompanyList, CompanyDetail, CompanyForm
|
||||||
|
│ ├── contacts/ — ContactList, ContactDetail, ContactForm
|
||||||
|
│ ├── settings/ — SettingsTree, ProfileSettings, RoleEditor
|
||||||
|
│ ├── audit/ — AuditLog
|
||||||
|
│ ├── dashboard/ — Dashboard
|
||||||
|
│ └── search/ — GlobalSearch
|
||||||
|
├── plugins/ — Plugin UI loading framework
|
||||||
|
│ ├── PluginRegistry.tsx — Fetch manifests, register components
|
||||||
|
│ └── PluginLoader.tsx — Dynamic lazy-loading of plugin components
|
||||||
|
├── hooks/ — Custom React hooks (useDebounce, usePagination, useAuth, etc.)
|
||||||
|
├── store/ — Zustand stores (useAuthStore, useUIStore, useTenantStore)
|
||||||
|
├── i18n/ — react-i18next setup + locale files (de.json, en.json)
|
||||||
|
├── styles/ — Global CSS, design tokens (Tailwind config), accessibility
|
||||||
|
└── utils/ — Utilities (format, validation, export, constants)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Naming Conventions
|
||||||
|
|
||||||
|
- **Files:** `PascalCase.tsx` for components (e.g., `CompanyList.tsx`), `camelCase.ts` for utilities (e.g., `apiClient.ts`)
|
||||||
|
- **Components:** `PascalCase` (e.g., `CompanyList`, `ContactForm`)
|
||||||
|
- **Hooks:** `use<Feature>` (e.g., `useDebounce`, `useAuth`)
|
||||||
|
- **Stores:** `use<Domain>Store` (e.g., `useAuthStore`, `useUIStore`)
|
||||||
|
- **Types/Interfaces:** `PascalCase` (e.g., `CompanyData`, `ContactFormValues`)
|
||||||
|
- **API functions:** `camelCase` (e.g., `getCompanies`, `createContact`)
|
||||||
|
- **Test files:** `<Component>.test.tsx` next to component or in `__tests__/` mirror
|
||||||
|
|
||||||
|
### Frontend Code Conventions
|
||||||
|
|
||||||
|
- **TypeScript strict:** `strict: true` in tsconfig.json. No `any` types.
|
||||||
|
- **Functional components:** Only function components, no class components.
|
||||||
|
- **Hooks:** Custom hooks for reusable logic. No inline hooks in JSX.
|
||||||
|
- **TanStack Query:** Server state via `useQuery` / `useMutation`. No manual fetch in components.
|
||||||
|
- **Zustand:** Client state only (UI toggles, theme, active tenant). No server data in Zustand.
|
||||||
|
- **React Hook Form + Zod:** All forms use `react-hook-form` with `zodResolver`.
|
||||||
|
- **Tailwind CSS:** No custom CSS files (except global + accessibility). Use Tailwind utility classes.
|
||||||
|
- **i18n:** All user-visible strings go through `t()` from `react-i18next`. No hardcoded strings.
|
||||||
|
- **Accessibility:** ARIA attributes on all interactive elements. 44px touch targets. Keyboard navigation.
|
||||||
|
- **Lazy loading:** Plugin components use `React.lazy()` with `Suspense` boundaries.
|
||||||
|
|
||||||
|
### Git Conventions
|
||||||
|
|
||||||
|
- **Branch naming:** `feature/T01-core-infrastructure`, `fix/auth-tenant-isolation`, `hotfix/critical-bug`
|
||||||
|
- **Commit messages:** Conventional Commits format:
|
||||||
|
- `feat(core): implement auth system with session-based login`
|
||||||
|
- `fix(dms): resolve folder permission bypass on move`
|
||||||
|
- `test(mail): add IMAP sync integration tests`
|
||||||
|
- `refactor(calendar): extract recurrence engine to separate module`
|
||||||
|
- `docs(architecture): update ADR-03 with plugin lifecycle details`
|
||||||
|
- **PR titles:** `[T01] Core Infrastructure + Multi-Tenant + Auth System`
|
||||||
|
- **Branch from:** `main` (or feature branch for sub-features)
|
||||||
|
- **Merge strategy:** Squash merge to `main` after review + CI passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Task-Zuweisung (Subagenten pro Task)
|
||||||
|
|
||||||
|
### Phasen-Plan
|
||||||
|
|
||||||
|
#### v1 Core Phases (Phase 3 — Implementation)
|
||||||
|
|
||||||
|
| Phase | Tasks | Parallel | Subagent Profile | Description |
|
||||||
|
|-------|-------|----------|-------------------|-------------|
|
||||||
|
| 1 | T01 | No | implementation_engineer | Foundation: Core, Auth, Multi-Tenant, RLS, Rate Limiting |
|
||||||
|
| 2 | T02, T03 | Yes (2 agents) | implementation_engineer ×2 | Core entities + Plugin framework parallel |
|
||||||
|
| 3 | T07a, T09 | Yes (2 agents) | implementation_engineer ×2 | Frontend Shell+Auth+UI Library + KI-Copilot/Workflow parallel |
|
||||||
|
| 4 | T07b | No | implementation_engineer | Frontend Feature Pages (Companies, Contacts, Settings, Dashboard, Search) |
|
||||||
|
| 5 | T10 | No | implementation_engineer | Monitoring, Performance, Doku, Environment Config |
|
||||||
|
|
||||||
|
#### v2 Plugin Phases (nach v1 Deployment)
|
||||||
|
|
||||||
|
| Phase | Tasks | Parallel | Subagent Profile | Description |
|
||||||
|
|-------|-------|----------|-------------------|-------------|
|
||||||
|
| 6 | T04, T05, T06, T11 | Yes (4 agents) | implementation_engineer ×4 | DMS, Calendar, Mail, Tags+Permissions backends parallel |
|
||||||
|
| 7 | T08a, T08b, T08c | Yes (3 agents) | implementation_engineer ×3 | Frontend DMS+Tags, Calendar, Mail+Search parallel |
|
||||||
|
|
||||||
|
### Task-to-Subagent Mapping
|
||||||
|
|
||||||
|
| Task ID | Title | Subagent | Dependencies | Phase | Scope |
|
||||||
|
|---------|-------|----------|--------------|-------|-------|
|
||||||
|
| T01 | Core Infrastructure + Multi-Tenant + Auth | implementation_engineer | — | 1 | v1 |
|
||||||
|
| T02 | Company + Contact + Import/Export | implementation_engineer | T01 | 2 | v1 |
|
||||||
|
| T03 | Plugin System Framework | implementation_engineer | T01 | 2 | v1 |
|
||||||
|
| T07a | Frontend SPA — Shell, Auth, Routing, i18n, UI Library | implementation_engineer | T01 | 3 | v1 |
|
||||||
|
| T07b | Frontend SPA — Companies, Contacts, Settings, Dashboard, Search | implementation_engineer | T01, T02, T07a | 4 | v1 |
|
||||||
|
| T09 | KI-Copilot + Workflow Engine | implementation_engineer | T01, T02 | 3 | v1 |
|
||||||
|
| T10 | Monitoring + Performance + Doku + Env Config | implementation_engineer | T01, T02 | 5 | v1 |
|
||||||
|
| T04 | DMS Plugin Backend | implementation_engineer | T01, T03 | 6 | v2 |
|
||||||
|
| T05 | Calendar Plugin Backend | implementation_engineer | T01, T03 | 6 | v2 |
|
||||||
|
| T06 | Mail Plugin Backend | implementation_engineer | T01, T03 | 6 | v2 |
|
||||||
|
| T11 | Tags + Permissions + Entity Links Backend | implementation_engineer | T01, T03 | 6 | v2 |
|
||||||
|
| T08a | Frontend DMS + Tags + Permissions UI | implementation_engineer | T04, T07b | 7 | v2 |
|
||||||
|
| T08b | Frontend Calendar UI | implementation_engineer | T05, T07b | 7 | v2 |
|
||||||
|
| T08c | Frontend Mail + Global Search UI | implementation_engineer | T06, T07b | 7 | v2 |
|
||||||
|
|
||||||
|
### Parallelization Notes
|
||||||
|
|
||||||
|
**v1 Phases:**
|
||||||
|
- **Phase 2:** T02 (Company/Contact) and T03 (Plugin Framework) are independent after T01 — safe to run in parallel.
|
||||||
|
- **Phase 3:** T07a (Frontend Shell+Auth+UI Library) depends only on T01. T09 (KI/Workflow) depends on T01+T02. Both can run in parallel if API contracts are frozen.
|
||||||
|
- **Phase 4:** T07b (Frontend Feature Pages) depends on T07a (UI library, routing, auth) + T02 (company/contact API). Must run after T07a.
|
||||||
|
- **Phase 5:** T10 (Monitoring+Doku) depends on T01+T02. Can run parallel with T07b.
|
||||||
|
|
||||||
|
**v2 Phases (after v1 deployment):**
|
||||||
|
- **Phase 6:** T04 (DMS), T05 (Calendar), T06 (Mail), T11 (Tags+Perm) all depend on T01+T03 — safe to run in parallel.
|
||||||
|
- **Phase 7:** T08a/T08b/T08c depend on T07b + respective backend (T04/T05/T06) — safe to run in parallel.
|
||||||
|
|
||||||
|
### Block Rules
|
||||||
|
|
||||||
|
- Block = max 3 Tasks per implementation block.
|
||||||
|
- After each block: quality_reviewer review → block_compactor → context_compactor → User checkpoint.
|
||||||
|
- quality_reviewer and release_auditor do NOT count toward the 3-task limit.
|
||||||
|
- After 3 blocks (9 tasks): release_auditor runs full audit.
|
||||||
|
- Token budget: ~3000 tokens per task. If tool result >5000 tokens: context_compactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Forbidden Patterns
|
||||||
|
|
||||||
|
### Backend Forbidden
|
||||||
|
|
||||||
|
- ❌ **SQLite:** No SQLite as database. PostgreSQL 16 only (ADR-01).
|
||||||
|
- ❌ **Jinja2:** No server-side HTML rendering. API-only backend (ADR-03).
|
||||||
|
- ❌ **Cross-Tenant Data Access:** No query without tenant_id filter. ORM auto-filter must not be bypassed.
|
||||||
|
- ❌ **Plaintext Passwords:** Passwords must be bcrypt-hashed (cost=12). Never store or log plaintext.
|
||||||
|
- ❌ **JWT Tokens:** No JWT auth in v1. Session-based auth with HttpOnly cookies only (ADR-05).
|
||||||
|
- ❌ **Naive Datetime:** All datetime fields must be timezone-aware (TIMESTAMPTZ). Never use `datetime.now()` without tz.
|
||||||
|
- ❌ **Integer IDs:** All primary keys are UUID. Never use auto-increment integer IDs.
|
||||||
|
- ❌ **Hard-Delete without GDPR flag:** Companies/Contacts use soft-delete. Hard-delete only with explicit `?gdpr=true`.
|
||||||
|
- ❌ **Manual Tenant Filter:** Never manually add `.filter(Tenant.id == x)` in services. The ORM auto-filter handles this.
|
||||||
|
- ❌ **Sync I/O in Routes:** All route handlers are `async def`. Never use blocking I/O (use `asyncpg`, `aiofiles`, etc.).
|
||||||
|
- ❌ **Raw SQL without Tenant Check:** Any raw SQL query must explicitly include `tenant_id` filter.
|
||||||
|
- ❌ **Secrets in Code:** No hardcoded secrets. All secrets via environment variables.
|
||||||
|
- ❌ **Unvalidated Input:** All request bodies validated by Pydantic schemas. Never trust raw request data.
|
||||||
|
- ❌ **Missing Audit Log:** All create/update/delete operations must create audit log entries.
|
||||||
|
- ❌ **Plugin Tables without tenant_id:** All plugin-created tables must include `tenant_id` column. The migration validator enforces this.
|
||||||
|
|
||||||
|
### Frontend Forbidden
|
||||||
|
|
||||||
|
- ❌ **Class Components:** No class components. Functional components with hooks only.
|
||||||
|
- ❌ **Inline Styles:** No `style={{}}` props. Use Tailwind utility classes.
|
||||||
|
- ❌ **Hardcoded Strings:** No user-visible hardcoded strings. Use `t()` from i18n.
|
||||||
|
- ❌ **Manual Fetch in Components:** No `fetch()` or `axios` calls in components. Use TanStack Query hooks.
|
||||||
|
- ❌ **Server Data in Zustand:** Zustand is for client state only. Server data goes in TanStack Query.
|
||||||
|
- ❌ **`any` Types:** No `any` type. Use proper TypeScript types.
|
||||||
|
- ❌ **Missing ARIA Attributes:** All interactive elements must have ARIA labels.
|
||||||
|
- ❌ **Touch Targets < 44px:** All buttons/links must have minimum 44px touch target.
|
||||||
|
- ❌ **Direct DOM Manipulation:** No `document.getElementById()` or `querySelector()` in components. Use React refs.
|
||||||
|
- ❌ **Unsafe HTML Rendering:** No `dangerouslySetInnerHTML` without sanitization. Mail bodies must be sanitized (DOMPurify equivalent).
|
||||||
|
|
||||||
|
### Deployment Forbidden
|
||||||
|
|
||||||
|
- ❌ **Running as Root in Container:** Containers run as non-root user (app:app).
|
||||||
|
- ❌ **Exposed DB Port in Production:** PostgreSQL port (5432) must not be exposed externally in production.
|
||||||
|
- ❌ **No Health Check:** All services must have Docker health checks configured.
|
||||||
|
- ❌ **No Volume for Storage:** File storage must use a named volume, not ephemeral container storage.
|
||||||
|
- ❌ **Secrets in docker-compose.yml:** No secrets in compose file. Use `.env` file or Docker secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quality Gates
|
||||||
|
|
||||||
|
### Per-Task Quality Gate
|
||||||
|
|
||||||
|
Before a task is marked complete:
|
||||||
|
1. All test_spec commands must pass.
|
||||||
|
2. Coverage target must be met (measured by pytest-cov / vitest coverage).
|
||||||
|
3. TypeScript compiles without errors (`tsc --noEmit`).
|
||||||
|
4. Linting passes (ruff for backend, eslint for frontend).
|
||||||
|
5. Build succeeds (Vite build for frontend, no build step for backend).
|
||||||
|
6. No forbidden patterns detected.
|
||||||
|
7. All acceptance criteria verified as testable.
|
||||||
|
|
||||||
|
### Phase Gate (after each phase)
|
||||||
|
|
||||||
|
1. All tasks in the phase pass their quality gates.
|
||||||
|
2. quality_reviewer subagent reviews the phase output.
|
||||||
|
3. No critical issues from quality_reviewer.
|
||||||
|
4. Block compactor saves progress.
|
||||||
|
5. User checkpoint before next phase.
|
||||||
|
|
||||||
|
### Release Gate (before v1 deployment)
|
||||||
|
|
||||||
|
1. All 7 v1 tasks complete (T01, T02, T03, T07a, T07b, T09, T10).
|
||||||
|
2. release_auditor runs full audit.
|
||||||
|
3. Docker Compose builds and starts successfully.
|
||||||
|
4. Health endpoint returns 200.
|
||||||
|
5. E2E tests (Playwright) pass.
|
||||||
|
6. All forbidden patterns checked.
|
||||||
|
|
||||||
|
### v2 Release Gate (before v2 plugin deployment)
|
||||||
|
|
||||||
|
1. All 7 v2 tasks complete (T04, T05, T06, T11, T08a, T08b, T08c).
|
||||||
|
2. release_auditor runs full audit.
|
||||||
|
3. All plugin backends + frontends pass quality gates.
|
||||||
|
4. Plugin install/activate/deactivate lifecycle tested.
|
||||||
|
5. All forbidden patterns checked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Environment Setup
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
|
||||||
|
| Variable | Value | Purpose |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `POSTGRES_HOST` | `localhost` (dev) / `postgres` (docker) | Database host |
|
||||||
|
| `POSTGRES_PORT` | `5432` | Database port |
|
||||||
|
| `POSTGRES_DB` | `leocrm` | Database name |
|
||||||
|
| `POSTGRES_USER` | `leocrm` | Database user |
|
||||||
|
| `POSTGRES_PASSWORD` | (from .env) | Database password |
|
||||||
|
| `REDIS_URL` | `redis://localhost:6379/0` | Redis for cache + sessions + jobs |
|
||||||
|
| `LEOCRM_SECRET_KEY` | (min 32 chars) | Session signing secret |
|
||||||
|
| `SESSION_TIMEOUT_HOURS` | `8` | Session expiry |
|
||||||
|
| `MAIL_ENCRYPTION_KEY` | (32-byte hex) | AES-256 key for mail credentials |
|
||||||
|
| `STORAGE_BACKEND` | `local` (dev) / `s3` (prod) | File storage backend |
|
||||||
|
| `STORAGE_PATH` | `/data/leocrm/storage` | Local storage path |
|
||||||
|
| `ONLYOFFICE_URL` | `http://onlyoffice:80` | OnlyOffice document server |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
|
|
||||||
|
### Test Environment
|
||||||
|
|
||||||
|
- Test DB: Ephemeral PostgreSQL (pytest-postgresql or testcontainers).
|
||||||
|
- Test Redis: Ephemeral or fakeredis.
|
||||||
|
- External services (IMAP, SMTP, OnlyOffice): Mocked via `unittest.mock.AsyncMock`.
|
||||||
|
- Test fixtures in `conftest.py` provide: test client, authenticated client (per role), seeded data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Architecture Reference
|
||||||
|
|
||||||
|
Full architecture details: `architecture.md`
|
||||||
|
|
||||||
|
Full task graph with test specs: `task_graph.json`
|
||||||
|
|
||||||
|
Key ADRs:
|
||||||
|
- ADR-01: PostgreSQL 16 (not SQLite)
|
||||||
|
- ADR-02: ARQ (not Celery)
|
||||||
|
- ADR-03: Built-in plugins with manifest (not dynamic pip-install)
|
||||||
|
- ADR-04: TanStack Query (not Redux)
|
||||||
|
- ADR-05: Session-based auth (not JWT)
|
||||||
|
- ADR-06: Soft-delete with `deleted_at` column
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- **AGENTS.md status:** COMPLETE
|
||||||
|
- **task_graph.json status:** COMPLETE (14 tasks: 7 v1 + 7 v2, all with test_spec, 143 features covered, v1/v2 separated, v2.1.0)
|
||||||
|
- **architecture.md status:** COMPLETE (73/73 v1 features referenced, v2 sections marked)
|
||||||
|
- **Ready for v1 implementation:** YES (pending quality_reviewer review + plan_mode transition to implementation_allowed)
|
||||||
|
- **v2 implementation:** After v1 deployment, separate phase
|
||||||
+8
-41
@@ -1,8 +1,4 @@
|
|||||||
"""Alembic environment for async SQLAlchemy.
|
"""Alembic migration environment."""
|
||||||
|
|
||||||
Reads DATABASE_URL from app.core.config and imports all models so
|
|
||||||
autogenerate can detect schema changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,69 +10,40 @@ from sqlalchemy import pool
|
|||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
# Import settings + Base + all models
|
from app.config import get_settings
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.core.db import Base
|
from app.core.db import Base
|
||||||
|
from app.models import * # noqa: F401,F403
|
||||||
# Import models so Base.metadata is populated
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# Override sqlalchemy.url from app settings
|
|
||||||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
|
||||||
|
|
||||||
# Configure Python logging from alembic.ini
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# Metadata for autogenerate
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode (emits SQL without a DB connection)."""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
context.configure(
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
render_as_batch=True, # SQLite ALTER TABLE support
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
"""Run migrations with the given connection."""
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
render_as_batch=True, # SQLite ALTER TABLE support
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations() -> None:
|
async def run_async_migrations() -> None:
|
||||||
"""Run migrations in async mode using an async engine."""
|
connectable = async_engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||||
connectable = async_engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
async with connectable.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
await connectable.dispose()
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
def run_migrations_online() -> None:
|
||||||
"""Run migrations in 'online' mode via asyncio."""
|
|
||||||
asyncio.run(run_async_migrations())
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,14 @@
|
|||||||
Revision ID: ${up_revision}
|
Revision ID: ${up_revision}
|
||||||
Revises: ${down_revision | comma,n}
|
Revises: ${down_revision | comma,n}
|
||||||
Create Date: ${create_date}
|
Create Date: ${create_date}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from typing import Sequence, Union
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
"""init_orgs_and_users
|
|
||||||
|
|
||||||
Revision ID: 0001
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-06-03
|
|
||||||
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0001"
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# === orgs ===
|
|
||||||
op.create_table(
|
|
||||||
"orgs",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("logo_url", sa.String(length=1024), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"default_currency",
|
|
||||||
sa.String(length=3),
|
|
||||||
nullable=False,
|
|
||||||
server_default="EUR",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index("ix_orgs_created_at", "orgs", ["created_at"])
|
|
||||||
|
|
||||||
# === users ===
|
|
||||||
op.create_table(
|
|
||||||
"users",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("email", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"role",
|
|
||||||
sa.String(length=32),
|
|
||||||
nullable=False,
|
|
||||||
server_default="sales_rep",
|
|
||||||
),
|
|
||||||
sa.Column("avatar_url", sa.String(length=1024), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"email_notifications",
|
|
||||||
sa.Boolean(),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("1"),
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_users_org_id"
|
|
||||||
),
|
|
||||||
sa.UniqueConstraint("org_id", "email", name="uq_users_org_email"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_users_email", "users", ["email"])
|
|
||||||
op.create_index("ix_users_org_id", "users", ["org_id"])
|
|
||||||
op.create_index("ix_users_created_at", "users", ["created_at"])
|
|
||||||
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_users_deleted_at", table_name="users")
|
|
||||||
op.drop_index("ix_users_created_at", table_name="users")
|
|
||||||
op.drop_index("ix_users_org_id", table_name="users")
|
|
||||||
op.drop_index("ix_users_email", table_name="users")
|
|
||||||
op.drop_table("users")
|
|
||||||
op.drop_index("ix_orgs_created_at", table_name="orgs")
|
|
||||||
op.drop_table("orgs")
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Initial migration - all core tables.
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# tenants
|
||||||
|
op.create_table(
|
||||||
|
"tenants",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tenants_slug", "tenants", ["slug"])
|
||||||
|
|
||||||
|
# users
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(50), nullable=False, server_default="viewer"),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("preferences", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# user_tenants
|
||||||
|
op.create_table(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# roles
|
||||||
|
op.create_table(
|
||||||
|
"roles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("permissions", postgresql.JSONB, nullable=False),
|
||||||
|
sa.Column("field_permissions", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_roles_tenant_id", "roles", ["tenant_id"])
|
||||||
|
|
||||||
|
# sessions
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("csrf_token", sa.String(255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sessions_tenant_id", "sessions", ["tenant_id"])
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
|
||||||
|
# audit_log
|
||||||
|
op.create_table(
|
||||||
|
"audit_log",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("action", sa.String(50), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("changes", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_log_tenant_id", "audit_log", ["tenant_id"])
|
||||||
|
op.create_index("ix_audit_log_entity_type", "audit_log", ["entity_type"])
|
||||||
|
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
|
||||||
|
op.create_index("ix_audit_log_timestamp", "audit_log", ["timestamp"])
|
||||||
|
|
||||||
|
# deletion_log
|
||||||
|
op.create_table(
|
||||||
|
"deletion_log",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("entity_snapshot", postgresql.JSONB, nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# notifications
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("title", sa.String(200), nullable=False),
|
||||||
|
sa.Column("body", sa.Text, nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_notifications_tenant_id", "notifications", ["tenant_id"])
|
||||||
|
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
||||||
|
op.create_index("ix_notifications_tenant_user_read", "notifications", ["tenant_id", "user_id", "read_at"])
|
||||||
|
|
||||||
|
# password_reset_tokens
|
||||||
|
op.create_table(
|
||||||
|
"password_reset_tokens",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_reset_tokens_tenant_id", "password_reset_tokens", ["tenant_id"])
|
||||||
|
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
|
||||||
|
op.create_index("ix_password_reset_tokens_token_hash", "password_reset_tokens", ["token_hash"])
|
||||||
|
|
||||||
|
# api_tokens
|
||||||
|
op.create_table(
|
||||||
|
"api_tokens",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("scopes", postgresql.JSONB, nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_tokens_tenant_id", "api_tokens", ["tenant_id"])
|
||||||
|
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"])
|
||||||
|
op.create_index("ix_api_tokens_tenant_user", "api_tokens", ["tenant_id", "user_id"])
|
||||||
|
|
||||||
|
# companies
|
||||||
|
op.create_table(
|
||||||
|
"companies",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("account_number", sa.String(40), nullable=True),
|
||||||
|
sa.Column("industry", sa.String(50), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(30), nullable=True),
|
||||||
|
sa.Column("email", sa.String(255), nullable=True),
|
||||||
|
sa.Column("website", sa.String(500), nullable=True),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_companies_tenant_id", "companies", ["tenant_id"])
|
||||||
|
op.create_index("ix_companies_tenant_deleted", "companies", ["tenant_id", "deleted_at"])
|
||||||
|
op.create_index("ix_companies_tenant_name", "companies", ["tenant_id", "name"])
|
||||||
|
|
||||||
|
# Enable RLS on tenant-scoped tables
|
||||||
|
for table in ["companies", "users", "roles", "sessions", "audit_log", "notifications", "api_tokens"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY tenant_isolation ON {table} "
|
||||||
|
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid);"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ["companies", "api_tokens", "password_reset_tokens", "notifications",
|
||||||
|
"deletion_log", "audit_log", "sessions", "roles", "user_tenants",
|
||||||
|
"users", "tenants"]:
|
||||||
|
op.drop_table(table)
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"""business_entities
|
|
||||||
|
|
||||||
Revision ID: 0002
|
|
||||||
Revises: 0001
|
|
||||||
Create Date: 2026-06-03
|
|
||||||
|
|
||||||
Adds 8 new tables for the Phase 4b business logic:
|
|
||||||
- accounts, contacts, deals, activities (with polymorphic parent FKs)
|
|
||||||
- notes, tags, tag_links (polymorphic parent, no DB FK)
|
|
||||||
- deal_stage_history (audit trail of stage transitions)
|
|
||||||
|
|
||||||
All tables include org_id for multi-tenant isolation (R-1) and
|
|
||||||
deleted_at for soft-delete (R-1 default filter).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0002"
|
|
||||||
down_revision: Union[str, None] = "0001"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# === accounts ===
|
|
||||||
op.create_table(
|
|
||||||
"accounts",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("website", sa.String(length=512), nullable=True),
|
|
||||||
sa.Column("industry", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("size", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("address", sa.JSON(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_accounts_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_accounts_owner_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_accounts_name", "accounts", ["name"])
|
|
||||||
op.create_index("ix_accounts_industry", "accounts", ["industry"])
|
|
||||||
op.create_index("ix_accounts_org_id", "accounts", ["org_id"])
|
|
||||||
op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"])
|
|
||||||
op.create_index("ix_accounts_created_at", "accounts", ["created_at"])
|
|
||||||
op.create_index("ix_accounts_deleted_at", "accounts", ["deleted_at"])
|
|
||||||
|
|
||||||
# === contacts ===
|
|
||||||
op.create_table(
|
|
||||||
"contacts",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("first_name", sa.String(length=128), nullable=False),
|
|
||||||
sa.Column("last_name", sa.String(length=128), nullable=False),
|
|
||||||
sa.Column("email", sa.String(length=255), nullable=True),
|
|
||||||
sa.Column("phone", sa.String(length=64), nullable=True),
|
|
||||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_contacts_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_contacts_account_id"),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_contacts_owner_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_contacts_last_name", "contacts", ["last_name"])
|
|
||||||
op.create_index("ix_contacts_email", "contacts", ["email"])
|
|
||||||
op.create_index("ix_contacts_org_id", "contacts", ["org_id"])
|
|
||||||
op.create_index("ix_contacts_account_id", "contacts", ["account_id"])
|
|
||||||
op.create_index("ix_contacts_owner_id", "contacts", ["owner_id"])
|
|
||||||
op.create_index("ix_contacts_created_at", "contacts", ["created_at"])
|
|
||||||
op.create_index("ix_contacts_deleted_at", "contacts", ["deleted_at"])
|
|
||||||
|
|
||||||
# === deals ===
|
|
||||||
op.create_table(
|
|
||||||
"deals",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("title", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("value", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
|
||||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
|
||||||
sa.Column("stage", sa.String(length=32), nullable=False, server_default="lead"),
|
|
||||||
sa.Column("close_date", sa.Date(), nullable=True),
|
|
||||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("won_lost_reason", sa.String(length=512), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_deals_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_deals_account_id"),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_deals_owner_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_deals_stage", "deals", ["stage"])
|
|
||||||
op.create_index("ix_deals_org_id", "deals", ["org_id"])
|
|
||||||
op.create_index("ix_deals_account_id", "deals", ["account_id"])
|
|
||||||
op.create_index("ix_deals_owner_id", "deals", ["owner_id"])
|
|
||||||
op.create_index("ix_deals_created_at", "deals", ["created_at"])
|
|
||||||
op.create_index("ix_deals_deleted_at", "deals", ["deleted_at"])
|
|
||||||
|
|
||||||
# === activities ===
|
|
||||||
op.create_table(
|
|
||||||
"activities",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("type", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("subject", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("body", sa.Text(), nullable=True),
|
|
||||||
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("contact_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("deal_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_activities_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_activities_account_id"),
|
|
||||||
sa.ForeignKeyConstraint(["contact_id"], ["contacts.id"], name="fk_activities_contact_id"),
|
|
||||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], name="fk_activities_deal_id"),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_activities_owner_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_activities_type", "activities", ["type"])
|
|
||||||
op.create_index("ix_activities_due_date", "activities", ["due_date"])
|
|
||||||
op.create_index("ix_activities_org_id", "activities", ["org_id"])
|
|
||||||
op.create_index("ix_activities_account_id", "activities", ["account_id"])
|
|
||||||
op.create_index("ix_activities_contact_id", "activities", ["contact_id"])
|
|
||||||
op.create_index("ix_activities_deal_id", "activities", ["deal_id"])
|
|
||||||
op.create_index("ix_activities_owner_id", "activities", ["owner_id"])
|
|
||||||
op.create_index("ix_activities_created_at", "activities", ["created_at"])
|
|
||||||
op.create_index("ix_activities_deleted_at", "activities", ["deleted_at"])
|
|
||||||
|
|
||||||
# === notes ===
|
|
||||||
op.create_table(
|
|
||||||
"notes",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("body", sa.Text(), nullable=False),
|
|
||||||
sa.Column("author_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_notes_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["author_id"], ["users.id"], name="fk_notes_author_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_notes_parent_type", "notes", ["parent_type"])
|
|
||||||
op.create_index("ix_notes_parent_id", "notes", ["parent_id"])
|
|
||||||
op.create_index("ix_notes_author_id", "notes", ["author_id"])
|
|
||||||
op.create_index("ix_notes_org_id", "notes", ["org_id"])
|
|
||||||
op.create_index("ix_notes_created_at", "notes", ["created_at"])
|
|
||||||
op.create_index("ix_notes_deleted_at", "notes", ["deleted_at"])
|
|
||||||
|
|
||||||
# === tags ===
|
|
||||||
op.create_table(
|
|
||||||
"tags",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("color", sa.String(length=7), nullable=False, server_default="#3B82F6"),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tags_org_id"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_tags_name", "tags", ["name"])
|
|
||||||
op.create_index("ix_tags_org_id", "tags", ["org_id"])
|
|
||||||
op.create_index("ix_tags_created_at", "tags", ["created_at"])
|
|
||||||
op.create_index("ix_tags_deleted_at", "tags", ["deleted_at"])
|
|
||||||
|
|
||||||
# === tag_links ===
|
|
||||||
op.create_table(
|
|
||||||
"tag_links",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("tag_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tag_links_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE", name="fk_tag_links_tag_id"),
|
|
||||||
sa.UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_tag_links_tag_id", "tag_links", ["tag_id"])
|
|
||||||
op.create_index("ix_tag_links_parent_type", "tag_links", ["parent_type"])
|
|
||||||
op.create_index("ix_tag_links_parent_id", "tag_links", ["parent_id"])
|
|
||||||
op.create_index("ix_tag_links_org_id", "tag_links", ["org_id"])
|
|
||||||
op.create_index("ix_tag_links_created_at", "tag_links", ["created_at"])
|
|
||||||
op.create_index("ix_tag_links_deleted_at", "tag_links", ["deleted_at"])
|
|
||||||
|
|
||||||
# === deal_stage_history ===
|
|
||||||
op.create_table(
|
|
||||||
"deal_stage_history",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("deal_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("from_stage", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("to_stage", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("changed_by", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_dsh_org_id"),
|
|
||||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], ondelete="CASCADE", name="fk_dsh_deal_id"),
|
|
||||||
sa.ForeignKeyConstraint(["changed_by"], ["users.id"], name="fk_dsh_changed_by"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_dsh_deal_id", "deal_stage_history", ["deal_id"])
|
|
||||||
op.create_index("ix_dsh_org_id", "deal_stage_history", ["org_id"])
|
|
||||||
op.create_index("ix_dsh_created_at", "deal_stage_history", ["created_at"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_dsh_created_at", table_name="deal_stage_history")
|
|
||||||
op.drop_index("ix_dsh_org_id", table_name="deal_stage_history")
|
|
||||||
op.drop_index("ix_dsh_deal_id", table_name="deal_stage_history")
|
|
||||||
op.drop_table("deal_stage_history")
|
|
||||||
|
|
||||||
op.drop_index("ix_tag_links_deleted_at", table_name="tag_links")
|
|
||||||
op.drop_index("ix_tag_links_created_at", table_name="tag_links")
|
|
||||||
op.drop_index("ix_tag_links_org_id", table_name="tag_links")
|
|
||||||
op.drop_index("ix_tag_links_parent_id", table_name="tag_links")
|
|
||||||
op.drop_index("ix_tag_links_parent_type", table_name="tag_links")
|
|
||||||
op.drop_index("ix_tag_links_tag_id", table_name="tag_links")
|
|
||||||
op.drop_table("tag_links")
|
|
||||||
|
|
||||||
op.drop_index("ix_tags_deleted_at", table_name="tags")
|
|
||||||
op.drop_index("ix_tags_created_at", table_name="tags")
|
|
||||||
op.drop_index("ix_tags_org_id", table_name="tags")
|
|
||||||
op.drop_index("ix_tags_name", table_name="tags")
|
|
||||||
op.drop_table("tags")
|
|
||||||
|
|
||||||
op.drop_index("ix_notes_deleted_at", table_name="notes")
|
|
||||||
op.drop_index("ix_notes_created_at", table_name="notes")
|
|
||||||
op.drop_index("ix_notes_org_id", table_name="notes")
|
|
||||||
op.drop_index("ix_notes_author_id", table_name="notes")
|
|
||||||
op.drop_index("ix_notes_parent_id", table_name="notes")
|
|
||||||
op.drop_index("ix_notes_parent_type", table_name="notes")
|
|
||||||
op.drop_table("notes")
|
|
||||||
|
|
||||||
op.drop_index("ix_activities_deleted_at", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_created_at", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_owner_id", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_deal_id", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_contact_id", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_account_id", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_org_id", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_due_date", table_name="activities")
|
|
||||||
op.drop_index("ix_activities_type", table_name="activities")
|
|
||||||
op.drop_table("activities")
|
|
||||||
|
|
||||||
op.drop_index("ix_deals_deleted_at", table_name="deals")
|
|
||||||
op.drop_index("ix_deals_created_at", table_name="deals")
|
|
||||||
op.drop_index("ix_deals_owner_id", table_name="deals")
|
|
||||||
op.drop_index("ix_deals_account_id", table_name="deals")
|
|
||||||
op.drop_index("ix_deals_org_id", table_name="deals")
|
|
||||||
op.drop_index("ix_deals_stage", table_name="deals")
|
|
||||||
op.drop_table("deals")
|
|
||||||
|
|
||||||
op.drop_index("ix_contacts_deleted_at", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_created_at", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_owner_id", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_account_id", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_org_id", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_email", table_name="contacts")
|
|
||||||
op.drop_index("ix_contacts_last_name", table_name="contacts")
|
|
||||||
op.drop_table("contacts")
|
|
||||||
|
|
||||||
op.drop_index("ix_accounts_deleted_at", table_name="accounts")
|
|
||||||
op.drop_index("ix_accounts_created_at", table_name="accounts")
|
|
||||||
op.drop_index("ix_accounts_owner_id", table_name="accounts")
|
|
||||||
op.drop_index("ix_accounts_org_id", table_name="accounts")
|
|
||||||
op.drop_index("ix_accounts_industry", table_name="accounts")
|
|
||||||
op.drop_index("ix_accounts_name", table_name="accounts")
|
|
||||||
op.drop_table("accounts")
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""T02: contacts, company_contacts, FTS search_tsv on companies.
|
||||||
|
|
||||||
|
Revision ID: 0002_contacts_fts
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0002_contacts_fts"
|
||||||
|
down_revision: Union[str, None] = "0001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- companies: add FTS search_tsv computed column ---
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE companies
|
||||||
|
ADD COLUMN search_tsv TSVECTOR
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('english',
|
||||||
|
coalesce(name, '') || ' ' ||
|
||||||
|
coalesce(description, '') || ' ' ||
|
||||||
|
coalesce(industry, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_companies_search_vec",
|
||||||
|
"companies",
|
||||||
|
["search_tsv"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_companies_industry",
|
||||||
|
"companies",
|
||||||
|
["tenant_id", "industry"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- contacts ---
|
||||||
|
op.create_table(
|
||||||
|
"contacts",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("first_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("last_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(30), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(30), nullable=True),
|
||||||
|
sa.Column("position", sa.String(100), nullable=True),
|
||||||
|
sa.Column("department", sa.String(100), nullable=True),
|
||||||
|
sa.Column("linkedin_url", sa.String(500), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_contacts_tenant_id", "contacts", ["tenant_id"])
|
||||||
|
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
||||||
|
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "last_name", "first_name"])
|
||||||
|
op.create_index("ix_contacts_email", "contacts", ["email"])
|
||||||
|
|
||||||
|
# --- company_contacts (N:M join) ---
|
||||||
|
op.create_table(
|
||||||
|
"company_contacts",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("contact_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role_at_company", sa.String(100), nullable=True),
|
||||||
|
sa.Column("is_primary", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_cc_company", "company_contacts", ["company_id"])
|
||||||
|
op.create_index("ix_cc_contact", "company_contacts", ["contact_id"])
|
||||||
|
op.create_index("ix_company_contacts_tenant_id", "company_contacts", ["tenant_id"])
|
||||||
|
|
||||||
|
# --- RLS on new tenant-scoped tables ---
|
||||||
|
for table in ["contacts", "company_contacts"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
f"""CREATE POLICY tenant_isolation ON {table}
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("company_contacts")
|
||||||
|
op.drop_table("contacts")
|
||||||
|
op.drop_index("ix_companies_search_vec", table_name="companies")
|
||||||
|
op.drop_index("ix_companies_industry", table_name="companies")
|
||||||
|
op.execute("ALTER TABLE companies DROP COLUMN search_tsv")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""T03: plugins + plugin_migrations tables.
|
||||||
|
|
||||||
|
Revision ID: 0003_plugin_system
|
||||||
|
Revises: 0002_contacts_fts
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0003_plugin_system"
|
||||||
|
down_revision: Union[str, None] = "0002_contacts_fts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- plugins table (system-wide, NOT tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"plugins",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(80), nullable=False, unique=True),
|
||||||
|
sa.Column("display_name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("version", sa.String(40), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="installed"),
|
||||||
|
sa.Column("installed", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("config", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plugins_name", "plugins", ["name"], unique=True)
|
||||||
|
|
||||||
|
# --- plugin_migrations table (tracks which migrations have been applied) ---
|
||||||
|
op.create_table(
|
||||||
|
"plugin_migrations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("plugin_name", sa.String(80), nullable=False),
|
||||||
|
sa.Column("migration_file", sa.String(255), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="applied"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("plugin_name", "migration_file", name="ix_plugin_migrations_unique"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plugin_migrations_plugin", "plugin_migrations", ["plugin_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("plugin_migrations")
|
||||||
|
op.drop_table("plugins")
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""T09: ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history tables.
|
||||||
|
|
||||||
|
Revision ID: 0004_ai_workflows
|
||||||
|
Revises: 0003_plugin_system
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0004_ai_workflows"
|
||||||
|
down_revision: Union[str, None] = "0003_plugin_system"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- ai_conversations table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"ai_conversations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False, server_default="Untitled"),
|
||||||
|
sa.Column("context", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_conversations_tenant_id", "ai_conversations", ["tenant_id"])
|
||||||
|
op.create_index("ix_ai_conversations_tenant_user", "ai_conversations", ["tenant_id", "user_id"])
|
||||||
|
|
||||||
|
# --- ai_messages table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"ai_messages",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("conversation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
sa.Column("proposed_actions", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("executed_action", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("execution_result", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("message_index", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_messages_tenant_id", "ai_messages", ["tenant_id"])
|
||||||
|
op.create_index("ix_ai_messages_tenant_conversation", "ai_messages", ["tenant_id", "conversation_id"])
|
||||||
|
op.create_index("ix_ai_messages_conversation_id", "ai_messages", ["conversation_id"])
|
||||||
|
|
||||||
|
# --- workflows table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflows",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("trigger_event", sa.String(100), nullable=True),
|
||||||
|
sa.Column("steps", postgresql.JSONB, nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workflows_tenant_id", "workflows", ["tenant_id"])
|
||||||
|
op.create_index("ix_workflows_tenant_active", "workflows", ["tenant_id", "is_active"])
|
||||||
|
op.create_index("ix_workflows_tenant_trigger", "workflows", ["tenant_id", "trigger_event"])
|
||||||
|
|
||||||
|
# --- workflow_instances table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflow_instances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workflow_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("current_step_index", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("context", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("initiated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("timeout_hours", sa.Integer, nullable=True),
|
||||||
|
sa.Column("timeout_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wf_instances_tenant_id", "workflow_instances", ["tenant_id"])
|
||||||
|
op.create_index("ix_wf_instances_tenant_status", "workflow_instances", ["tenant_id", "status"])
|
||||||
|
op.create_index("ix_wf_instances_tenant_workflow", "workflow_instances", ["tenant_id", "workflow_id"])
|
||||||
|
op.create_index("ix_wf_instances_workflow_id", "workflow_instances", ["workflow_id"])
|
||||||
|
|
||||||
|
# --- workflow_step_history table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflow_step_history",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("instance_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("step_index", sa.Integer, nullable=False),
|
||||||
|
sa.Column("step_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("action", sa.String(50), nullable=False),
|
||||||
|
sa.Column("actor_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("details", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wf_step_history_tenant_id", "workflow_step_history", ["tenant_id"])
|
||||||
|
op.create_index("ix_wf_step_history_tenant_instance", "workflow_step_history", ["tenant_id", "instance_id"])
|
||||||
|
op.create_index("ix_wf_step_history_instance_id", "workflow_step_history", ["instance_id"])
|
||||||
|
|
||||||
|
# --- RLS Policies ---
|
||||||
|
for table in ["ai_conversations", "ai_messages", "workflows", "workflow_instances", "workflow_step_history"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
f"USING (tenant_id::text = current_setting('app.current_tenant_id', true));"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ["workflow_step_history", "workflow_instances", "workflows", "ai_messages", "ai_conversations"]:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table};")
|
||||||
|
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
|
||||||
|
op.drop_table(table)
|
||||||
+1
-3
@@ -1,3 +1 @@
|
|||||||
"""CRM System Application Package."""
|
"""LeoCRM backend application package."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AI Copilot modules — LLM client and action mapper."""
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Action mapper — maps natural language intents to proposed API calls.
|
||||||
|
|
||||||
|
Used by the mock LLM client for test mode and as a fallback.
|
||||||
|
Supports keyword-based intent detection for common CRM operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# Precompiled patterns for intent detection
|
||||||
|
_PATTERNS = {
|
||||||
|
'create_company': re.compile(r'\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b', re.IGNORECASE),
|
||||||
|
'delete_company': re.compile(r'\b(delete|remove)\b.*\b(company|firm)\b', re.IGNORECASE),
|
||||||
|
'update_company': re.compile(r'\b(update|edit|modify|change)\b.*\b(company|firm)\b', re.IGNORECASE),
|
||||||
|
'list_company': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b', re.IGNORECASE),
|
||||||
|
'list_company2': re.compile(r'\bcompan.*\b(list|all)\b', re.IGNORECASE),
|
||||||
|
'create_contact': re.compile(r'\b(create|add|new)\b.*\b(contact|person)\b', re.IGNORECASE),
|
||||||
|
'list_contact': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(contact|person)\b', re.IGNORECASE),
|
||||||
|
'list_workflow': re.compile(r'\b(list|show|get|display)\b.*\b(workflow)\b', re.IGNORECASE),
|
||||||
|
'create_workflow': re.compile(r'\b(create|new|add)\b.*\b(workflow)\b', re.IGNORECASE),
|
||||||
|
'help': re.compile(r'\b(help|what can you do|assist)\b', re.IGNORECASE),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||||
|
_NAME_PATTERNS = [
|
||||||
|
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcontact\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bworkflow\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bfirm\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bperson\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Field extraction patterns
|
||||||
|
_INDUSTRY_PAT = re.compile(r"industry\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_NAME_UPDATE_PAT = re.compile(r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_PHONE_PAT = re.compile(r"phone\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_EMAIL_PAT = re.compile(r"email\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_SEARCH_PAT = re.compile(r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Map a natural language query to proposed API actions.
|
||||||
|
|
||||||
|
Uses keyword matching to detect intents. Returns a list of proposed
|
||||||
|
action dictionaries with method, path, body, description, and confidence.
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
q = query.lower().strip()
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# --- Company intents ---
|
||||||
|
if _PATTERNS['create_company'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': {'name': name or 'New Company'},
|
||||||
|
'description': f"Create a new company named '{name or 'New Company'}'",
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['delete_company'].search(q):
|
||||||
|
entity_id = context.get('company_id') or context.get('entity_id')
|
||||||
|
if entity_id:
|
||||||
|
actions.append({
|
||||||
|
'method': 'DELETE',
|
||||||
|
'path': f'/api/v1/companies/{entity_id}',
|
||||||
|
'body': None,
|
||||||
|
'description': f'Delete company {entity_id}',
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
actions.append({
|
||||||
|
'method': 'DELETE',
|
||||||
|
'path': '/api/v1/companies/{id}',
|
||||||
|
'body': None,
|
||||||
|
'description': 'Delete a company (requires company ID in context or selection)',
|
||||||
|
'confidence': 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['update_company'].search(q):
|
||||||
|
entity_id = context.get('company_id') or context.get('entity_id')
|
||||||
|
path = f'/api/v1/companies/{entity_id}' if entity_id else '/api/v1/companies/{id}'
|
||||||
|
actions.append({
|
||||||
|
'method': 'PATCH',
|
||||||
|
'path': path,
|
||||||
|
'body': _extract_update_fields(query),
|
||||||
|
'description': 'Update company information',
|
||||||
|
'confidence': 0.8,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['list_company'].search(q) or _PATTERNS['list_company2'].search(q):
|
||||||
|
search_term = _extract_search_term(query)
|
||||||
|
desc = 'List companies'
|
||||||
|
if search_term:
|
||||||
|
desc += f" matching '{search_term}'"
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': None,
|
||||||
|
'description': desc,
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Contact intents ---
|
||||||
|
elif _PATTERNS['create_contact'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/contacts',
|
||||||
|
'body': {'name': name or 'New Contact'},
|
||||||
|
'description': f"Create a new contact named '{name or 'New Contact'}'",
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['list_contact'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/contacts',
|
||||||
|
'body': None,
|
||||||
|
'description': 'List contacts',
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Workflow intents ---
|
||||||
|
elif _PATTERNS['list_workflow'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/workflows',
|
||||||
|
'body': None,
|
||||||
|
'description': 'List workflows',
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['create_workflow'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/workflows',
|
||||||
|
'body': {'name': name or 'New Workflow', 'steps': []},
|
||||||
|
'description': 'Create a new workflow',
|
||||||
|
'confidence': 0.8,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Generic fallback ---
|
||||||
|
if not actions:
|
||||||
|
if _PATTERNS['help'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': None,
|
||||||
|
'description': 'Show available companies (demonstration action)',
|
||||||
|
'confidence': 0.3,
|
||||||
|
})
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name(query: str) -> str | None:
|
||||||
|
"""Extract a name from the query, looking for 'named X', 'called X', 'for X'."""
|
||||||
|
for pat in _NAME_PATTERNS:
|
||||||
|
match = pat.search(query)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_search_term(query: str) -> str | None:
|
||||||
|
"""Extract a search term from the query."""
|
||||||
|
match = _SEARCH_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_update_fields(query: str) -> dict[str, Any]:
|
||||||
|
"""Extract fields to update from the query."""
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
if re.search(r'\bindustry\b', query, re.IGNORECASE):
|
||||||
|
match = _INDUSTRY_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['industry'] = match.group(1).strip()
|
||||||
|
if re.search(r'\b(name|rename)\b', query, re.IGNORECASE):
|
||||||
|
match = _NAME_UPDATE_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['name'] = match.group(1).strip()
|
||||||
|
if re.search(r'\bphone\b', query, re.IGNORECASE):
|
||||||
|
match = _PHONE_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['phone'] = match.group(1).strip()
|
||||||
|
if re.search(r'\bemail\b', query, re.IGNORECASE):
|
||||||
|
match = _EMAIL_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['email'] = match.group(1).strip()
|
||||||
|
return fields or {'name': 'Updated Name'}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Configurable LLM client — supports OpenAI-compatible API or mock/stub mode.
|
||||||
|
|
||||||
|
Reads AI_MODEL and AI_API_KEY from environment. If not set, uses mock mode
|
||||||
|
which returns predefined actions based on keyword matching. This allows
|
||||||
|
tests to run without external API dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponse:
|
||||||
|
"""Structured LLM response containing proposed actions."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8):
|
||||||
|
self.message = message
|
||||||
|
self.proposed_actions = proposed_actions
|
||||||
|
self.confidence = confidence
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"message": self.message,
|
||||||
|
"proposed_actions": self.proposed_actions,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""LLM client that translates natural language to proposed API actions.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- If AI_MODEL and AI_API_KEY are set: calls OpenAI-compatible chat completions API
|
||||||
|
- Otherwise: mock/stub mode with keyword-based action mapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model: str | None = None, api_key: str | None = None, api_base: str | None = None):
|
||||||
|
self.model = model or os.environ.get("AI_MODEL", "")
|
||||||
|
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
|
||||||
|
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1")
|
||||||
|
self.is_mock = not bool(self.model and self.api_key)
|
||||||
|
|
||||||
|
async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse:
|
||||||
|
"""Generate proposed actions from natural language query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_query: Natural language input from user
|
||||||
|
context: Optional context (e.g. current page, selected entity)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LLMResponse with message and proposed_actions list
|
||||||
|
"""
|
||||||
|
if self.is_mock:
|
||||||
|
return await self._mock_generate(user_query, context or {})
|
||||||
|
return await self._api_generate(user_query, context or {})
|
||||||
|
|
||||||
|
async def _mock_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
|
||||||
|
"""Mock/stub mode — keyword-based action mapping for tests."""
|
||||||
|
from app.ai.action_mapper import map_query_to_actions
|
||||||
|
|
||||||
|
actions = map_query_to_actions(query, context)
|
||||||
|
if actions:
|
||||||
|
return LLMResponse(
|
||||||
|
message=f"I found {len(actions)} possible action(s) based on your request.",
|
||||||
|
proposed_actions=actions,
|
||||||
|
confidence=0.85,
|
||||||
|
)
|
||||||
|
return LLMResponse(
|
||||||
|
message="I couldn't determine a specific action from your request. Could you be more specific?",
|
||||||
|
proposed_actions=[],
|
||||||
|
confidence=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
|
||||||
|
"""Call OpenAI-compatible chat completions API.
|
||||||
|
|
||||||
|
Sends a system prompt explaining the available API endpoints and asks
|
||||||
|
the LLM to propose actions in structured JSON format.
|
||||||
|
"""
|
||||||
|
system_prompt = self._build_system_prompt(context)
|
||||||
|
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
return self._parse_llm_response(content)
|
||||||
|
|
||||||
|
def _build_system_prompt(self, context: dict[str, Any]) -> str:
|
||||||
|
"""Build system prompt describing available API actions."""
|
||||||
|
available_apis = [
|
||||||
|
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
|
||||||
|
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
|
||||||
|
{"method": "GET", "path": "/api/v1/companies/{id}", "description": "Get company details"},
|
||||||
|
{"method": "PATCH", "path": "/api/v1/companies/{id}", "description": "Update a company"},
|
||||||
|
{"method": "DELETE", "path": "/api/v1/companies/{id}", "description": "Delete a company"},
|
||||||
|
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
|
||||||
|
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
|
||||||
|
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
|
||||||
|
{"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"},
|
||||||
|
]
|
||||||
|
context_str = json.dumps(context) if context else "{}"
|
||||||
|
return (
|
||||||
|
"You are an AI copilot for LeoCRM. Based on the user's natural language request, "
|
||||||
|
"propose one or more API actions. Always respond with a JSON object containing: "
|
||||||
|
'"message": a human-readable summary, '
|
||||||
|
'"proposed_actions": an array of {method, path, body, description, confidence}. '
|
||||||
|
f"Available API endpoints: {json.dumps(available_apis)}. "
|
||||||
|
f"Current context: {context_str}. "
|
||||||
|
"Never execute actions directly — only propose them for user confirmation."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_llm_response(self, content: str) -> LLMResponse:
|
||||||
|
"""Parse LLM JSON response into LLMResponse."""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
return LLMResponse(
|
||||||
|
message=parsed.get("message", "Here are the proposed actions."),
|
||||||
|
proposed_actions=parsed.get("proposed_actions", []),
|
||||||
|
confidence=parsed.get("confidence", 0.8),
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
logger.warning("Failed to parse LLM response as JSON: %s", content[:200])
|
||||||
|
return LLMResponse(
|
||||||
|
message=content,
|
||||||
|
proposed_actions=[],
|
||||||
|
confidence=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: LLMClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_client() -> LLMClient:
|
||||||
|
"""Get or create the global LLM client instance."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = LLMClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def reset_llm_client() -> None:
|
||||||
|
"""Reset the global client (for testing)."""
|
||||||
|
global _client
|
||||||
|
_client = None
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
"""API routers package."""
|
"""API package."""
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
"""API v1 routers."""
|
"""API v1 package."""
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Account API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.account import AccountSize, Industry
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
|
|
||||||
from app.services.account_service import (
|
|
||||||
create_account as svc_create_account,
|
|
||||||
get_account as svc_get_account,
|
|
||||||
list_accounts as svc_list_accounts,
|
|
||||||
soft_delete_account as svc_soft_delete_account,
|
|
||||||
update_account as svc_update_account,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=AccountOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new account",
|
|
||||||
)
|
|
||||||
async def create_account(
|
|
||||||
payload: AccountCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> AccountOut:
|
|
||||||
acc = await svc_create_account(
|
|
||||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
|
||||||
)
|
|
||||||
return AccountOut.model_validate(acc)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[AccountOut],
|
|
||||||
summary="List accounts (paginated, filterable)",
|
|
||||||
)
|
|
||||||
async def list_accounts(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
industry: Optional[Industry] = None,
|
|
||||||
size: Optional[AccountSize] = None,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
q: Optional[str] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[AccountOut]:
|
|
||||||
accounts = await svc_list_accounts(
|
|
||||||
db,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
industry=industry.value if industry else None,
|
|
||||||
size=size.value if size else None,
|
|
||||||
owner_id=owner_id,
|
|
||||||
q=q,
|
|
||||||
)
|
|
||||||
return [AccountOut.model_validate(a) for a in accounts]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{account_id}",
|
|
||||||
response_model=AccountOut,
|
|
||||||
summary="Get one account with all relations",
|
|
||||||
)
|
|
||||||
async def get_account(
|
|
||||||
account_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> AccountOut:
|
|
||||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
|
||||||
if acc is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
|
||||||
return AccountOut.model_validate(acc)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{account_id}",
|
|
||||||
response_model=AccountOut,
|
|
||||||
summary="Update an account",
|
|
||||||
)
|
|
||||||
async def update_account(
|
|
||||||
account_id: int,
|
|
||||||
payload: AccountUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> AccountOut:
|
|
||||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
|
||||||
if acc is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
|
||||||
updated = await svc_update_account(db, acc, payload)
|
|
||||||
return AccountOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{account_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete an account",
|
|
||||||
)
|
|
||||||
async def delete_account(
|
|
||||||
account_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
|
||||||
if acc is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
|
||||||
await svc_soft_delete_account(db, acc)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""Activity API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.activity import ActivityType
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.activity import (
|
|
||||||
ActivityCompleteRequest,
|
|
||||||
ActivityCreate,
|
|
||||||
ActivityOut,
|
|
||||||
ActivityUpdate,
|
|
||||||
)
|
|
||||||
from app.services.activity_service import (
|
|
||||||
NoParentException,
|
|
||||||
complete_activity as svc_complete_activity,
|
|
||||||
create_activity as svc_create_activity,
|
|
||||||
get_activity as svc_get_activity,
|
|
||||||
list_activities as svc_list_activities,
|
|
||||||
soft_delete_activity as svc_soft_delete_activity,
|
|
||||||
update_activity as svc_update_activity,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=ActivityOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new activity (polymorphic parent: account/contact/deal)",
|
|
||||||
)
|
|
||||||
async def create_activity(
|
|
||||||
payload: ActivityCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ActivityOut:
|
|
||||||
try:
|
|
||||||
activity = await svc_create_activity(
|
|
||||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
|
||||||
)
|
|
||||||
except NoParentException as e:
|
|
||||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
||||||
return ActivityOut.model_validate(activity)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[ActivityOut],
|
|
||||||
summary="List activities (paginated, filterable)",
|
|
||||||
)
|
|
||||||
async def list_activities(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
type: Optional[ActivityType] = None,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
overdue: Optional[bool] = None,
|
|
||||||
completed: Optional[bool] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[ActivityOut]:
|
|
||||||
activities = await svc_list_activities(
|
|
||||||
db,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
type=type,
|
|
||||||
owner_id=owner_id,
|
|
||||||
overdue=overdue,
|
|
||||||
completed=completed,
|
|
||||||
)
|
|
||||||
return [ActivityOut.model_validate(a) for a in activities]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{activity_id}",
|
|
||||||
response_model=ActivityOut,
|
|
||||||
summary="Get one activity",
|
|
||||||
)
|
|
||||||
async def get_activity(
|
|
||||||
activity_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ActivityOut:
|
|
||||||
activity = await svc_get_activity(
|
|
||||||
db, activity_id, org_id=current_user.org_id
|
|
||||||
)
|
|
||||||
if activity is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
|
||||||
return ActivityOut.model_validate(activity)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{activity_id}",
|
|
||||||
response_model=ActivityOut,
|
|
||||||
summary="Update an activity",
|
|
||||||
)
|
|
||||||
async def update_activity(
|
|
||||||
activity_id: int,
|
|
||||||
payload: ActivityUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ActivityOut:
|
|
||||||
activity = await svc_get_activity(
|
|
||||||
db, activity_id, org_id=current_user.org_id
|
|
||||||
)
|
|
||||||
if activity is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
|
||||||
try:
|
|
||||||
updated = await svc_update_activity(db, activity, payload)
|
|
||||||
except NoParentException as e:
|
|
||||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
||||||
return ActivityOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{activity_id}/complete",
|
|
||||||
response_model=ActivityOut,
|
|
||||||
summary="Mark an activity as completed",
|
|
||||||
)
|
|
||||||
async def complete_activity(
|
|
||||||
activity_id: int,
|
|
||||||
payload: ActivityCompleteRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ActivityOut:
|
|
||||||
activity = await svc_get_activity(
|
|
||||||
db, activity_id, org_id=current_user.org_id
|
|
||||||
)
|
|
||||||
if activity is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
|
||||||
updated = await svc_complete_activity(db, activity, payload)
|
|
||||||
return ActivityOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{activity_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete an activity",
|
|
||||||
)
|
|
||||||
async def delete_activity(
|
|
||||||
activity_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
activity = await svc_get_activity(
|
|
||||||
db, activity_id, org_id=current_user.org_id
|
|
||||||
)
|
|
||||||
if activity is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
|
||||||
await svc_soft_delete_activity(db, activity)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"""Auth API endpoints: register, login, refresh, logout."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.auth import (
|
|
||||||
LogoutResponse,
|
|
||||||
RegisterResponse,
|
|
||||||
TokenResponse,
|
|
||||||
UserLoginRequest,
|
|
||||||
UserRegisterRequest,
|
|
||||||
)
|
|
||||||
from app.schemas.user import UserOut
|
|
||||||
from app.services import auth_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/register",
|
|
||||||
response_model=RegisterResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Bootstrap user registration (only allowed if users table is empty)",
|
|
||||||
)
|
|
||||||
async def register(
|
|
||||||
payload: UserRegisterRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> RegisterResponse:
|
|
||||||
"""Create the first user and a default organization.
|
|
||||||
|
|
||||||
Returns 403 after the first user has been registered.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, token = await auth_service.register_user(db, payload)
|
|
||||||
except auth_service.BootstrapAlreadyCompleted as e:
|
|
||||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
|
||||||
except auth_service.EmailAlreadyExists as e:
|
|
||||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
return RegisterResponse(
|
|
||||||
user=UserOut.model_validate(user),
|
|
||||||
access_token=token,
|
|
||||||
token_type="bearer",
|
|
||||||
expires_in=settings.jwt_expiry_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/login",
|
|
||||||
response_model=TokenResponse,
|
|
||||||
summary="Login with email + password (form-data or JSON)",
|
|
||||||
)
|
|
||||||
async def login(
|
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> TokenResponse:
|
|
||||||
"""OAuth2-compatible login. `username` field carries the email.
|
|
||||||
|
|
||||||
Returns 401 on invalid credentials — never leaks whether the email exists.
|
|
||||||
"""
|
|
||||||
result = await auth_service.authenticate_user(
|
|
||||||
db, email=form_data.username, password=form_data.password
|
|
||||||
)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid email or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
_user, token = result
|
|
||||||
settings = get_settings()
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token,
|
|
||||||
token_type="bearer",
|
|
||||||
expires_in=settings.jwt_expiry_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/login/json",
|
|
||||||
response_model=TokenResponse,
|
|
||||||
summary="Login with JSON body (alternative to form-data)",
|
|
||||||
)
|
|
||||||
async def login_json(
|
|
||||||
payload: UserLoginRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> TokenResponse:
|
|
||||||
"""JSON-body login variant for clients that prefer JSON over form-data."""
|
|
||||||
result = await auth_service.authenticate_user(
|
|
||||||
db, email=payload.email, password=payload.password
|
|
||||||
)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid email or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
_user, token = result
|
|
||||||
settings = get_settings()
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token,
|
|
||||||
token_type="bearer",
|
|
||||||
expires_in=settings.jwt_expiry_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/refresh",
|
|
||||||
response_model=TokenResponse,
|
|
||||||
summary="Issue a new JWT for the current user",
|
|
||||||
)
|
|
||||||
async def refresh(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> TokenResponse:
|
|
||||||
"""Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret."""
|
|
||||||
settings = get_settings()
|
|
||||||
from app.core.security import create_access_token
|
|
||||||
|
|
||||||
role_str = (
|
|
||||||
current_user.role.value
|
|
||||||
if hasattr(current_user.role, "value")
|
|
||||||
else str(current_user.role)
|
|
||||||
)
|
|
||||||
token = create_access_token(current_user.id, current_user.org_id, role_str)
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token,
|
|
||||||
token_type="bearer",
|
|
||||||
expires_in=settings.jwt_expiry_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/logout",
|
|
||||||
response_model=LogoutResponse,
|
|
||||||
status_code=status.HTTP_200_OK,
|
|
||||||
summary="Logout (client-side token discard)",
|
|
||||||
)
|
|
||||||
async def logout(
|
|
||||||
_current_user: User = Depends(get_current_user),
|
|
||||||
) -> LogoutResponse:
|
|
||||||
"""Stateless logout. Client deletes the token from localStorage.
|
|
||||||
|
|
||||||
The endpoint validates the token (so a stolen token can be detected on logout)
|
|
||||||
but does not maintain a server-side blacklist in v1.
|
|
||||||
"""
|
|
||||||
return LogoutResponse()
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""Contact API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
|
|
||||||
from app.services.contact_service import (
|
|
||||||
InvalidAccount,
|
|
||||||
create_contact as svc_create_contact,
|
|
||||||
get_contact as svc_get_contact,
|
|
||||||
list_contacts as svc_list_contacts,
|
|
||||||
soft_delete_contact as svc_soft_delete_contact,
|
|
||||||
update_contact as svc_update_contact,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=ContactOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new contact",
|
|
||||||
)
|
|
||||||
async def create_contact(
|
|
||||||
payload: ContactCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ContactOut:
|
|
||||||
try:
|
|
||||||
contact = await svc_create_contact(
|
|
||||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
|
||||||
)
|
|
||||||
except InvalidAccount as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
return ContactOut.model_validate(contact)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[ContactOut],
|
|
||||||
summary="List contacts (paginated, filterable)",
|
|
||||||
)
|
|
||||||
async def list_contacts(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
account_id: Optional[int] = None,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
q: Optional[str] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[ContactOut]:
|
|
||||||
contacts = await svc_list_contacts(
|
|
||||||
db,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
account_id=account_id,
|
|
||||||
owner_id=owner_id,
|
|
||||||
q=q,
|
|
||||||
)
|
|
||||||
return [ContactOut.model_validate(c) for c in contacts]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{contact_id}",
|
|
||||||
response_model=ContactOut,
|
|
||||||
summary="Get one contact",
|
|
||||||
)
|
|
||||||
async def get_contact(
|
|
||||||
contact_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ContactOut:
|
|
||||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
|
||||||
if contact is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
return ContactOut.model_validate(contact)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{contact_id}",
|
|
||||||
response_model=ContactOut,
|
|
||||||
summary="Update a contact",
|
|
||||||
)
|
|
||||||
async def update_contact(
|
|
||||||
contact_id: int,
|
|
||||||
payload: ContactUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ContactOut:
|
|
||||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
|
||||||
if contact is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
try:
|
|
||||||
updated = await svc_update_contact(db, contact, payload)
|
|
||||||
except InvalidAccount as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
return ContactOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{contact_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete a contact",
|
|
||||||
)
|
|
||||||
async def delete_contact(
|
|
||||||
contact_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
|
||||||
if contact is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
await svc_soft_delete_contact(db, contact)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Dashboard API endpoints: KPIs and activity feed."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
|
||||||
from app.services.dashboard_service import (
|
|
||||||
get_activity_feed,
|
|
||||||
get_kpis,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/kpis",
|
|
||||||
response_model=KPIOut,
|
|
||||||
summary="Get dashboard KPIs (open deals, pipeline value, won this month, conversion rate)",
|
|
||||||
)
|
|
||||||
async def get_kpis_endpoint(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> KPIOut:
|
|
||||||
kpis = await get_kpis(db, org_id=current_user.org_id)
|
|
||||||
return KPIOut(**kpis) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/feed",
|
|
||||||
response_model=list[ActivityFeedItem],
|
|
||||||
summary="Get the latest 20 activities (sorted by created_at desc)",
|
|
||||||
)
|
|
||||||
async def get_activity_feed_endpoint(
|
|
||||||
limit: int = 20,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[ActivityFeedItem]:
|
|
||||||
activities = await get_activity_feed(
|
|
||||||
db, org_id=current_user.org_id, limit=limit
|
|
||||||
)
|
|
||||||
return [ActivityFeedItem.model_validate(a) for a in activities]
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"""Deal API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.deal import DealStage
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.deal import (
|
|
||||||
DealCreate,
|
|
||||||
DealOut,
|
|
||||||
DealPipelineOut,
|
|
||||||
DealStageUpdate,
|
|
||||||
DealUpdate,
|
|
||||||
)
|
|
||||||
from app.services.deal_service import (
|
|
||||||
InvalidAccount,
|
|
||||||
create_deal as svc_create_deal,
|
|
||||||
get_deal as svc_get_deal,
|
|
||||||
get_pipeline,
|
|
||||||
list_deals as svc_list_deals,
|
|
||||||
soft_delete_deal as svc_soft_delete_deal,
|
|
||||||
update_deal as svc_update_deal,
|
|
||||||
update_stage as svc_update_stage,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/deals", tags=["deals"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=DealOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new deal",
|
|
||||||
)
|
|
||||||
async def create_deal(
|
|
||||||
payload: DealCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> DealOut:
|
|
||||||
try:
|
|
||||||
deal = await svc_create_deal(
|
|
||||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
|
||||||
)
|
|
||||||
except InvalidAccount as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
return DealOut.model_validate(deal)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[DealOut],
|
|
||||||
summary="List deals (paginated, filterable)",
|
|
||||||
)
|
|
||||||
async def list_deals(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
stage: Optional[DealStage] = None,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
account_id: Optional[int] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[DealOut]:
|
|
||||||
deals = await svc_list_deals(
|
|
||||||
db,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
stage=stage.value if stage else None,
|
|
||||||
owner_id=owner_id,
|
|
||||||
account_id=account_id,
|
|
||||||
)
|
|
||||||
return [DealOut.model_validate(d) for d in deals]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/pipeline",
|
|
||||||
response_model=list[DealPipelineOut],
|
|
||||||
summary="Get pipeline view (deals grouped by stage)",
|
|
||||||
)
|
|
||||||
async def get_pipeline_endpoint(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[DealPipelineOut]:
|
|
||||||
pipeline = await get_pipeline(db, org_id=current_user.org_id)
|
|
||||||
out: list[DealPipelineOut] = []
|
|
||||||
for item in pipeline:
|
|
||||||
out.append(
|
|
||||||
DealPipelineOut(
|
|
||||||
stage=DealStage(item["stage"]),
|
|
||||||
count=int(item["count"]),
|
|
||||||
total_value=float(item["total_value"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{deal_id}",
|
|
||||||
response_model=DealOut,
|
|
||||||
summary="Get one deal",
|
|
||||||
)
|
|
||||||
async def get_deal(
|
|
||||||
deal_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> DealOut:
|
|
||||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
|
||||||
if deal is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Deal not found")
|
|
||||||
return DealOut.model_validate(deal)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{deal_id}",
|
|
||||||
response_model=DealOut,
|
|
||||||
summary="Update a deal (does NOT change stage)",
|
|
||||||
)
|
|
||||||
async def update_deal(
|
|
||||||
deal_id: int,
|
|
||||||
payload: DealUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> DealOut:
|
|
||||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
|
||||||
if deal is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Deal not found")
|
|
||||||
updated = await svc_update_deal(db, deal, payload)
|
|
||||||
return DealOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{deal_id}/stage",
|
|
||||||
response_model=DealOut,
|
|
||||||
summary="Change a deal's stage (creates DealStageHistory entry)",
|
|
||||||
)
|
|
||||||
async def update_deal_stage(
|
|
||||||
deal_id: int,
|
|
||||||
payload: DealStageUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> DealOut:
|
|
||||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
|
||||||
if deal is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Deal not found")
|
|
||||||
updated = await svc_update_stage(
|
|
||||||
db, deal, payload.stage, changed_by=current_user.id, reason=payload.reason
|
|
||||||
)
|
|
||||||
return DealOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{deal_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete a deal",
|
|
||||||
)
|
|
||||||
async def delete_deal(
|
|
||||||
deal_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
|
||||||
if deal is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Deal not found")
|
|
||||||
await svc_soft_delete_deal(db, deal)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Health check endpoints: /health (root) and /api/v1/health."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app import __version__
|
|
||||||
from app.core.db import get_db
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["health"])
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_db(db: AsyncSession) -> bool:
|
|
||||||
"""Run SELECT 1 to verify the database connection is alive."""
|
|
||||||
try:
|
|
||||||
result = await db.execute(text("SELECT 1"))
|
|
||||||
result.scalar_one()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Database health check failed: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/health",
|
|
||||||
summary="Healthcheck (used by Coolify + Kubernetes)",
|
|
||||||
)
|
|
||||||
async def health(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Returns 200 if the API and DB are healthy, 503 if the DB is down."""
|
|
||||||
db_ok = await _check_db(db)
|
|
||||||
if not db_ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail={"status": "error", "db": "down"},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"db": "ok",
|
|
||||||
"version": __version__,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/api/v1/health",
|
|
||||||
summary="Versioned healthcheck (for clients that hit /api/v1/*)",
|
|
||||||
)
|
|
||||||
async def api_v1_health(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Same as /health but under the /api/v1 prefix for versioned routing."""
|
|
||||||
db_ok = await _check_db(db)
|
|
||||||
if not db_ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail={"status": "error", "db": "down"},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"db": "ok",
|
|
||||||
"version": __version__,
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Note API endpoints (R-2: polymorphic parent validation)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.note import NoteParentType
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
|
||||||
from app.services.note_service import (
|
|
||||||
InvalidParent,
|
|
||||||
create_note as svc_create_note,
|
|
||||||
get_note as svc_get_note,
|
|
||||||
list_notes as svc_list_notes,
|
|
||||||
soft_delete_note as svc_soft_delete_note,
|
|
||||||
update_note as svc_update_note,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=NoteOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new note (R-2: validates parent exists)",
|
|
||||||
)
|
|
||||||
async def create_note(
|
|
||||||
payload: NoteCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> NoteOut:
|
|
||||||
try:
|
|
||||||
note = await svc_create_note(
|
|
||||||
db, payload, org_id=current_user.org_id, author_id=current_user.id
|
|
||||||
)
|
|
||||||
except InvalidParent as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
return NoteOut.model_validate(note)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[NoteOut],
|
|
||||||
summary="List notes (filterable by parent_type + parent_id)",
|
|
||||||
)
|
|
||||||
async def list_notes(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
parent_type: Optional[NoteParentType] = None,
|
|
||||||
parent_id: Optional[int] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[NoteOut]:
|
|
||||||
notes = await svc_list_notes(
|
|
||||||
db,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
parent_type=parent_type.value if parent_type else None,
|
|
||||||
parent_id=parent_id,
|
|
||||||
)
|
|
||||||
return [NoteOut.model_validate(n) for n in notes]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{note_id}",
|
|
||||||
response_model=NoteOut,
|
|
||||||
summary="Get one note",
|
|
||||||
)
|
|
||||||
async def get_note(
|
|
||||||
note_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> NoteOut:
|
|
||||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
|
||||||
if note is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
|
||||||
return NoteOut.model_validate(note)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{note_id}",
|
|
||||||
response_model=NoteOut,
|
|
||||||
summary="Update a note's body",
|
|
||||||
)
|
|
||||||
async def update_note(
|
|
||||||
note_id: int,
|
|
||||||
payload: NoteUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> NoteOut:
|
|
||||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
|
||||||
if note is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
|
||||||
updated = await svc_update_note(db, note, payload)
|
|
||||||
return NoteOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{note_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete a note",
|
|
||||||
)
|
|
||||||
async def delete_note(
|
|
||||||
note_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
|
||||||
if note is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
|
||||||
await svc_soft_delete_note(db, note)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""Tag API endpoints (R-2: polymorphic parent validation)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
|
||||||
from app.services.tag_service import (
|
|
||||||
DuplicateTagLink,
|
|
||||||
InvalidParent,
|
|
||||||
TagNotFound,
|
|
||||||
create_tag as svc_create_tag,
|
|
||||||
link_tag as svc_link_tag,
|
|
||||||
list_tags as svc_list_tags,
|
|
||||||
unlink_tag as svc_unlink_tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=TagOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new tag",
|
|
||||||
)
|
|
||||||
async def create_tag(
|
|
||||||
payload: TagCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> TagOut:
|
|
||||||
tag = await svc_create_tag(db, payload, org_id=current_user.org_id)
|
|
||||||
return TagOut.model_validate(tag)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[TagOut],
|
|
||||||
summary="List all tags in the org",
|
|
||||||
)
|
|
||||||
async def list_tags(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[TagOut]:
|
|
||||||
tags = await svc_list_tags(db, org_id=current_user.org_id)
|
|
||||||
return [TagOut.model_validate(t) for t in tags]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/link",
|
|
||||||
response_model=TagLinkOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Link a tag to an entity (R-2: validates parent exists)",
|
|
||||||
)
|
|
||||||
async def link_tag_endpoint(
|
|
||||||
payload: TagLinkCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> TagLinkOut:
|
|
||||||
try:
|
|
||||||
link = await svc_link_tag(db, payload, org_id=current_user.org_id)
|
|
||||||
except InvalidParent as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except TagNotFound as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except DuplicateTagLink as e:
|
|
||||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
|
||||||
return TagLinkOut.model_validate(link)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/link",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Unlink a tag from an entity",
|
|
||||||
)
|
|
||||||
async def unlink_tag_endpoint(
|
|
||||||
payload: TagLinkCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
deleted = await svc_unlink_tag(
|
|
||||||
db,
|
|
||||||
tag_id=payload.tag_id,
|
|
||||||
parent_type=payload.parent_type,
|
|
||||||
parent_id=payload.parent_id,
|
|
||||||
org_id=current_user.org_id,
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Tag link not found")
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""User API endpoints: me, list, create, update, soft-delete."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.deps import get_current_admin_user, get_current_user
|
|
||||||
from app.models.user import User, UserRole
|
|
||||||
from app.schemas.user import (
|
|
||||||
UserCreateRequest,
|
|
||||||
UserListResponse,
|
|
||||||
UserOut,
|
|
||||||
UserUpdate,
|
|
||||||
)
|
|
||||||
from app.services import user_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me",
|
|
||||||
response_model=UserOut,
|
|
||||||
summary="Get the currently authenticated user",
|
|
||||||
)
|
|
||||||
async def get_me(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> UserOut:
|
|
||||||
"""Returns the user from the JWT. Used by the frontend for auth checks and profile display."""
|
|
||||||
return UserOut.model_validate(current_user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/me",
|
|
||||||
response_model=UserOut,
|
|
||||||
summary="Update own profile (name, avatar, notification prefs)",
|
|
||||||
)
|
|
||||||
async def update_me(
|
|
||||||
payload: UserUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> UserOut:
|
|
||||||
"""A user can update their own profile, but not their role."""
|
|
||||||
updated = await user_service.update_user_profile(
|
|
||||||
db, current_user, payload, is_admin=False
|
|
||||||
)
|
|
||||||
return UserOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=UserListResponse,
|
|
||||||
summary="List all users in the current org (admin only)",
|
|
||||||
)
|
|
||||||
async def list_users(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(50, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> UserListResponse:
|
|
||||||
"""Paginated list of active users. Restricted to admin role."""
|
|
||||||
skip = (page - 1) * page_size
|
|
||||||
users = await user_service.list_users(
|
|
||||||
db, org_id=current_user.org_id, skip=skip, limit=page_size
|
|
||||||
)
|
|
||||||
total = await user_service.count_users_in_org(db, current_user.org_id)
|
|
||||||
return UserListResponse(
|
|
||||||
items=[UserOut.model_validate(u) for u in users],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=UserOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a new user in the current org (admin only)",
|
|
||||||
)
|
|
||||||
async def create_user(
|
|
||||||
payload: UserCreateRequest,
|
|
||||||
current_user: User = Depends(get_current_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> UserOut:
|
|
||||||
"""Admin creates a new user in the same org."""
|
|
||||||
try:
|
|
||||||
new_user = await user_service.create_user_as_admin(
|
|
||||||
db, payload, org_id=current_user.org_id
|
|
||||||
)
|
|
||||||
except user_service.EmailAlreadyTaken as e:
|
|
||||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
|
||||||
return UserOut.model_validate(new_user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{user_id}",
|
|
||||||
response_model=UserOut,
|
|
||||||
summary="Update a user (admin or self for non-role fields)",
|
|
||||||
)
|
|
||||||
async def update_user(
|
|
||||||
user_id: int,
|
|
||||||
payload: UserUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> UserOut:
|
|
||||||
"""Admin can update any user (including role); non-admin can update only their own profile fields."""
|
|
||||||
is_admin = (
|
|
||||||
current_user.role == UserRole.admin
|
|
||||||
if hasattr(current_user.role, "__eq__") and not isinstance(current_user.role, str)
|
|
||||||
else str(current_user.role) == UserRole.admin.value
|
|
||||||
)
|
|
||||||
if not is_admin and current_user.id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="You can only update your own profile",
|
|
||||||
)
|
|
||||||
|
|
||||||
target = await user_service.get_user_by_id(db, user_id)
|
|
||||||
if target is None:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
if target.org_id != current_user.org_id:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
updated = await user_service.update_user_profile(
|
|
||||||
db, target, payload, is_admin=is_admin
|
|
||||||
)
|
|
||||||
return UserOut.model_validate(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{user_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_class=Response,
|
|
||||||
summary="Soft-delete a user (admin only)",
|
|
||||||
)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
"""Sets deleted_at timestamp. The record stays in the DB for audit purposes."""
|
|
||||||
if current_user.id == user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="You cannot delete your own account"
|
|
||||||
)
|
|
||||||
|
|
||||||
target = await user_service.get_user_by_id(db, user_id)
|
|
||||||
if target is None or target.org_id != current_user.org_id:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
await user_service.soft_delete_user(db, target)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Application configuration via Pydantic Settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
environment: Literal["development", "production", "testing"] = "development"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
||||||
|
db_pool_size: int = 10
|
||||||
|
db_max_overflow: int = 20
|
||||||
|
db_echo: bool = False
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
session_ttl_seconds: int = 28800 # 8 hours
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
bcrypt_rounds: int = 12
|
||||||
|
session_cookie_name: str = "leocrm_session"
|
||||||
|
session_cookie_secure: bool = False # True in production behind HTTPS
|
||||||
|
session_cookie_samesite: str = "strict"
|
||||||
|
session_cookie_httponly: bool = True
|
||||||
|
password_reset_expiry_hours: int = 1
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
rate_limit_login_max: int = 5
|
||||||
|
rate_limit_login_window: int = 900 # 15 min
|
||||||
|
rate_limit_reset_max: int = 3
|
||||||
|
rate_limit_reset_window: int = 3600 # 1 hour
|
||||||
|
rate_limit_reset_confirm_max: int = 5
|
||||||
|
rate_limit_reset_confirm_window: int = 3600 # 1 hour
|
||||||
|
rate_limit_general_max: int = 60
|
||||||
|
rate_limit_general_window: int = 60 # 1 min
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
"""Parse comma-separated CORS origins into a list."""
|
||||||
|
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Get cached settings instance."""
|
||||||
|
return Settings()
|
||||||
@@ -1 +1 @@
|
|||||||
"""Core module: configuration, database, security, dependencies."""
|
"""Core infrastructure package."""
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Audit log middleware — records create/update/delete actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog, DeletionLog
|
||||||
|
|
||||||
|
|
||||||
|
async def log_audit(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID | None,
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID | None = None,
|
||||||
|
changes: dict[str, Any] | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Create an audit log entry."""
|
||||||
|
entry = AuditLog(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def log_deletion(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID | None,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
entity_snapshot: dict[str, Any],
|
||||||
|
) -> DeletionLog:
|
||||||
|
"""Create a deletion log entry (immutable snapshot)."""
|
||||||
|
entry = DeletionLog(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_snapshot=entity_snapshot,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush()
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Session-based authentication, password hashing, and RBAC."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.user import User, UserTenant
|
||||||
|
from app.models.role import Role
|
||||||
|
from app.models.session import Session as SessionModel
|
||||||
|
|
||||||
|
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt."""
|
||||||
|
return _pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Verify a password against a bcrypt hash."""
|
||||||
|
return _pwd_context.verify(password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_token() -> str:
|
||||||
|
"""Generate a cryptographically secure session token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csrf_token() -> str:
|
||||||
|
"""Generate a CSRF token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""SHA-256 hash a token for storage."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> aioredis.Redis:
|
||||||
|
"""Get a Redis client instance."""
|
||||||
|
return aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
user: User,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Create a session in Redis (runtime) and PostgreSQL (audit trail).
|
||||||
|
Returns (session_id, csrf_token).
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
csrf_token = generate_csrf_token()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.session_ttl_seconds)
|
||||||
|
|
||||||
|
# Redis runtime session
|
||||||
|
session_data: dict[str, Any] = {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"role": user.role,
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
}
|
||||||
|
import json
|
||||||
|
await redis.setex(
|
||||||
|
f"session:{session_id}",
|
||||||
|
settings.session_ttl_seconds,
|
||||||
|
json.dumps(session_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
# PostgreSQL audit trail
|
||||||
|
audit_record = SessionModel(
|
||||||
|
id=uuid.UUID(session_id),
|
||||||
|
user_id=user.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
csrf_token=csrf_token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(audit_record)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return session_id, csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_data(redis: aioredis.Redis, session_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve session data from Redis."""
|
||||||
|
import json
|
||||||
|
raw = await redis.get(f"session:{session_id}")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_session(redis: aioredis.Redis, session_id: str) -> None:
|
||||||
|
"""Delete a session from Redis (logout). PostgreSQL record persists."""
|
||||||
|
await redis.delete(f"session:{session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def update_session_tenant(
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
session_id: str,
|
||||||
|
new_tenant_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update the active tenant in a Redis session."""
|
||||||
|
import json
|
||||||
|
settings = get_settings()
|
||||||
|
raw = await redis.get(f"session:{session_id}")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
data = json.loads(raw)
|
||||||
|
data["tenant_id"] = str(new_tenant_id)
|
||||||
|
ttl = await redis.ttl(f"session:{session_id}")
|
||||||
|
if ttl <= 0:
|
||||||
|
ttl = settings.session_ttl_seconds
|
||||||
|
await redis.setex(f"session:{session_id}", ttl, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check_permission(role_name: str, module: str, action: str, permissions: dict | None = None) -> bool:
|
||||||
|
"""Check if a role has permission for a module+action.
|
||||||
|
Built-in roles: admin (all), editor (read+write), viewer (read only).
|
||||||
|
Custom roles use the permissions dict.
|
||||||
|
"""
|
||||||
|
if role_name == "admin":
|
||||||
|
return True
|
||||||
|
if role_name == "editor":
|
||||||
|
if action in ("read", "write", "create", "update"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
if role_name == "viewer":
|
||||||
|
return action == "read"
|
||||||
|
# Custom role — check permissions dict
|
||||||
|
if permissions:
|
||||||
|
module_perms = permissions.get(module, {})
|
||||||
|
return bool(module_perms.get(action, False))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_fields_by_permission(
|
||||||
|
data: dict[str, Any],
|
||||||
|
field_permissions: dict[str, str],
|
||||||
|
role_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Filter response fields based on field-level permissions.
|
||||||
|
field_permissions: {"annual_revenue": "hidden"} → removed for non-admin.
|
||||||
|
"""
|
||||||
|
if role_name == "admin":
|
||||||
|
return data
|
||||||
|
result = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
perm = field_permissions.get(key)
|
||||||
|
if perm == "hidden":
|
||||||
|
continue
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Redis cache wrapper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
_cache_redis: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache() -> aioredis.Redis:
|
||||||
|
"""Get or create the cache Redis client."""
|
||||||
|
global _cache_redis
|
||||||
|
if _cache_redis is None:
|
||||||
|
_cache_redis = aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||||
|
return _cache_redis
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_get(key: str) -> Any | None:
|
||||||
|
"""Get a value from cache."""
|
||||||
|
r = get_cache()
|
||||||
|
raw = await r.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_set(key: str, value: Any, ttl: int = 300) -> None:
|
||||||
|
"""Set a value in cache with TTL."""
|
||||||
|
r = get_cache()
|
||||||
|
await r.setex(key, ttl, json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_delete(key: str) -> None:
|
||||||
|
"""Delete a key from cache."""
|
||||||
|
r = get_cache()
|
||||||
|
await r.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_flush_pattern(pattern: str) -> None:
|
||||||
|
"""Delete all keys matching a pattern."""
|
||||||
|
r = get_cache()
|
||||||
|
keys = await r.keys(pattern)
|
||||||
|
if keys:
|
||||||
|
await r.delete(*keys)
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Application configuration via Pydantic-Settings v2.
|
|
||||||
|
|
||||||
Loads from .env file and environment variables.
|
|
||||||
Hard-fails if AUTH_SECRET is missing or shorter than 32 characters
|
|
||||||
(per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings loaded from environment."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=False,
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# === Database ===
|
|
||||||
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"
|
|
||||||
|
|
||||||
# === JWT / Auth ===
|
|
||||||
# NO DEFAULT — hard-fail if missing (R-5)
|
|
||||||
AUTH_SECRET: str = Field(..., min_length=32)
|
|
||||||
JWT_ALGORITHM: str = "HS256"
|
|
||||||
JWT_EXPIRY_HOURS: int = 24
|
|
||||||
|
|
||||||
# === Password Hashing ===
|
|
||||||
BCRYPT_ROUNDS: int = 12
|
|
||||||
|
|
||||||
# === CORS ===
|
|
||||||
# Comma-separated list of allowed origins, NO wildcards
|
|
||||||
CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000"
|
|
||||||
|
|
||||||
# === Runtime ===
|
|
||||||
ENVIRONMENT: Literal["development", "production", "test"] = "development"
|
|
||||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
||||||
|
|
||||||
@field_validator("AUTH_SECRET")
|
|
||||||
@classmethod
|
|
||||||
def validate_auth_secret(cls, v: str) -> str:
|
|
||||||
"""Ensure AUTH_SECRET is at least 32 characters and not a known dev default."""
|
|
||||||
if len(v) < 32:
|
|
||||||
raise ValueError(
|
|
||||||
f"AUTH_SECRET must be at least 32 characters (got {len(v)}). "
|
|
||||||
"Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'"
|
|
||||||
)
|
|
||||||
# Reject obvious placeholders
|
|
||||||
lowered = v.lower()
|
|
||||||
if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered:
|
|
||||||
raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cors_origins_list(self) -> list[str]:
|
|
||||||
"""Parse CORS_ORIGINS into a list of trimmed, non-empty origins."""
|
|
||||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_production(self) -> bool:
|
|
||||||
"""Check if running in production mode."""
|
|
||||||
return self.ENVIRONMENT == "production"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_test(self) -> bool:
|
|
||||||
"""Check if running in test mode."""
|
|
||||||
return self.ENVIRONMENT == "test"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def jwt_expiry_seconds(self) -> int:
|
|
||||||
"""JWT expiry in seconds (for response `expires_in` field)."""
|
|
||||||
return self.JWT_EXPIRY_HOURS * 3600
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Cached settings instance."""
|
|
||||||
return Settings() # type: ignore[call-arg]
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Async SQLAlchemy engine, session factory, and FastAPI dependency.
|
|
||||||
|
|
||||||
Driver-agnostic: works with both aiosqlite (dev) and asyncpg (prod).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
|
||||||
AsyncEngine,
|
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
"""Declarative base for all ORM models.
|
|
||||||
|
|
||||||
Re-exported here so Alembic env.py can import it from a single location.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# === Engine & Session Factory ===
|
|
||||||
|
|
||||||
_settings = get_settings()
|
|
||||||
|
|
||||||
# SQLite needs check_same_thread=False even for async — handled by aiosqlite.
|
|
||||||
# echo=False in production; controlled by env if needed later.
|
|
||||||
_engine_kwargs: dict[str, Any] = {"echo": False, "future": True}
|
|
||||||
|
|
||||||
# For PostgreSQL asyncpg, pool sizing matters; for SQLite it's irrelevant.
|
|
||||||
if not _settings.DATABASE_URL.startswith("sqlite"):
|
|
||||||
_engine_kwargs["pool_size"] = 5
|
|
||||||
_engine_kwargs["max_overflow"] = 10
|
|
||||||
_engine_kwargs["pool_pre_ping"] = True
|
|
||||||
|
|
||||||
engine: AsyncEngine = create_async_engine(_settings.DATABASE_URL, **_engine_kwargs)
|
|
||||||
|
|
||||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
||||||
bind=engine,
|
|
||||||
expire_on_commit=False,
|
|
||||||
class_=AsyncSession,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === Dependency ===
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""FastAPI dependency that yields an AsyncSession and ensures cleanup."""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
# No explicit close needed — async context manager handles it.
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_engine() -> None:
|
|
||||||
"""Dispose of the engine on application shutdown."""
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Database engine, session management, and base model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import event as sa_event
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
||||||
|
from sqlalchemy import text, String, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Declarative base with shared columns and tenant-scoping support."""
|
||||||
|
|
||||||
|
@declared_attr.directive
|
||||||
|
def __tablename__(cls) -> str:
|
||||||
|
return cls.__name__.lower() + "s"
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Adds created_at and updated_at timestamps."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMixin(TimestampMixin):
|
||||||
|
"""Adds tenant_id column and enables ORM-level auto-filtering."""
|
||||||
|
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global engine and session factory
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
"""Get or create the global async engine."""
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the global session factory."""
|
||||||
|
global _session_factory
|
||||||
|
if _session_factory is None:
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
bind=get_engine(),
|
||||||
|
expire_on_commit=False,
|
||||||
|
class_=AsyncSession,
|
||||||
|
)
|
||||||
|
return _session_factory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency: yield an async database session."""
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str) -> None:
|
||||||
|
"""Set PostgreSQL session variable for RLS tenant context."""
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||||
|
{"tid": str(tenant_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def create_db_session(tenant_id: uuid.UUID | str | None = None) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create a standalone session outside FastAPI (e.g. for tests/workers)."""
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
if tenant_id is not None:
|
||||||
|
await set_tenant_context(session, tenant_id)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def close_engine() -> None:
|
||||||
|
"""Dispose the global engine (for shutdown)."""
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Replace the global engine with a test engine. Returns a session factory."""
|
||||||
|
global _engine, _session_factory
|
||||||
|
_engine = engine
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, class_=AsyncSession
|
||||||
|
)
|
||||||
|
return _session_factory
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""FastAPI dependency functions for auth and role checks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.core.security import decode_access_token
|
|
||||||
from app.models.user import User, UserRole
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
token: str = Depends(oauth2_scheme),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> User:
|
|
||||||
"""Resolve the current authenticated user from the JWT in the Authorization header.
|
|
||||||
|
|
||||||
Raises 401 on invalid/expired token, missing user, or soft-deleted user.
|
|
||||||
"""
|
|
||||||
credentials_exc = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
if payload is None:
|
|
||||||
raise credentials_exc
|
|
||||||
|
|
||||||
sub = payload.get("sub")
|
|
||||||
if sub is None:
|
|
||||||
raise credentials_exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_id = int(sub)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise credentials_exc from None
|
|
||||||
|
|
||||||
# Load user fresh from DB to honor soft-delete and role changes
|
|
||||||
result = await db.execute(
|
|
||||||
select(User).where(User.id == user_id, User.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exc
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_admin_user(
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
"""Require the current user to have the admin role."""
|
|
||||||
user_role = (
|
|
||||||
user.role.value if hasattr(user.role, "value") else str(user.role)
|
|
||||||
)
|
|
||||||
if user_role != UserRole.admin.value:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Admin role required",
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""In-process event bus for publish/subscribe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Callable, Coroutine
|
||||||
|
|
||||||
|
EventHandler = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""Simple async event bus for in-process pub/sub."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||||
|
|
||||||
|
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
|
"""Subscribe a handler to an event."""
|
||||||
|
self._handlers[event_name].append(handler)
|
||||||
|
|
||||||
|
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
|
"""Unsubscribe a handler from an event."""
|
||||||
|
if event_name in self._handlers:
|
||||||
|
self._handlers[event_name] = [h for h in self._handlers[event_name] if h is not handler]
|
||||||
|
|
||||||
|
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""Publish an event to all subscribers."""
|
||||||
|
handlers = self._handlers.get(event_name, [])
|
||||||
|
tasks = [asyncio.create_task(h(payload)) for h in handlers]
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Global event bus instance
|
||||||
|
_event_bus = EventBus()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_bus() -> EventBus:
|
||||||
|
"""Get the global event bus."""
|
||||||
|
return _event_bus
|
||||||
|
|
||||||
|
|
||||||
|
def register_workflow_event_handlers() -> None:
|
||||||
|
"""Register workflow event handlers on the global event bus.
|
||||||
|
|
||||||
|
Subscribes to events that can trigger workflows (user.created, etc.).
|
||||||
|
Should be called during application startup.
|
||||||
|
"""
|
||||||
|
from app.workflows.engine import register_workflow_event_handlers as _register
|
||||||
|
_register()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""ARQ job queue integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job_pool():
|
||||||
|
"""Get an ARQ job pool for enqueueing background tasks."""
|
||||||
|
settings = get_settings()
|
||||||
|
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||||
|
return await create_pool(redis_settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_job(job_name: str, *args: Any, **kwargs: Any) -> str | None:
|
||||||
|
"""Enqueue a background job. Returns job_id or None."""
|
||||||
|
pool = await get_job_pool()
|
||||||
|
job = await pool.enqueue_job(job_name, *args, **kwargs)
|
||||||
|
if job:
|
||||||
|
return job.job_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job_status(job_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get the status of a background job."""
|
||||||
|
pool = await get_job_pool()
|
||||||
|
job_info = await pool.job_info(job_id)
|
||||||
|
if job_info is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": str(job_info.status),
|
||||||
|
"result": job_info.result,
|
||||||
|
"enqueue_time": job_info.enqueue_time.isoformat() if job_info.enqueue_time else None,
|
||||||
|
"start_time": job_info.start_time.isoformat() if job_info.start_time else None,
|
||||||
|
"finish_time": job_info.finish_time.isoformat() if job_info.finish_time else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""CSRF middleware — Origin header validation for POST/PATCH/DELETE."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request, status
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Validate Origin header on all state-changing requests.
|
||||||
|
SameSite=Strict cookie + Origin validation = CSRF protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNSAFE_METHODS = {"POST", "PATCH", "PUT", "DELETE"}
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method in self.UNSAFE_METHODS:
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if not origin:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
content={"detail": "Missing Origin header", "code": "csrf_missing_origin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
allowed = settings.cors_origin_list
|
||||||
|
# In production, same-origin is enforced; in dev, explicit origins
|
||||||
|
if origin not in allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
content={"detail": "Invalid Origin", "code": "csrf_invalid_origin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Notification service — create and manage user notifications."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
async def create_notification(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
type: str,
|
||||||
|
title: str,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> Notification:
|
||||||
|
"""Create a new notification for a user."""
|
||||||
|
notif = Notification(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
type=type,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
db.add(notif)
|
||||||
|
await db.flush()
|
||||||
|
return notif
|
||||||
|
|
||||||
|
|
||||||
|
async def list_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List notifications for a user, unread first, then by created_at desc."""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
# Count total
|
||||||
|
count_q = select(func.count()).select_from(Notification).where(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
)
|
||||||
|
total = (await db.execute(count_q)).scalar() or 0
|
||||||
|
|
||||||
|
# Query — unread first (read_at IS NULL), then newest
|
||||||
|
q = (
|
||||||
|
select(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
Notification.read_at.isnot(None), # False (unread) sorts first
|
||||||
|
Notification.created_at.desc(),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [_notification_to_dict(n) for n in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_notification_read(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
) -> Notification | None:
|
||||||
|
"""Mark a notification as read."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
q = (
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
)
|
||||||
|
.values(read_at=datetime.now(timezone.utc))
|
||||||
|
.returning(Notification)
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unread_count(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Get unread notification count for a user."""
|
||||||
|
q = select(func.count()).select_from(Notification).where(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_to_dict(n: Notification) -> dict[str, Any]:
|
||||||
|
"""Convert a notification to a dict."""
|
||||||
|
return {
|
||||||
|
"id": str(n.id),
|
||||||
|
"type": n.type,
|
||||||
|
"title": n.title,
|
||||||
|
"body": n.body,
|
||||||
|
"read_at": n.read_at.isoformat() if n.read_at else None,
|
||||||
|
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Redis-based rate limiting for auth endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.core.auth import get_redis
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def check_rate_limit(
|
||||||
|
redis_key: str,
|
||||||
|
max_attempts: int,
|
||||||
|
window_seconds: int,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit using Redis INCR + EXPIRE.
|
||||||
|
Raises 429 if limit exceeded.
|
||||||
|
"""
|
||||||
|
redis = get_redis()
|
||||||
|
current = await redis.incr(redis_key)
|
||||||
|
if current == 1:
|
||||||
|
await redis.expire(redis_key, window_seconds)
|
||||||
|
if current > max_attempts:
|
||||||
|
ttl = await redis.ttl(redis_key)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail={
|
||||||
|
"detail": "Rate limit exceeded",
|
||||||
|
"code": "rate_limited",
|
||||||
|
"retry_after": ttl,
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(ttl)} if ttl > 0 else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_rate_limit(redis_key: str) -> None:
|
||||||
|
"""Reset a rate limit counter (e.g. on successful login)."""
|
||||||
|
redis = get_redis()
|
||||||
|
await redis.delete(redis_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract client IP from request."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""Password hashing and JWT encoding/decoding.
|
|
||||||
|
|
||||||
Uses python-jose[cryptography] (per 02-architecture.md Section 13.1) and
|
|
||||||
passlib[bcrypt] with bcrypt 4.0.1 (per Section 13.6, 03a-patterns R-6).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
_settings = get_settings()
|
|
||||||
|
|
||||||
# === Password Hashing ===
|
|
||||||
|
|
||||||
# CryptContext auto-upgrades old hashes when verified. bcrypt 4.0.1 is pinned
|
|
||||||
# because 4.1+ breaks passlib (per 03a-patterns-summary R-6).
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
|
||||||
"""Hash a plaintext password using bcrypt with the configured rounds."""
|
|
||||||
return pwd_context.hash(plain)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
|
||||||
"""Verify a plaintext password against a bcrypt hash."""
|
|
||||||
try:
|
|
||||||
return pwd_context.verify(plain, hashed)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# === JWT ===
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
|
||||||
user_id: int,
|
|
||||||
org_id: int,
|
|
||||||
role: str,
|
|
||||||
expires_delta: timedelta | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Create a JWT access token with the given user, org, and role claims.
|
|
||||||
|
|
||||||
The token contains:
|
|
||||||
- sub: user id (string)
|
|
||||||
- org_id: organization id
|
|
||||||
- role: user role
|
|
||||||
- exp: expiry timestamp
|
|
||||||
- iat: issued-at timestamp
|
|
||||||
"""
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if expires_delta is None:
|
|
||||||
expires_delta = timedelta(hours=_settings.JWT_EXPIRY_HOURS)
|
|
||||||
expire = now + expires_delta
|
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"sub": str(user_id),
|
|
||||||
"org_id": org_id,
|
|
||||||
"role": role,
|
|
||||||
"iat": int(now.timestamp()),
|
|
||||||
"exp": int(expire.timestamp()),
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, _settings.AUTH_SECRET, algorithm=_settings.JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
|
||||||
"""Decode and validate a JWT access token.
|
|
||||||
|
|
||||||
Returns the payload dict on success, or None on any error
|
|
||||||
(invalid signature, malformed token, expired).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
_settings.AUTH_SECRET,
|
|
||||||
algorithms=[_settings.JWT_ALGORITHM],
|
|
||||||
)
|
|
||||||
return payload
|
|
||||||
except JWTError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_token_expired(token: str) -> bool:
|
|
||||||
"""Check whether a token is expired without raising on other errors."""
|
|
||||||
try:
|
|
||||||
jwt.get_unverified_claims(token)
|
|
||||||
# If decode succeeds, it's not expired.
|
|
||||||
jwt.decode(
|
|
||||||
token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM]
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except JWTError as e:
|
|
||||||
return "expired" in str(e).lower() or "exp" in str(e).lower()
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Service container (dependency injection)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.cache import get_cache
|
||||||
|
from app.core.event_bus import get_event_bus
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceContainer:
|
||||||
|
"""Simple DI container for shared services."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._services: dict[str, Any] = {}
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def register(self, name: str, instance: Any) -> None:
|
||||||
|
"""Register a service instance."""
|
||||||
|
self._services[name] = instance
|
||||||
|
|
||||||
|
def get(self, name: str) -> Any:
|
||||||
|
"""Get a service by name."""
|
||||||
|
if name not in self._services:
|
||||||
|
raise KeyError(f"Service '{name}' not registered")
|
||||||
|
return self._services[name]
|
||||||
|
|
||||||
|
def has(self, name: str) -> bool:
|
||||||
|
"""Check if a service is registered."""
|
||||||
|
return name in self._services
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize core services."""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self.register("cache", get_cache())
|
||||||
|
self.register("event_bus", get_event_bus())
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
_container = ServiceContainer()
|
||||||
|
|
||||||
|
|
||||||
|
def get_container() -> ServiceContainer:
|
||||||
|
"""Get the global service container."""
|
||||||
|
return _container
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tenant-scoping: ORM auto-filter and tenant context management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import event as sa_event
|
||||||
|
from sqlalchemy.orm import Session, with_loader_criteria
|
||||||
|
from sqlalchemy.sql import Select
|
||||||
|
|
||||||
|
from app.core.db import TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tenant_filter(query: Select, tenant_id: uuid.UUID) -> Select:
|
||||||
|
"""Apply tenant_id filter to a SELECT query."""
|
||||||
|
# This is used explicitly when needed
|
||||||
|
return query.where(TenantMixin.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_rls_context(session, tenant_id: uuid.UUID | str) -> None:
|
||||||
|
"""Set PostgreSQL RLS session variable."""
|
||||||
|
from app.core.db import set_tenant_context
|
||||||
|
await set_tenant_context(session, tenant_id)
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
"""FastAPI dependencies: auth, db, tenant context, RBAC."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.auth import get_redis, get_session_data
|
||||||
|
from app.core.db import get_db, set_tenant_context
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis_dep() -> aioredis.Redis:
|
||||||
|
"""FastAPI dependency for Redis client."""
|
||||||
|
return get_redis()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get the current authenticated user from session cookie.
|
||||||
|
Returns session data dict with user_id, tenant_id, email, name, role.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
session_id = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
session_data = await get_session_data(redis, session_id)
|
||||||
|
if session_data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not session_data.get("is_active", True):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "User account deactivated", "code": "user_inactive"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set RLS tenant context
|
||||||
|
tenant_id = uuid.UUID(session_data["tenant_id"])
|
||||||
|
await set_tenant_context(db, tenant_id)
|
||||||
|
|
||||||
|
return session_data
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Require admin role."""
|
||||||
|
if current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Admin access required", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_write(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Require write permission (admin or editor)."""
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
if role not in ("admin", "editor"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Write access required", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_id(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Extract tenant_id from current user session."""
|
||||||
|
return uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_id(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Extract user_id from current user session."""
|
||||||
|
return uuid.UUID(current_user["user_id"])
|
||||||
+35
-177
@@ -1,206 +1,64 @@
|
|||||||
"""FastAPI application entry point.
|
"""FastAPI application - LeoCRM backend."""
|
||||||
|
|
||||||
Wires up:
|
|
||||||
- CORS middleware (whitelist, NO wildcard)
|
|
||||||
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
|
|
||||||
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
|
|
||||||
- Startup/shutdown events (DB connection check, log level)
|
|
||||||
- Versioned API router mounts under /api/v1
|
|
||||||
- Root-level /health endpoint for Coolify healthcheck
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from pathlib import Path
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, status
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
||||||
|
|
||||||
from app import __version__
|
from app.config import get_settings
|
||||||
from app.api.v1 import (
|
from app.core.middleware import CSRFMiddleware
|
||||||
accounts,
|
from app.core.db import close_engine, get_engine
|
||||||
activities,
|
from app.core.service_container import get_container
|
||||||
auth,
|
from app.plugins.registry import get_registry
|
||||||
contacts,
|
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
|
||||||
dashboard,
|
|
||||||
deals,
|
|
||||||
health,
|
|
||||||
notes,
|
|
||||||
tags,
|
|
||||||
users,
|
|
||||||
)
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.core.db import AsyncSessionLocal, dispose_engine
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# === Lifespan ===
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: startup and shutdown hooks."""
|
"""Application lifespan: startup and shutdown."""
|
||||||
settings = get_settings()
|
# Initialize service container
|
||||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
container = get_container()
|
||||||
logger.info(
|
await container.initialize()
|
||||||
"Starting CRM System v%s in %s mode",
|
|
||||||
__version__,
|
|
||||||
settings.ENVIRONMENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify DB connection on startup
|
# Initialize plugin registry and discover built-in plugins
|
||||||
try:
|
registry = get_registry()
|
||||||
async with AsyncSessionLocal() as session:
|
registry.initialize(get_engine(), app)
|
||||||
await session.execute(text("SELECT 1"))
|
registry.discover_builtins()
|
||||||
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Database connection FAILED on startup: %s", e)
|
|
||||||
# Don't crash — let /health report the issue so Coolify can restart
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
await close_engine()
|
||||||
logger.info("Shutting down CRM System")
|
|
||||||
await dispose_engine()
|
|
||||||
|
|
||||||
|
|
||||||
# === App ===
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Application factory (allows easy testing override)."""
|
"""Create and configure the FastAPI application."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="CRM System",
|
|
||||||
version=__version__,
|
|
||||||
docs_url="/docs",
|
|
||||||
redoc_url="/redoc",
|
|
||||||
openapi_url="/openapi.json",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# === CORS ===
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins_list,
|
allow_origins=settings.cors_origin_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
|
||||||
|
max_age=3600,
|
||||||
)
|
)
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
# === Security Headers ===
|
|
||||||
@app.middleware("http")
|
|
||||||
async def security_headers_middleware(request: Request, call_next):
|
|
||||||
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
|
|
||||||
response = await call_next(request)
|
|
||||||
settings_local = get_settings()
|
|
||||||
|
|
||||||
if settings_local.is_production:
|
|
||||||
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
|
|
||||||
csp = (
|
|
||||||
"default-src 'self'; "
|
|
||||||
"script-src 'self' https://cdn.tailwindcss.com; "
|
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
|
||||||
"img-src 'self' data:; "
|
|
||||||
"object-src 'none';"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
|
|
||||||
csp = (
|
|
||||||
"default-src 'self'; "
|
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
|
||||||
"img-src 'self' data:; "
|
|
||||||
"object-src 'none';"
|
|
||||||
)
|
|
||||||
|
|
||||||
response.headers["Content-Security-Policy"] = csp
|
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
|
||||||
if settings_local.is_production:
|
|
||||||
response.headers["Strict-Transport-Security"] = (
|
|
||||||
"max-age=31536000; includeSubDomains"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
# === Exception Handlers ===
|
|
||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
|
||||||
async def http_exception_handler(
|
|
||||||
request: Request, exc: StarletteHTTPException
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Format HTTPException responses consistently."""
|
|
||||||
# Distinguish token-expired for FR-1.7 acceptance criterion
|
|
||||||
if exc.status_code == 401:
|
|
||||||
detail = exc.detail
|
|
||||||
if detail == "Could not validate credentials":
|
|
||||||
# Token invalid or expired — callers can detect this
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={"detail": "token_expired_or_invalid"},
|
|
||||||
headers=exc.headers,
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={"detail": exc.detail},
|
|
||||||
headers=exc.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def validation_exception_handler(
|
|
||||||
request: Request, exc: RequestValidationError
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Format Pydantic validation errors consistently."""
|
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
content={"detail": jsonable_encoder(exc.errors())},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(SQLAlchemyError)
|
|
||||||
async def sqlalchemy_exception_handler(
|
|
||||||
request: Request, exc: SQLAlchemyError
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Log DB errors and return a 500 without leaking internals."""
|
|
||||||
logger.exception("Database error on %s %s", request.method, request.url)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
content={"detail": "Database error"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# === Routers ===
|
|
||||||
# Health is mounted at both /health (root) and /api/v1/health (versioned)
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router, prefix="/api/v1")
|
app.include_router(users.router)
|
||||||
# Phase 4b business routers
|
app.include_router(roles.router)
|
||||||
app.include_router(accounts.router, prefix="/api/v1")
|
app.include_router(tenants.router)
|
||||||
app.include_router(contacts.router, prefix="/api/v1")
|
app.include_router(notifications.router)
|
||||||
app.include_router(deals.router, prefix="/api/v1")
|
app.include_router(companies.router)
|
||||||
app.include_router(activities.router, prefix="/api/v1")
|
app.include_router(contacts.router)
|
||||||
app.include_router(notes.router, prefix="/api/v1")
|
app.include_router(import_export.router)
|
||||||
app.include_router(tags.router, prefix="/api/v1")
|
app.include_router(plugins.router)
|
||||||
app.include_router(dashboard.router, prefix="/api/v1")
|
app.include_router(ai_copilot.router)
|
||||||
|
app.include_router(workflows.router)
|
||||||
# === Static Files (Frontend) ===
|
|
||||||
# Mount app/webui/ on root AFTER all routers, so /api/v1/* still matches first.
|
|
||||||
# StaticFiles mit html=True servt index.html on `/` und alle .html files direkt.
|
|
||||||
webui_dir = Path(__file__).parent / "webui"
|
|
||||||
if webui_dir.exists():
|
|
||||||
app.mount("/", StaticFiles(directory=str(webui_dir), html=True), name="webui")
|
|
||||||
else:
|
|
||||||
logger.warning("webui directory not found at %s — frontend not served", webui_dir)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
+28
-33
@@ -1,37 +1,32 @@
|
|||||||
"""SQLAlchemy ORM models for the CRM system."""
|
"""SQLAlchemy models for LeoCRM."""
|
||||||
|
|
||||||
from app.models.account import Account, AccountSize, Industry
|
from app.models.tenant import Tenant
|
||||||
from app.models.activity import Activity, ActivityType
|
from app.models.user import User, UserTenant
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
from app.models.role import Role
|
||||||
from app.models.contact import Contact
|
from app.models.session import Session
|
||||||
from app.models.deal import Deal, DealStage
|
from app.models.audit import AuditLog, DeletionLog
|
||||||
from app.models.deal_stage_history import DealStageHistory
|
from app.models.notification import Notification
|
||||||
from app.models.note import Note, NoteParentType
|
from app.models.auth import PasswordResetToken, ApiToken
|
||||||
from app.models.org import Org
|
from app.models.company import Company
|
||||||
from app.models.tag import Tag
|
from app.models.contact import Contact, CompanyContact
|
||||||
from app.models.tag_link import TagLink, TagLinkParentType
|
from app.models.plugin import Plugin, PluginMigration
|
||||||
from app.models.user import User, UserRole
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Account",
|
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||||
"AccountSize",
|
"AuditLog", "DeletionLog", "Notification",
|
||||||
"Activity",
|
"PasswordResetToken", "ApiToken", "Company",
|
||||||
"ActivityType",
|
"Contact", "CompanyContact",
|
||||||
"Base",
|
"Plugin", "PluginMigration",
|
||||||
"Contact",
|
]
|
||||||
"Deal",
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
"DealStage",
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
"DealStageHistory",
|
|
||||||
"Industry",
|
__all__ = [
|
||||||
"Note",
|
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||||
"NoteParentType",
|
"AuditLog", "DeletionLog", "Notification",
|
||||||
"Org",
|
"PasswordResetToken", "ApiToken", "Company",
|
||||||
"OrgScopedMixin",
|
"Contact", "CompanyContact",
|
||||||
"SoftDeleteMixin",
|
"Plugin", "PluginMigration",
|
||||||
"Tag",
|
"AIConversation", "AIMessage",
|
||||||
"TagLink",
|
"Workflow", "WorkflowInstance", "WorkflowStepHistory",
|
||||||
"TagLinkParentType",
|
|
||||||
"TimestampMixin",
|
|
||||||
"User",
|
|
||||||
"UserRole",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Account model: companies/organizations that the CRM tracks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, ForeignKey, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.contact import Contact
|
|
||||||
from app.models.deal import Deal
|
|
||||||
from app.models.activity import Activity
|
|
||||||
from app.models.note import Note
|
|
||||||
from app.models.tag_link import TagLink
|
|
||||||
|
|
||||||
|
|
||||||
class Industry(str, Enum):
|
|
||||||
"""Industry classification for accounts."""
|
|
||||||
|
|
||||||
startup = "startup"
|
|
||||||
sme = "sme"
|
|
||||||
midmarket = "midmarket"
|
|
||||||
enterprise = "enterprise"
|
|
||||||
other = "other"
|
|
||||||
|
|
||||||
|
|
||||||
class AccountSize(str, Enum):
|
|
||||||
"""Company size category."""
|
|
||||||
|
|
||||||
startup = "startup"
|
|
||||||
sme = "sme"
|
|
||||||
midmarket = "midmarket"
|
|
||||||
enterprise = "enterprise"
|
|
||||||
|
|
||||||
|
|
||||||
class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "accounts"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
|
||||||
website: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
|
||||||
industry: Mapped[Optional[Industry]] = mapped_column(
|
|
||||||
String(32), nullable=True, index=True
|
|
||||||
)
|
|
||||||
size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True)
|
|
||||||
address: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
|
||||||
owner_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
owner: Mapped["User"] = relationship(
|
|
||||||
"User", foreign_keys=[owner_id], lazy="joined"
|
|
||||||
)
|
|
||||||
contacts: Mapped[list["Contact"]] = relationship(
|
|
||||||
"Contact", back_populates="account", lazy="selectin"
|
|
||||||
)
|
|
||||||
deals: Mapped[list["Deal"]] = relationship(
|
|
||||||
"Deal", back_populates="account", lazy="selectin"
|
|
||||||
)
|
|
||||||
activities: Mapped[list["Activity"]] = relationship(
|
|
||||||
"Activity", back_populates="account", lazy="selectin"
|
|
||||||
)
|
|
||||||
notes: Mapped[list["Note"]] = relationship(
|
|
||||||
"Note",
|
|
||||||
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
|
|
||||||
viewonly=True,
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
tags: Mapped[list["TagLink"]] = relationship(
|
|
||||||
"TagLink",
|
|
||||||
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
|
|
||||||
viewonly=True,
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Account id={self.id} name={self.name!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Account", "AccountSize", "Industry"]
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Activity model: calls, meetings, emails, tasks, notes tied to any entity."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.account import Account
|
|
||||||
from app.models.contact import Contact
|
|
||||||
from app.models.deal import Deal
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityType(str, Enum):
|
|
||||||
"""Type of activity."""
|
|
||||||
|
|
||||||
call = "call"
|
|
||||||
meeting = "meeting"
|
|
||||||
email = "email"
|
|
||||||
task = "task"
|
|
||||||
note = "note"
|
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "activities"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
type: Mapped[ActivityType] = mapped_column(
|
|
||||||
String(32), nullable=False, index=True
|
|
||||||
)
|
|
||||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
body: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
due_date: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True, index=True
|
|
||||||
)
|
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
account_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("accounts.id"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
contact_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("contacts.id"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
deal_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("deals.id"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
owner_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
account: Mapped[Optional["Account"]] = relationship(
|
|
||||||
"Account", back_populates="activities", lazy="selectin"
|
|
||||||
)
|
|
||||||
contact: Mapped[Optional["Contact"]] = relationship(
|
|
||||||
"Contact", back_populates="activities", lazy="selectin"
|
|
||||||
)
|
|
||||||
deal: Mapped[Optional["Deal"]] = relationship(
|
|
||||||
"Deal", back_populates="activities", lazy="selectin"
|
|
||||||
)
|
|
||||||
owner: Mapped["User"] = relationship(
|
|
||||||
"User", foreign_keys=[owner_id], lazy="joined"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Activity", "ActivityType"]
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""AI Conversation and Message models — tenant-scoped with RLS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AIConversation(Base, TenantMixin):
|
||||||
|
"""AI Copilot conversation thread — tenant-scoped."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_conversations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Untitled")
|
||||||
|
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AIMessage(Base, TenantMixin):
|
||||||
|
"""Individual messages within an AI conversation — user input, AI response, actions."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_messages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
proposed_actions: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
executed_action: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
execution_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
message_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""AuditLog and DeletionLog models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base, TenantMixin):
|
||||||
|
"""Audit trail for all create/update/delete/login actions."""
|
||||||
|
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
|
changes: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeletionLog(Base, TenantMixin):
|
||||||
|
"""Immutable record of deleted entities (for forensic recovery)."""
|
||||||
|
|
||||||
|
__tablename__ = "deletion_log"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
entity_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
deleted_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""PasswordResetToken and ApiToken models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetToken(Base, TenantMixin):
|
||||||
|
"""Token for password reset flow."""
|
||||||
|
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(Base, TenantMixin):
|
||||||
|
"""API token for programmatic access."""
|
||||||
|
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
scopes: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Base model and reusable mixins for the CRM system."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.core.db import Base
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
|
||||||
"""Adds created_at and updated_at columns to a model."""
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SoftDeleteMixin:
|
|
||||||
"""Adds deleted_at column for soft-delete pattern."""
|
|
||||||
|
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
default=None,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OrgScopedMixin:
|
|
||||||
"""Adds org_id foreign key. All queries must filter by org_id (OrgScopedQuery)."""
|
|
||||||
|
|
||||||
org_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("orgs.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Base", "OrgScopedMixin", "SoftDeleteMixin", "TimestampMixin"]
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Company model — with soft-delete, FTS tsvector, and tenant scoping."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, Computed
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, TSVECTOR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Company(Base, TenantMixin):
|
||||||
|
"""Company entity with full-text search support."""
|
||||||
|
|
||||||
|
__tablename__ = "companies"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_companies_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
|
Index("ix_companies_tenant_name", "tenant_id", "name"),
|
||||||
|
Index("ix_companies_industry", "tenant_id", "industry"),
|
||||||
|
Index("ix_companies_search_vec", "search_tsv", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
# FTS vector — PostgreSQL generated column, auto-updated on insert/update
|
||||||
|
search_tsv: Mapped[Any] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || coalesce(industry, ''))",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
+69
-45
@@ -1,62 +1,86 @@
|
|||||||
"""Contact model: people at accounts (or standalone contacts)."""
|
"""Contact and CompanyContact (N:M join) models."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String
|
from sqlalchemy import (
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
String,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
Boolean,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.account import Account
|
|
||||||
from app.models.activity import Activity
|
|
||||||
from app.models.note import Note
|
|
||||||
from app.models.tag_link import TagLink
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
class Contact(Base, TenantMixin):
|
||||||
|
"""Contact person entity — can be linked to multiple companies via CompanyContact."""
|
||||||
|
|
||||||
__tablename__ = "contacts"
|
__tablename__ = "contacts"
|
||||||
|
__table_args__ = (
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
first_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
Index("ix_contacts_tenant_name", "tenant_id", "last_name", "first_name"),
|
||||||
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
Index("ix_contacts_email", "email"),
|
||||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
|
||||||
phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
||||||
account_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
ForeignKey("accounts.id"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
owner_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
account: Mapped[Optional["Account"]] = relationship(
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
"Account", back_populates="contacts", lazy="selectin"
|
|
||||||
)
|
)
|
||||||
owner: Mapped["User"] = relationship(
|
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
"User", foreign_keys=[owner_id], lazy="joined"
|
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
activities: Mapped[list["Activity"]] = relationship(
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
"Activity", back_populates="contact", lazy="selectin"
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
)
|
)
|
||||||
notes: Mapped[list["Note"]] = relationship(
|
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
"Note",
|
PGUUID(as_uuid=True),
|
||||||
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
viewonly=True,
|
nullable=True,
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
tags: Mapped[list["TagLink"]] = relationship(
|
|
||||||
"TagLink",
|
|
||||||
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
|
|
||||||
viewonly=True,
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Contact id={self.id} {self.first_name} {self.last_name!r}>"
|
|
||||||
|
|
||||||
|
class CompanyContact(Base, TenantMixin):
|
||||||
|
"""N:M join table between Company and Contact."""
|
||||||
|
|
||||||
__all__ = ["Contact"]
|
__tablename__ = "company_contacts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"
|
||||||
|
),
|
||||||
|
Index("ix_cc_company", "company_id"),
|
||||||
|
Index("ix_cc_contact", "contact_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
role_at_company: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Deal model: sales opportunities tied to an account."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.account import Account
|
|
||||||
from app.models.activity import Activity
|
|
||||||
from app.models.note import Note
|
|
||||||
from app.models.tag_link import TagLink
|
|
||||||
from app.models.deal_stage_history import DealStageHistory
|
|
||||||
|
|
||||||
|
|
||||||
class DealStage(str, Enum):
|
|
||||||
"""Sales pipeline stage for a deal."""
|
|
||||||
|
|
||||||
lead = "lead"
|
|
||||||
qualified = "qualified"
|
|
||||||
proposal = "proposal"
|
|
||||||
negotiation = "negotiation"
|
|
||||||
won = "won"
|
|
||||||
lost = "lost"
|
|
||||||
|
|
||||||
|
|
||||||
class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "deals"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
|
|
||||||
currency: Mapped[str] = mapped_column(
|
|
||||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
|
||||||
)
|
|
||||||
stage: Mapped[DealStage] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
nullable=False,
|
|
||||||
default=DealStage.lead,
|
|
||||||
server_default=DealStage.lead.value,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
close_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
|
||||||
account_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("accounts.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
owner_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
won_lost_reason: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
account: Mapped["Account"] = relationship(
|
|
||||||
"Account", back_populates="deals", lazy="selectin"
|
|
||||||
)
|
|
||||||
owner: Mapped["User"] = relationship(
|
|
||||||
"User", foreign_keys=[owner_id], lazy="joined"
|
|
||||||
)
|
|
||||||
stage_history: Mapped[list["DealStageHistory"]] = relationship(
|
|
||||||
"DealStageHistory",
|
|
||||||
back_populates="deal",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="selectin",
|
|
||||||
order_by="DealStageHistory.created_at",
|
|
||||||
)
|
|
||||||
activities: Mapped[list["Activity"]] = relationship(
|
|
||||||
"Activity", back_populates="deal", lazy="selectin"
|
|
||||||
)
|
|
||||||
notes: Mapped[list["Note"]] = relationship(
|
|
||||||
"Note",
|
|
||||||
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
|
|
||||||
viewonly=True,
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
tags: Mapped[list["TagLink"]] = relationship(
|
|
||||||
"TagLink",
|
|
||||||
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
|
|
||||||
viewonly=True,
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Deal id={self.id} title={self.title!r} stage={self.stage}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Deal", "DealStage"]
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""DealStageHistory model: audit trail of stage transitions for a deal."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, TimestampMixin
|
|
||||||
from app.models.deal import DealStage
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.deal import Deal
|
|
||||||
|
|
||||||
|
|
||||||
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
|
|
||||||
"""Records each deal stage transition. Append-only — no soft-delete."""
|
|
||||||
|
|
||||||
__tablename__ = "deal_stage_history"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
deal_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("deals.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
from_stage: Mapped[Optional[DealStage]] = mapped_column(String(32), nullable=True)
|
|
||||||
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
|
|
||||||
changed_by: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
deal: Mapped["Deal"] = relationship(
|
|
||||||
"Deal", back_populates="stage_history", lazy="joined"
|
|
||||||
)
|
|
||||||
changer: Mapped["User"] = relationship(
|
|
||||||
"User", foreign_keys=[changed_by], lazy="joined"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["DealStageHistory"]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Note model: free-text notes attached polymorphically to accounts/contacts/deals."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class NoteParentType(str, Enum):
|
|
||||||
"""Polymorphic parent type for notes (no DB FK, validated in service layer)."""
|
|
||||||
|
|
||||||
account = "account"
|
|
||||||
contact = "contact"
|
|
||||||
deal = "deal"
|
|
||||||
|
|
||||||
|
|
||||||
class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "notes"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
author_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
parent_type: Mapped[NoteParentType] = mapped_column(
|
|
||||||
String(32), nullable=False, index=True
|
|
||||||
)
|
|
||||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
|
||||||
# Service layer (note_service.create_note) validates existence.
|
|
||||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
||||||
|
|
||||||
author: Mapped["User"] = relationship(
|
|
||||||
"User", foreign_keys=[author_id], lazy="joined"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Note", "NoteParentType"]
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Notification model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, Text, func, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base, TenantMixin):
|
||||||
|
"""User notification entity."""
|
||||||
|
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_notifications_tenant_user_read", "tenant_id", "user_id", "read_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Organization model."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class Org(Base, TimestampMixin):
|
|
||||||
__tablename__ = "orgs"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
logo_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
|
||||||
default_currency: Mapped[str] = mapped_column(
|
|
||||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationship to users (defined here to resolve circular import)
|
|
||||||
users: Mapped[list["User"]] = relationship(
|
|
||||||
back_populates="org",
|
|
||||||
lazy="selectin",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Org id={self.id} name={self.name!r}>"
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Plugin and PluginMigration models — tracks installed plugins and their migrations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, Text, DateTime, func, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(Base, TimestampMixin):
|
||||||
|
"""Plugin record — tracks installation and activation status.
|
||||||
|
|
||||||
|
This table is NOT tenant-scoped (plugins are system-wide, not per-tenant).
|
||||||
|
The tables *created by* plugin migrations MUST have tenant_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "plugins"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plugins_name", "name", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="installed"
|
||||||
|
)
|
||||||
|
installed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
|
Text, nullable=True # JSON string for plugin configuration
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transient attribute for response (not persisted)
|
||||||
|
# Used by uninstall to report dropped tables
|
||||||
|
# This is set dynamically by registry.uninstall()
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMigration(Base, TimestampMixin):
|
||||||
|
"""Tracks individual migration files applied for each plugin."""
|
||||||
|
|
||||||
|
__tablename__ = "plugin_migrations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plugin_migrations_plugin", "plugin_name"),
|
||||||
|
Index("ix_plugin_migrations_unique", "plugin_name", "migration_file", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="applied"
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Role model with RBAC permissions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base, TenantMixin):
|
||||||
|
"""Role entity with module→action→permission mapping and field-level permissions."""
|
||||||
|
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
field_permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Session model — PostgreSQL audit trail for sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base, TenantMixin):
|
||||||
|
"""Immutable session audit record. Runtime session lookup uses Redis."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
csrf_token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Tag model: labels that can be linked to accounts/contacts/deals."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.tag_link import TagLink
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "tags"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
||||||
color: Mapped[str] = mapped_column(
|
|
||||||
String(7), nullable=False, default="#3B82F6", server_default="#3B82F6"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
links: Mapped[list["TagLink"]] = relationship(
|
|
||||||
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Tag id={self.id} name={self.name!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Tag"]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""TagLink model: polymorphic many-to-many between tags and accounts/contacts/deals."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.tag import Tag
|
|
||||||
|
|
||||||
|
|
||||||
class TagLinkParentType(str, Enum):
|
|
||||||
"""Polymorphic parent type for tag links (no DB FK, validated in service layer)."""
|
|
||||||
|
|
||||||
account = "account"
|
|
||||||
contact = "contact"
|
|
||||||
deal = "deal"
|
|
||||||
|
|
||||||
|
|
||||||
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "tag_links"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"tag_id", "parent_type", "parent_id", name="uq_tag_link"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
tag_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("tags.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
parent_type: Mapped[TagLinkParentType] = mapped_column(
|
|
||||||
String(32), nullable=False, index=True
|
|
||||||
)
|
|
||||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
|
||||||
# Service layer (tag_service.link_tag) validates existence.
|
|
||||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
||||||
|
|
||||||
tag: Mapped["Tag"] = relationship("Tag", back_populates="links", lazy="joined")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["TagLink", "TagLinkParentType"]
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Tenant model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base):
|
||||||
|
"""Tenant entity — top-level organisational unit."""
|
||||||
|
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
+32
-33
@@ -1,50 +1,49 @@
|
|||||||
"""User model with role enum and soft-delete."""
|
"""User and UserTenant models."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Optional
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.org import Org
|
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
class User(Base, TenantMixin):
|
||||||
"""User role for RBAC."""
|
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||||
|
|
||||||
admin = "admin"
|
|
||||||
sales_manager = "sales_manager"
|
|
||||||
sales_rep = "sales_rep"
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("org_id", "email", name="uq_users_org_email"),
|
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||||
role: Mapped[UserRole] = mapped_column(
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
String(32),
|
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
nullable=False,
|
|
||||||
default=UserRole.sales_rep,
|
|
||||||
server_default=UserRole.sales_rep.value,
|
|
||||||
)
|
|
||||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
|
||||||
email_notifications: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True, server_default="1"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationship back to org (string reference avoids circular import at runtime)
|
|
||||||
org: Mapped["Org"] = relationship(back_populates="users", lazy="joined")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
class UserTenant(Base):
|
||||||
return f"<User id={self.id} email={self.email!r} role={self.role}>"
|
"""N:M association — user membership in tenants."""
|
||||||
|
|
||||||
|
__tablename__ = "user_tenants"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Workflow, WorkflowInstance, and WorkflowStepHistory models — tenant-scoped with RLS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer, Boolean
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Workflow(Base, TenantMixin):
|
||||||
|
"""Workflow definition — a template of steps stored as JSONB, tenant-scoped."""
|
||||||
|
|
||||||
|
__tablename__ = "workflows"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_workflows_tenant_active", "tenant_id", "is_active"),
|
||||||
|
Index("ix_workflows_tenant_trigger", "tenant_id", "trigger_event"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
trigger_event: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
steps: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstance(Base, TenantMixin):
|
||||||
|
"""A running instance of a workflow — tracks current step, status, context."""
|
||||||
|
|
||||||
|
__tablename__ = "workflow_instances"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_wf_instances_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_wf_instances_tenant_workflow", "tenant_id", "workflow_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
workflow_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending")
|
||||||
|
# pending → in_progress → completed / rejected / cancelled
|
||||||
|
current_step_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
initiated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
timeout_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStepHistory(Base, TenantMixin):
|
||||||
|
"""Immutable record of every step transition in a workflow instance."""
|
||||||
|
|
||||||
|
__tablename__ = "workflow_step_history"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
step_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False) # entered, approved, rejected, skipped, cancelled
|
||||||
|
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Plugin system framework for LeoCRM."""
|
||||||
|
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
from app.plugins.migration_runner import MigrationRunner
|
||||||
|
|
||||||
|
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Base plugin abstract class with lifecycle hooks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.event_bus import EventBus
|
||||||
|
from app.core.service_container import ServiceContainer
|
||||||
|
|
||||||
|
|
||||||
|
class BasePlugin(ABC):
|
||||||
|
"""Abstract base class for all LeoCRM plugins.
|
||||||
|
|
||||||
|
Subclasses must define a ``manifest`` class attribute (PluginManifest)
|
||||||
|
and implement the lifecycle hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
manifest: PluginManifest
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if not hasattr(self, "manifest") or self.manifest is None:
|
||||||
|
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
|
||||||
|
self._event_handlers: dict[str, Any] = {}
|
||||||
|
self._routers: list[APIRouter] = []
|
||||||
|
|
||||||
|
# ─── Lifecycle Hooks ───
|
||||||
|
|
||||||
|
async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||||
|
"""Called when the plugin is installed (after migrations are run).
|
||||||
|
|
||||||
|
Override to perform seed data or initial setup.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def on_activate(
|
||||||
|
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||||
|
) -> None:
|
||||||
|
"""Called when the plugin is activated.
|
||||||
|
|
||||||
|
Override to register event listeners and prepare runtime state.
|
||||||
|
Default implementation subscribes to events listed in the manifest.
|
||||||
|
"""
|
||||||
|
for event_name in self.manifest.events:
|
||||||
|
handler = self._make_event_handler(event_name)
|
||||||
|
self._event_handlers[event_name] = handler
|
||||||
|
event_bus.subscribe(event_name, handler)
|
||||||
|
|
||||||
|
async def on_deactivate(
|
||||||
|
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||||
|
) -> None:
|
||||||
|
"""Called when the plugin is deactivated.
|
||||||
|
|
||||||
|
Override to clean up runtime state. Default implementation unsubscribes
|
||||||
|
all event listeners that were registered during activation.
|
||||||
|
"""
|
||||||
|
for event_name, handler in self._event_handlers.items():
|
||||||
|
event_bus.unsubscribe(event_name, handler)
|
||||||
|
self._event_handlers.clear()
|
||||||
|
|
||||||
|
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||||
|
"""Called when the plugin is uninstalled (before data tables are dropped).
|
||||||
|
|
||||||
|
Override to clean up any external resources.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ─── Route Registration ───
|
||||||
|
|
||||||
|
def get_routes(self) -> list[APIRouter]:
|
||||||
|
"""Return list of APIRouter instances to mount on the FastAPI app.
|
||||||
|
|
||||||
|
Default implementation loads routers from manifest route definitions
|
||||||
|
by importing the module and getting the specified attribute.
|
||||||
|
"""
|
||||||
|
if self._routers:
|
||||||
|
return self._routers
|
||||||
|
|
||||||
|
routers: list[APIRouter] = []
|
||||||
|
for route_def in self.manifest.routes:
|
||||||
|
import importlib
|
||||||
|
module = importlib.import_module(route_def.module)
|
||||||
|
router: APIRouter = getattr(module, route_def.router_attr)
|
||||||
|
routers.append(router)
|
||||||
|
self._routers = routers
|
||||||
|
return routers
|
||||||
|
|
||||||
|
# ─── Event Handler Factory ───
|
||||||
|
|
||||||
|
def _make_event_handler(self, event_name: str) -> Any:
|
||||||
|
"""Create an event handler coroutine for the given event name.
|
||||||
|
|
||||||
|
Looks for a method named ``on_<event_name>`` with dots replaced by underscores.
|
||||||
|
Falls back to ``on_event`` if it exists, otherwise uses a no-op handler.
|
||||||
|
"""
|
||||||
|
method_name = f"on_{event_name.replace('.', '_')}"
|
||||||
|
specific = getattr(self, method_name, None)
|
||||||
|
if specific is not None:
|
||||||
|
return specific
|
||||||
|
fallback = getattr(self, "on_event", None)
|
||||||
|
if fallback is not None:
|
||||||
|
return fallback
|
||||||
|
# Default no-op handler
|
||||||
|
async def _noop_handler(payload: dict[str, Any]) -> None:
|
||||||
|
pass
|
||||||
|
return _noop_handler
|
||||||
|
|
||||||
|
# ─── Utility ───
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.manifest.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> str:
|
||||||
|
return self.manifest.version
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Built-in plugins directory.
|
||||||
|
|
||||||
|
Each module in this package that exports a BasePlugin subclass will be
|
||||||
|
discovered automatically by the plugin registry on application startup.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Bad migration: creates table WITHOUT tenant_id (should be rejected by validator)
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin_bad_table (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Test plugin migration: creates plugin_test_data table with tenant_id
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin_test_data (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_plugin_test_data_tenant ON plugin_test_data(tenant_id);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Sample test plugin for LeoCRM plugin system testing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplePlugin(BasePlugin):
|
||||||
|
"""A sample plugin for testing the plugin lifecycle."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="test_sample",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Test Sample Plugin",
|
||||||
|
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
||||||
|
dependencies=[],
|
||||||
|
routes=[],
|
||||||
|
events=["company.created", "contact.created"],
|
||||||
|
migrations=["0001_test_plugin.sql"],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.install_called = False
|
||||||
|
self.activate_called = False
|
||||||
|
self.deactivate_called = False
|
||||||
|
self.uninstall_called = False
|
||||||
|
self.event_log: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def on_install(self, db, service_container) -> None:
|
||||||
|
self.install_called = True
|
||||||
|
|
||||||
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||||
|
self.activate_called = True
|
||||||
|
await super().on_activate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||||
|
self.deactivate_called = True
|
||||||
|
await super().on_deactivate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
async def on_uninstall(self, db, service_container) -> None:
|
||||||
|
self.uninstall_called = True
|
||||||
|
|
||||||
|
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.event_log.append({"event": "company.created", "payload": payload})
|
||||||
|
|
||||||
|
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.event_log.append({"event": "contact.created", "payload": payload})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Plugin manifest schema (Pydantic v2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRouteDef(BaseModel):
|
||||||
|
"""Route definition within a plugin manifest."""
|
||||||
|
|
||||||
|
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
|
||||||
|
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
|
||||||
|
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManifest(BaseModel):
|
||||||
|
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
|
||||||
|
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
|
||||||
|
display_name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
|
||||||
|
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
|
||||||
|
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
|
||||||
|
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
|
||||||
|
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if not v.replace("_", "").isalnum():
|
||||||
|
raise ValueError("Plugin name must be alphanumeric with underscores only")
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestSchemaResponse(BaseModel):
|
||||||
|
"""Response model describing the manifest schema for API consumers."""
|
||||||
|
|
||||||
|
fields: dict[str, dict[str, str]]
|
||||||
|
example: PluginManifest
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
|
||||||
|
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
||||||
|
fields={
|
||||||
|
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
|
||||||
|
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
|
||||||
|
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
|
||||||
|
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
|
||||||
|
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
|
||||||
|
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
|
||||||
|
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
|
||||||
|
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
|
||||||
|
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
|
||||||
|
},
|
||||||
|
example=PluginManifest(
|
||||||
|
name="example_plugin",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Example Plugin",
|
||||||
|
description="An example plugin demonstrating the manifest schema.",
|
||||||
|
dependencies=[],
|
||||||
|
routes=[],
|
||||||
|
events=["company.created", "contact.created"],
|
||||||
|
migrations=["0001_initial.sql"],
|
||||||
|
permissions=["companies.read"],
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Plugin DB migration runner with tenant_id column validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text, inspect
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||||
|
|
||||||
|
from app.models.plugin import PluginMigration
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationValidationError(Exception):
|
||||||
|
"""Raised when a plugin migration creates a table without tenant_id column."""
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationRunner:
|
||||||
|
"""Runs plugin SQL migrations and validates that all created tables have tenant_id."""
|
||||||
|
|
||||||
|
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
|
||||||
|
self._engine = engine
|
||||||
|
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
|
||||||
|
|
||||||
|
def _resolve_migration_path(self, filename: str) -> Path:
|
||||||
|
"""Resolve a migration filename to an absolute path."""
|
||||||
|
# Try migrations_dir first, then app/plugins/migrations/
|
||||||
|
candidate = self._migrations_dir / filename
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
# Fallback: builtins directory
|
||||||
|
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
|
||||||
|
candidate2 = builtins_dir / filename
|
||||||
|
if candidate2.exists():
|
||||||
|
return candidate2
|
||||||
|
raise FileNotFoundError(f"Migration file not found: {filename}")
|
||||||
|
|
||||||
|
async def run_migration(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
plugin_name: str,
|
||||||
|
migration_filename: str,
|
||||||
|
tenant_id: Any | None = None,
|
||||||
|
) -> PluginMigration:
|
||||||
|
"""Run a single SQL migration file for a plugin.
|
||||||
|
|
||||||
|
Reads the SQL file, executes it, validates created tables have tenant_id,
|
||||||
|
and records the migration in plugin_migrations table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session.
|
||||||
|
plugin_name: Name of the plugin being migrated.
|
||||||
|
migration_filename: Filename of the migration SQL file.
|
||||||
|
tenant_id: Optional tenant UUID for tenant-scoped migrations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PluginMigration record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If migration file doesn't exist.
|
||||||
|
MigrationValidationError: If a created table lacks tenant_id column.
|
||||||
|
"""
|
||||||
|
migration_path = self._resolve_migration_path(migration_filename)
|
||||||
|
sql_content = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Capture existing table names before migration (static analysis of SQL)
|
||||||
|
existing_tables = await self._get_table_names_via_session(db)
|
||||||
|
|
||||||
|
# Execute the migration SQL
|
||||||
|
# Split on semicolons but handle potential function definitions
|
||||||
|
statements = self._split_sql(sql_content)
|
||||||
|
for stmt in statements:
|
||||||
|
stmt = stmt.strip()
|
||||||
|
if stmt:
|
||||||
|
await db.execute(text(stmt))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Capture table names after migration (using same session connection)
|
||||||
|
new_tables = await self._get_table_names_via_session(db)
|
||||||
|
created_tables = new_tables - existing_tables
|
||||||
|
|
||||||
|
# Validate: all newly created tables must have tenant_id column
|
||||||
|
tables_without_tenant = []
|
||||||
|
for table_name in created_tables:
|
||||||
|
if not await self._table_has_column_via_session(db, table_name, "tenant_id"):
|
||||||
|
tables_without_tenant.append(table_name)
|
||||||
|
|
||||||
|
if tables_without_tenant:
|
||||||
|
# Rollback the migration
|
||||||
|
await db.rollback()
|
||||||
|
raise MigrationValidationError(
|
||||||
|
f"Plugin '{plugin_name}' migration '{migration_filename}' created tables without tenant_id column: "
|
||||||
|
f"{', '.join(tables_without_tenant)}. All plugin tables must have tenant_id for multi-tenant isolation."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record the migration in plugin_migrations table
|
||||||
|
migration_record = PluginMigration(
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
migration_file=migration_filename,
|
||||||
|
status="applied",
|
||||||
|
)
|
||||||
|
db.add(migration_record)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return migration_record
|
||||||
|
|
||||||
|
async def run_all_migrations(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
plugin_name: str,
|
||||||
|
migration_files: list[str],
|
||||||
|
tenant_id: Any | None = None,
|
||||||
|
) -> list[PluginMigration]:
|
||||||
|
"""Run all migration files for a plugin in order.
|
||||||
|
|
||||||
|
Skips already-applied migrations (idempotent).
|
||||||
|
"""
|
||||||
|
# Check which migrations are already applied
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PluginMigration).where(
|
||||||
|
PluginMigration.plugin_name == plugin_name,
|
||||||
|
PluginMigration.status == "applied",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
applied_files = {row.migration_file for row in result.scalars().all()}
|
||||||
|
|
||||||
|
records: list[PluginMigration] = []
|
||||||
|
for filename in migration_files:
|
||||||
|
if filename in applied_files:
|
||||||
|
continue # Already applied — idempotent skip
|
||||||
|
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def drop_plugin_tables(self, db: AsyncSession, plugin_name: str) -> list[str]:
|
||||||
|
"""Drop all tables that were created by a plugin's migrations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session.
|
||||||
|
plugin_name: Name of the plugin to remove tables for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dropped table names.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Get all migration records for this plugin
|
||||||
|
result = await db.execute(
|
||||||
|
select(PluginMigration).where(PluginMigration.plugin_name == plugin_name)
|
||||||
|
)
|
||||||
|
migration_records = result.scalars().all()
|
||||||
|
|
||||||
|
dropped_tables: list[str] = []
|
||||||
|
for record in migration_records:
|
||||||
|
migration_path = self._resolve_migration_path(record.migration_file)
|
||||||
|
sql_content = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Parse table names from CREATE TABLE statements
|
||||||
|
table_names = self._extract_table_names(sql_content)
|
||||||
|
for table_name in table_names:
|
||||||
|
try:
|
||||||
|
await db.execute(text(f'DROP TABLE IF EXISTS "{table_name}" CASCADE'))
|
||||||
|
dropped_tables.append(table_name)
|
||||||
|
except Exception:
|
||||||
|
pass # Table may already be gone
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Remove migration records
|
||||||
|
for record in migration_records:
|
||||||
|
await db.delete(record)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return dropped_tables
|
||||||
|
|
||||||
|
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
|
||||||
|
"""Get current table names using the session's own connection.
|
||||||
|
|
||||||
|
This ensures we see uncommitted DDL changes within the same transaction.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.fetchall()}
|
||||||
|
|
||||||
|
async def _table_has_column_via_session(
|
||||||
|
self, db: AsyncSession, table_name: str, column_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a table has a specific column using the session's connection."""
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = :table_name AND column_name = :column_name"
|
||||||
|
),
|
||||||
|
{"table_name": table_name, "column_name": column_name},
|
||||||
|
)
|
||||||
|
return result.fetchone() is not None
|
||||||
|
|
||||||
|
async def _get_table_names(self) -> set[str]:
|
||||||
|
"""Get current table names from the database (separate connection)."""
|
||||||
|
def _get_names(sync_conn):
|
||||||
|
insp = inspect(sync_conn)
|
||||||
|
return set(insp.get_table_names())
|
||||||
|
|
||||||
|
async with self._engine.connect() as conn:
|
||||||
|
return await conn.run_sync(_get_names)
|
||||||
|
|
||||||
|
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a table has a specific column (separate connection)."""
|
||||||
|
def _has_col(sync_conn):
|
||||||
|
insp = inspect(sync_conn)
|
||||||
|
if table_name not in insp.get_table_names():
|
||||||
|
return False
|
||||||
|
columns = [col["name"] for col in insp.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
async with self._engine.connect() as conn:
|
||||||
|
return await conn.run_sync(_has_col)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_sql(sql: str) -> list[str]:
|
||||||
|
"""Split SQL into individual statements, respecting basic semicolon boundaries."""
|
||||||
|
statements: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
in_dollar_quote = False
|
||||||
|
dollar_tag = ""
|
||||||
|
|
||||||
|
for line in sql.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Handle dollar-quoted blocks (PostgreSQL function bodies)
|
||||||
|
if "$$" in stripped and not in_dollar_quote:
|
||||||
|
parts = stripped.split("$$", 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
dollar_tag = parts[0].strip()
|
||||||
|
if dollar_tag:
|
||||||
|
in_dollar_quote = True
|
||||||
|
elif stripped.count("$$") >= 2:
|
||||||
|
# Single-line $$ block
|
||||||
|
current.append(line)
|
||||||
|
if stripped.rstrip().endswith(";"):
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
in_dollar_quote = True
|
||||||
|
current.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dollar_quote:
|
||||||
|
current.append(line)
|
||||||
|
if "$$" in stripped:
|
||||||
|
in_dollar_quote = False
|
||||||
|
# After closing $$, check if the line ends with ; to flush statement
|
||||||
|
after_dollar = stripped.split("$$", 1)[-1] if "$$" in stripped else ""
|
||||||
|
if after_dollar.rstrip().endswith(";"):
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(line)
|
||||||
|
if stripped.endswith(";"):
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
|
||||||
|
if current:
|
||||||
|
remaining = "\n".join(current).strip()
|
||||||
|
if remaining:
|
||||||
|
statements.append(remaining)
|
||||||
|
|
||||||
|
return statements
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_table_names(sql: str) -> list[str]:
|
||||||
|
"""Extract table names from CREATE TABLE statements in SQL."""
|
||||||
|
import re
|
||||||
|
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
|
||||||
|
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
|
||||||
|
return re.findall(pattern, sql, re.IGNORECASE)
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Plugin registry — manages discovered, installed, and active plugins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
|
||||||
|
|
||||||
|
from app.core.event_bus import EventBus, get_event_bus
|
||||||
|
from app.core.service_container import ServiceContainer, get_container
|
||||||
|
from app.models.plugin import Plugin as PluginModel
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.migration_runner import MigrationRunner
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistry:
|
||||||
|
"""Registry for managing plugin lifecycle.
|
||||||
|
|
||||||
|
Maintains in-memory state for discovered plugins and their runtime instances,
|
||||||
|
backed by database records for persistent status tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# name -> BasePlugin instance (discovered/loaded)
|
||||||
|
self._plugins: dict[str, BasePlugin] = {}
|
||||||
|
# name -> PluginModel DB record (installed status)
|
||||||
|
self._db_status: dict[str, PluginModel] = {}
|
||||||
|
# name -> list of mounted APIRouter references (for unregistration)
|
||||||
|
self._mounted_routers: dict[str, list[Any]] = {}
|
||||||
|
self._engine: AsyncEngine | None = None
|
||||||
|
self._event_bus: EventBus = get_event_bus()
|
||||||
|
self._container: ServiceContainer = get_container()
|
||||||
|
self._migration_runner: MigrationRunner | None = None
|
||||||
|
self._app: FastAPI | None = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def initialize(self, engine: AsyncEngine, app: FastAPI | None = None) -> None:
|
||||||
|
"""Initialize the registry with the DB engine and optional FastAPI app."""
|
||||||
|
self._engine = engine
|
||||||
|
self._app = app
|
||||||
|
self._migration_runner = MigrationRunner(engine)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def migration_runner(self) -> MigrationRunner:
|
||||||
|
if self._migration_runner is None:
|
||||||
|
raise RuntimeError("Registry not initialized — call initialize() first")
|
||||||
|
return self._migration_runner
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine(self) -> AsyncEngine:
|
||||||
|
if self._engine is None:
|
||||||
|
raise RuntimeError("Registry not initialized — call initialize() first")
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
# ─── Discovery ───
|
||||||
|
|
||||||
|
def discover_builtins(self) -> list[str]:
|
||||||
|
"""Discover and load built-in plugins from app.plugins.builtins.
|
||||||
|
|
||||||
|
Scans the builtins package for modules that export a BasePlugin subclass.
|
||||||
|
Returns list of discovered plugin names.
|
||||||
|
"""
|
||||||
|
discovered: list[str] = []
|
||||||
|
try:
|
||||||
|
builtins_pkg = importlib.import_module("app.plugins.builtins")
|
||||||
|
except ImportError:
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
pkg_path = getattr(builtins_pkg, "__path__", None)
|
||||||
|
if pkg_path is None:
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
import pkgutil
|
||||||
|
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
|
||||||
|
if modname.startswith("_"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
full_name = f"app.plugins.builtins.{modname}"
|
||||||
|
module = importlib.import_module(full_name)
|
||||||
|
# Look for BasePlugin subclass in module
|
||||||
|
for attr_name in dir(module):
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and issubclass(attr, BasePlugin)
|
||||||
|
and attr is not BasePlugin
|
||||||
|
):
|
||||||
|
instance = attr()
|
||||||
|
if instance.name not in self._plugins:
|
||||||
|
self._plugins[instance.name] = instance
|
||||||
|
discovered.append(instance.name)
|
||||||
|
logger.info(f"Discovered plugin: {instance.name} v{instance.version}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to load builtin plugin module {modname}: {exc}")
|
||||||
|
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
def register_plugin(self, plugin: BasePlugin) -> None:
|
||||||
|
"""Manually register a plugin instance."""
|
||||||
|
self._plugins[plugin.name] = plugin
|
||||||
|
logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
||||||
|
|
||||||
|
def get_plugin(self, name: str) -> BasePlugin | None:
|
||||||
|
"""Get a registered plugin instance by name."""
|
||||||
|
return self._plugins.get(name)
|
||||||
|
|
||||||
|
def list_discovered(self) -> list[str]:
|
||||||
|
"""List all discovered plugin names."""
|
||||||
|
return list(self._plugins.keys())
|
||||||
|
|
||||||
|
# ─── DB Status Sync ───
|
||||||
|
|
||||||
|
async def sync_db_status(self, db: AsyncSession) -> None:
|
||||||
|
"""Load plugin status records from the database."""
|
||||||
|
result = await db.execute(select(PluginModel))
|
||||||
|
self._db_status = {row.name: row for row in result.scalars().all()}
|
||||||
|
|
||||||
|
def get_db_status(self, name: str) -> PluginModel | None:
|
||||||
|
"""Get the DB status record for a plugin."""
|
||||||
|
return self._db_status.get(name)
|
||||||
|
|
||||||
|
# ─── Install / Activate / Deactivate / Uninstall ───
|
||||||
|
|
||||||
|
async def install(self, db: AsyncSession, name: str) -> PluginModel:
|
||||||
|
"""Install a plugin: run migrations and create DB record.
|
||||||
|
|
||||||
|
Idempotent: if already installed, returns existing record.
|
||||||
|
"""
|
||||||
|
plugin = self.get_plugin(name)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||||
|
|
||||||
|
# Check if already installed in DB
|
||||||
|
existing = await self._get_plugin_record(db, name)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
if plugin.manifest.migrations:
|
||||||
|
await self.migration_runner.run_all_migrations(
|
||||||
|
db, name, plugin.manifest.migrations
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call on_install hook
|
||||||
|
await plugin.on_install(db, self._container)
|
||||||
|
|
||||||
|
# Create DB record
|
||||||
|
record = PluginModel(
|
||||||
|
name=name,
|
||||||
|
display_name=plugin.manifest.display_name,
|
||||||
|
version=plugin.manifest.version,
|
||||||
|
status="installed",
|
||||||
|
installed=True,
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.flush()
|
||||||
|
self._db_status[name] = record
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def activate(self, db: AsyncSession, name: str) -> PluginModel:
|
||||||
|
"""Activate a plugin: register routes, event listeners, set status=active.
|
||||||
|
|
||||||
|
Idempotent: if already active, returns existing record without error.
|
||||||
|
"""
|
||||||
|
plugin = self.get_plugin(name)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||||
|
|
||||||
|
record = await self._get_plugin_record(db, name)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' is not installed — install first")
|
||||||
|
|
||||||
|
# Idempotent: already active
|
||||||
|
if record.active and record.status == "active":
|
||||||
|
return record
|
||||||
|
|
||||||
|
# Call on_activate hook (registers event listeners)
|
||||||
|
await plugin.on_activate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
# Register routes on FastAPI app if available
|
||||||
|
if self._app is not None:
|
||||||
|
routers = plugin.get_routes()
|
||||||
|
mounted = []
|
||||||
|
for router in routers:
|
||||||
|
self._app.include_router(router)
|
||||||
|
mounted.append(router)
|
||||||
|
self._mounted_routers[name] = mounted
|
||||||
|
|
||||||
|
# Update DB record
|
||||||
|
record.status = "active"
|
||||||
|
record.active = True
|
||||||
|
await db.flush()
|
||||||
|
self._db_status[name] = record
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def deactivate(self, db: AsyncSession, name: str) -> PluginModel:
|
||||||
|
"""Deactivate a plugin: unregister event listeners, set status=inactive.
|
||||||
|
|
||||||
|
Idempotent: if already inactive, returns existing record without error.
|
||||||
|
"""
|
||||||
|
plugin = self.get_plugin(name)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||||
|
|
||||||
|
record = await self._get_plugin_record(db, name)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||||||
|
|
||||||
|
# Idempotent: already inactive
|
||||||
|
if not record.active and record.status == "inactive":
|
||||||
|
return record
|
||||||
|
|
||||||
|
# Call on_deactivate hook (unregisters event listeners)
|
||||||
|
await plugin.on_deactivate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
# Unregister routes from FastAPI app if available
|
||||||
|
if self._app is not None and name in self._mounted_routers:
|
||||||
|
mounted = self._mounted_routers.pop(name, [])
|
||||||
|
# Collect all route paths from mounted routers for removal
|
||||||
|
paths_to_remove: set[str] = set()
|
||||||
|
for router in mounted:
|
||||||
|
for route in router.routes:
|
||||||
|
if hasattr(route, "path"):
|
||||||
|
paths_to_remove.add(route.path)
|
||||||
|
# Remove matching routes from app
|
||||||
|
self._app.router.routes = [
|
||||||
|
r for r in self._app.router.routes
|
||||||
|
if not (hasattr(r, "path") and r.path in paths_to_remove)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Update DB record
|
||||||
|
record.status = "inactive"
|
||||||
|
record.active = False
|
||||||
|
await db.flush()
|
||||||
|
self._db_status[name] = record
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def uninstall(
|
||||||
|
self, db: AsyncSession, name: str, remove_data: bool = False
|
||||||
|
) -> PluginModel:
|
||||||
|
"""Uninstall a plugin: deactivate, optionally drop tables, remove DB record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session.
|
||||||
|
name: Plugin name to uninstall.
|
||||||
|
remove_data: If True, drop all plugin-created tables.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The plugin record before deletion (for response).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError if plugin not installed.
|
||||||
|
"""
|
||||||
|
plugin = self.get_plugin(name)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||||
|
|
||||||
|
record = await self._get_plugin_record(db, name)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||||||
|
|
||||||
|
# Deactivate first if active
|
||||||
|
if record.active:
|
||||||
|
await self.deactivate(db, name)
|
||||||
|
# Refetch record after deactivate
|
||||||
|
record = await self._get_plugin_record(db, name)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"Plugin '{name}' disappeared during uninstall")
|
||||||
|
|
||||||
|
# Call on_uninstall hook
|
||||||
|
await plugin.on_uninstall(db, self._container)
|
||||||
|
|
||||||
|
# Optionally drop plugin tables
|
||||||
|
dropped_tables: list[str] = []
|
||||||
|
if remove_data:
|
||||||
|
dropped_tables = await self.migration_runner.drop_plugin_tables(db, name)
|
||||||
|
|
||||||
|
# Remove DB record
|
||||||
|
await db.delete(record)
|
||||||
|
await db.flush()
|
||||||
|
self._db_status.pop(name, None)
|
||||||
|
|
||||||
|
# Return a detached copy for response
|
||||||
|
record_dropped_tables = dropped_tables
|
||||||
|
record.status = "uninstalled"
|
||||||
|
record.dropped_tables = record_dropped_tables
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||||||
|
"""List all plugins with their current status.
|
||||||
|
|
||||||
|
Merges discovered (in-memory) plugins with installed (DB) records.
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(PluginModel))
|
||||||
|
db_records = {row.name: row for row in result.scalars().all()}
|
||||||
|
|
||||||
|
plugins_list: list[dict[str, Any]] = []
|
||||||
|
for name, plugin in self._plugins.items():
|
||||||
|
record = db_records.get(name)
|
||||||
|
if record is not None:
|
||||||
|
plugins_list.append({
|
||||||
|
"name": name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": record.status,
|
||||||
|
"installed": record.installed,
|
||||||
|
"active": record.active,
|
||||||
|
"description": plugin.manifest.description,
|
||||||
|
"dependencies": plugin.manifest.dependencies,
|
||||||
|
"events": plugin.manifest.events,
|
||||||
|
"migrations": plugin.manifest.migrations,
|
||||||
|
"permissions": plugin.manifest.permissions,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
plugins_list.append({
|
||||||
|
"name": name,
|
||||||
|
"display_name": plugin.manifest.display_name,
|
||||||
|
"version": plugin.version,
|
||||||
|
"status": "discovered",
|
||||||
|
"installed": False,
|
||||||
|
"active": False,
|
||||||
|
"description": plugin.manifest.description,
|
||||||
|
"dependencies": plugin.manifest.dependencies,
|
||||||
|
"events": plugin.manifest.events,
|
||||||
|
"migrations": plugin.manifest.migrations,
|
||||||
|
"permissions": plugin.manifest.permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Also include DB-only records (plugins that were installed but no longer discovered)
|
||||||
|
for name, record in db_records.items():
|
||||||
|
if name not in self._plugins:
|
||||||
|
plugins_list.append({
|
||||||
|
"name": name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": record.status,
|
||||||
|
"installed": record.installed,
|
||||||
|
"active": record.active,
|
||||||
|
"description": "",
|
||||||
|
"dependencies": [],
|
||||||
|
"events": [],
|
||||||
|
"migrations": [],
|
||||||
|
"permissions": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return plugins_list
|
||||||
|
|
||||||
|
# ─── Internal Helpers ───
|
||||||
|
|
||||||
|
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
|
||||||
|
"""Fetch a plugin record from DB by name."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PluginModel).where(PluginModel.name == name)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
_registry: PluginRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> PluginRegistry:
|
||||||
|
"""Get the global plugin registry."""
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = PluginRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_registry_for_testing() -> PluginRegistry:
|
||||||
|
"""Create a fresh registry for testing."""
|
||||||
|
global _registry
|
||||||
|
_registry = PluginRegistry()
|
||||||
|
return _registry
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Routes package."""
|
||||||
|
|
||||||
|
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""AI Copilot routes — query, execute, history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.ai_copilot import (
|
||||||
|
CopilotQueryRequest,
|
||||||
|
CopilotExecuteRequest,
|
||||||
|
)
|
||||||
|
from app.services import ai_copilot_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/ai/copilot", tags=["ai-copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/query")
|
||||||
|
async def copilot_query(
|
||||||
|
body: CopilotQueryRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Process a natural language query and return proposed actions.
|
||||||
|
|
||||||
|
Returns proposed_actions array for user confirmation.
|
||||||
|
Does NOT execute any actions — user must call /execute to confirm.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await ai_copilot_service.process_query(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
query=body.query,
|
||||||
|
conversation_id=body.conversation_id,
|
||||||
|
context=body.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result and result.get("status_code") == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": result["error"], "code": "conversation_not_found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute")
|
||||||
|
async def copilot_execute(
|
||||||
|
body: CopilotExecuteRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Execute a proposed action after user confirmation.
|
||||||
|
|
||||||
|
RBAC is enforced — the user must have permission for the action.
|
||||||
|
Returns 200 with execution result or 403 if RBAC blocks.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
result = await ai_copilot_service.execute_action(
|
||||||
|
db, tenant_id, user_id, role,
|
||||||
|
conversation_id=body.conversation_id,
|
||||||
|
action=body.action.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("status_code") == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": result.get("error", "Not found"), "code": "not_found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("status_code") == 403:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": result.get("error", "Insufficient permissions"), "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def copilot_history(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get paginated conversation history for the current user."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
return await ai_copilot_service.get_history(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
)
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Auth routes — login, logout, me, switch-tenant, password reset."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
|
||||||
|
from app.core.auth import get_redis
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
|
||||||
|
SwitchTenantRequest, AuthResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import auth_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
body: LoginRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Login with email+password. Sets session cookie."""
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
redis = get_redis()
|
||||||
|
|
||||||
|
# Rate limit
|
||||||
|
await check_rate_limit(
|
||||||
|
f"auth:login:{ip}:{body.email}",
|
||||||
|
settings.rate_limit_login_max,
|
||||||
|
settings.rate_limit_login_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await auth_service.login(db, redis, body.email, body.password)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Invalid email or password", "code": "invalid_credentials"},
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id, csrf_token, user, tenant = result
|
||||||
|
|
||||||
|
# Reset rate limit on success
|
||||||
|
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
|
||||||
|
|
||||||
|
response = Response(status_code=status.HTTP_200_OK)
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.session_cookie_name,
|
||||||
|
value=session_id,
|
||||||
|
httponly=settings.session_cookie_httponly,
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
|
samesite=settings.session_cookie_samesite,
|
||||||
|
max_age=settings.session_ttl_seconds,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
# Return auth info in body
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
resp = JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content={
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"role": user.role,
|
||||||
|
"tenant_id": str(tenant.id),
|
||||||
|
"tenant_name": tenant.name,
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie(
|
||||||
|
key=settings.session_cookie_name,
|
||||||
|
value=session_id,
|
||||||
|
httponly=settings.session_cookie_httponly,
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
|
samesite=settings.session_cookie_samesite,
|
||||||
|
max_age=settings.session_ttl_seconds,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Logout — invalidate session, clear cookie."""
|
||||||
|
session_id = request.cookies.get(settings.session_cookie_name)
|
||||||
|
redis = get_redis()
|
||||||
|
if session_id:
|
||||||
|
await auth_service.logout(redis, session_id)
|
||||||
|
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
resp = JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content={"message": "Logged out"},
|
||||||
|
)
|
||||||
|
resp.delete_cookie(settings.session_cookie_name, path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get current user + active tenant."""
|
||||||
|
session_id = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
redis = get_redis()
|
||||||
|
info = await auth_service.get_current_user_info(db, redis, session_id)
|
||||||
|
if info is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch-tenant")
|
||||||
|
async def switch_tenant(
|
||||||
|
request: Request,
|
||||||
|
body: SwitchTenantRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Switch active tenant for current session."""
|
||||||
|
session_id = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
redis = get_redis()
|
||||||
|
try:
|
||||||
|
new_tenant_id = uuid.UUID(body.tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Cannot switch to this tenant", "code": "tenant_switch_failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": result["user_id"],
|
||||||
|
"email": result["email"],
|
||||||
|
"name": result["name"],
|
||||||
|
"role": result["role"],
|
||||||
|
"tenant_id": result["tenant_id"],
|
||||||
|
"tenant_name": result.get("tenant_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/request")
|
||||||
|
async def password_reset_request(
|
||||||
|
request: Request,
|
||||||
|
body: PasswordResetRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Request password reset. Always returns 200 (no user enumeration)."""
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
redis = get_redis()
|
||||||
|
|
||||||
|
await check_rate_limit(
|
||||||
|
f"auth:reset:{ip}",
|
||||||
|
settings.rate_limit_reset_max,
|
||||||
|
settings.rate_limit_reset_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auth_service.request_password_reset(db, body.email)
|
||||||
|
return {"message": "If the email exists, a reset link has been sent."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/confirm")
|
||||||
|
async def password_reset_confirm(
|
||||||
|
request: Request,
|
||||||
|
body: PasswordResetConfirm,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reset password with a valid token."""
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
redis = get_redis()
|
||||||
|
|
||||||
|
await check_rate_limit(
|
||||||
|
f"auth:reset_confirm:{ip}",
|
||||||
|
settings.rate_limit_reset_confirm_max,
|
||||||
|
settings.rate_limit_reset_confirm_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = await auth_service.confirm_password_reset(db, body.token, body.new_password)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": "Invalid or expired token", "code": "invalid_token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Password has been reset successfully."}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Company routes — full CRUD, FTS search, filter, pagination, export, N:M, emails."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.company import CompanyCreate, CompanyUpdate
|
||||||
|
from app.services import company_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_companies(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
industry: str | None = Query(None),
|
||||||
|
sort_by: str = Query("name"),
|
||||||
|
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await company_service.list_companies(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
search=search, industry=industry,
|
||||||
|
sort_by=sort_by, sort_order=sort_order,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_company(
|
||||||
|
body: CompanyCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a company. Requires write permission."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "create"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump()
|
||||||
|
return await company_service.create_company(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_companies(
|
||||||
|
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
||||||
|
industry: str | None = Query(None),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Export companies as CSV or XLSX."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
csv_data = await company_service.export_companies_csv(
|
||||||
|
db, tenant_id, industry=industry, search=search,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=csv_data,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=companies.csv"},
|
||||||
|
)
|
||||||
|
elif format == "xlsx":
|
||||||
|
xlsx_data = await company_service.export_companies_xlsx(
|
||||||
|
db, tenant_id, industry=industry, search=search,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=xlsx_data,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=companies.xlsx"},
|
||||||
|
)
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid format", "code": "invalid_format"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}")
|
||||||
|
async def get_company(
|
||||||
|
company_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single company with contacts array. Cross-tenant returns 404."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(company_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
data = await company_service.get_company_detail(db, tenant_id, cid)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{company_id}")
|
||||||
|
async def update_company(
|
||||||
|
company_id: str,
|
||||||
|
body: CompanyUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a company (full PUT)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "update"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(company_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{company_id}")
|
||||||
|
async def delete_company(
|
||||||
|
company_id: str,
|
||||||
|
cascade: bool = Query(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Soft-delete a company. Use cascade=true to also delete N:M links."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "delete"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(company_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
deleted = await company_service.soft_delete_company(db, tenant_id, user_id, cid, cascade=cascade)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{company_id}/contacts/{contact_id}")
|
||||||
|
async def link_company_contact(
|
||||||
|
company_id: str,
|
||||||
|
contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Link a contact to a company (N:M)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "update"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
comp_id = uuid.UUID(company_id)
|
||||||
|
cont_id = uuid.UUID(contact_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||||
|
|
||||||
|
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Company or contact not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{company_id}/contacts/{contact_id}")
|
||||||
|
async def unlink_company_contact(
|
||||||
|
company_id: str,
|
||||||
|
contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Unlink a contact from a company (N:M)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "update"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
comp_id = uuid.UUID(company_id)
|
||||||
|
cont_id = uuid.UUID(contact_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||||
|
|
||||||
|
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
|
||||||
|
if not unlinked:
|
||||||
|
raise HTTPException(404, detail={"detail": "Link not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}/emails")
|
||||||
|
async def get_company_emails(
|
||||||
|
company_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(company_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
# Verify company exists
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.company import Company
|
||||||
|
q = select(Company).where(
|
||||||
|
Company.id == cid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return []
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.contact import ContactCreate, ContactUpdate
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_contacts(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
sort_by: str = Query("last_name"),
|
||||||
|
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List contacts with pagination and optional search."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await contact_service.list_contacts(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
search=search, sort_by=sort_by, sort_order=sort_order,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_contact(
|
||||||
|
body: ContactCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a contact. Optionally link to companies via company_ids array."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "contacts", "create"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump()
|
||||||
|
return await contact_service.create_contact(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{contact_id}")
|
||||||
|
async def get_contact(
|
||||||
|
contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single contact with companies array. Cross-tenant returns 404."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(contact_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
data = await contact_service.get_contact_detail(db, tenant_id, cid)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{contact_id}")
|
||||||
|
async def update_contact(
|
||||||
|
contact_id: str,
|
||||||
|
body: ContactUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a contact."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "contacts", "update"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(contact_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{contact_id}")
|
||||||
|
async def delete_contact(
|
||||||
|
contact_id: str,
|
||||||
|
gdpr: bool = Query(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "contacts", "delete"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(contact_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
if gdpr:
|
||||||
|
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
|
||||||
|
else:
|
||||||
|
deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Health check endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check — no auth required."""
|
||||||
|
return {"status": "ok", "version": "1.0.0"}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Import/export routes — CSV import, dry-run preview, CSV/XLSX export."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.services import import_export_service
|
||||||
|
from app.services import company_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["import_export"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_type: str = Form("companies"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Import companies or contacts from CSV file.
|
||||||
|
entity_type: 'companies' or 'contacts'.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "create"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
csv_content = content.decode("utf-8")
|
||||||
|
|
||||||
|
result = await import_export_service.import_csv(
|
||||||
|
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=False,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/preview")
|
||||||
|
async def import_csv_preview(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_type: str = Form("companies"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Preview CSV import (dry-run — no DB changes)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "companies", "read"):
|
||||||
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
csv_content = content.decode("utf-8")
|
||||||
|
|
||||||
|
result = await import_export_service.import_csv(
|
||||||
|
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Notification routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.notifications import (
|
||||||
|
get_unread_count, list_notifications, mark_notification_read,
|
||||||
|
)
|
||||||
|
from app.deps import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_notifications_endpoint(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List notifications (unread first)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
return await list_notifications(db, tenant_id, user_id, page, page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{notification_id}/read")
|
||||||
|
async def mark_notification_read_endpoint(
|
||||||
|
notification_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark a notification as read."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
nid = uuid.UUID(notification_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid notification_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
notif = await mark_notification_read(db, tenant_id, user_id, nid)
|
||||||
|
if notif is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Notification not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(notif.id),
|
||||||
|
"type": notif.type,
|
||||||
|
"title": notif.title,
|
||||||
|
"body": notif.body,
|
||||||
|
"read_at": notif.read_at.isoformat() if notif.read_at else None,
|
||||||
|
"created_at": notif.created_at.isoformat() if notif.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count")
|
||||||
|
async def unread_count_endpoint(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get unread notification count."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
count = await get_unread_count(db, tenant_id, user_id)
|
||||||
|
return {"count": count}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import require_admin
|
||||||
|
from app.plugins.migration_runner import MigrationValidationError
|
||||||
|
from app.services.plugin_service import get_plugin_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_plugins(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""List all plugins with their current status (discovered, installed, active, inactive)."""
|
||||||
|
service = get_plugin_service()
|
||||||
|
plugins = await service.list_plugins(db)
|
||||||
|
return {"plugins": plugins, "total": len(plugins)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest")
|
||||||
|
async def get_manifest_schema(
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Get the plugin manifest schema documentation."""
|
||||||
|
service = get_plugin_service()
|
||||||
|
return service.get_manifest_schema()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/install")
|
||||||
|
async def install_plugin(
|
||||||
|
name: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Install a plugin by name. Runs migrations and creates DB record.
|
||||||
|
|
||||||
|
Idempotent: returns 200 if already installed.
|
||||||
|
"""
|
||||||
|
import uuid as uuid_mod
|
||||||
|
service = get_plugin_service()
|
||||||
|
try:
|
||||||
|
result = await service.install_plugin(
|
||||||
|
db, name,
|
||||||
|
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||||
|
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not found" in str(exc).lower():
|
||||||
|
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||||
|
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||||
|
except MigrationValidationError as exc:
|
||||||
|
raise HTTPException(422, detail={"detail": str(exc), "code": "migration_validation_error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/activate")
|
||||||
|
async def activate_plugin(
|
||||||
|
name: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Activate a plugin by name. Registers event listeners and routes.
|
||||||
|
|
||||||
|
Idempotent: returns 200 if already active.
|
||||||
|
"""
|
||||||
|
import uuid as uuid_mod
|
||||||
|
service = get_plugin_service()
|
||||||
|
try:
|
||||||
|
result = await service.activate_plugin(
|
||||||
|
db, name,
|
||||||
|
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||||
|
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not installed" in str(exc).lower():
|
||||||
|
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_not_installed"})
|
||||||
|
if "not found" in str(exc).lower():
|
||||||
|
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||||
|
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/deactivate")
|
||||||
|
async def deactivate_plugin(
|
||||||
|
name: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Deactivate a plugin by name. Unregisters event listeners and routes.
|
||||||
|
|
||||||
|
Idempotent: returns 200 if already inactive.
|
||||||
|
"""
|
||||||
|
import uuid as uuid_mod
|
||||||
|
service = get_plugin_service()
|
||||||
|
try:
|
||||||
|
result = await service.deactivate_plugin(
|
||||||
|
db, name,
|
||||||
|
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||||
|
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not found" in str(exc).lower():
|
||||||
|
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||||
|
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{name}")
|
||||||
|
async def uninstall_plugin(
|
||||||
|
name: str,
|
||||||
|
remove_data: bool = Query(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Uninstall a plugin. Optionally drop plugin-created tables with remove_data=true.
|
||||||
|
|
||||||
|
Returns 200 with uninstalled status. Idempotent for already-uninstalled plugins.
|
||||||
|
"""
|
||||||
|
import uuid as uuid_mod
|
||||||
|
service = get_plugin_service()
|
||||||
|
try:
|
||||||
|
result = await service.uninstall_plugin(
|
||||||
|
db, name,
|
||||||
|
remove_data=remove_data,
|
||||||
|
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||||
|
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not found" in str(exc).lower():
|
||||||
|
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||||
|
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Role management routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi import Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user, require_admin
|
||||||
|
from app.schemas.role import RoleCreate, RoleUpdate
|
||||||
|
from app.services.role_service import role_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_roles(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List roles for the current tenant."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
roles = await role_service.list_roles(db, tenant_id)
|
||||||
|
return {"items": roles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_role(
|
||||||
|
body: RoleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Create a custom role (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
role = await role_service.create_role(
|
||||||
|
db, tenant_id, body.name, body.permissions, body.field_permissions,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
content={
|
||||||
|
"id": str(role.id),
|
||||||
|
"name": role.name,
|
||||||
|
"permissions": role.permissions,
|
||||||
|
"field_permissions": role.field_permissions,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{role_id}")
|
||||||
|
async def update_role(
|
||||||
|
role_id: str,
|
||||||
|
body: RoleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Update a role (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(role_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
role = await role_service.update_role(
|
||||||
|
db, tenant_id, rid, body.name, body.permissions, body.field_permissions,
|
||||||
|
)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(role.id),
|
||||||
|
"name": role.name,
|
||||||
|
"permissions": role.permissions,
|
||||||
|
"field_permissions": role.field_permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{role_id}")
|
||||||
|
async def delete_role(
|
||||||
|
role_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Delete a role (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(role_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
success = await role_service.delete_role(db, tenant_id, rid)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tenant management routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user, require_admin
|
||||||
|
from app.schemas.tenant import TenantCreate, TenantUserAssign
|
||||||
|
from app.services.tenant_service import tenant_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/tenants", tags=["tenants"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_tenants(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List tenants for the current user."""
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
tenants = await tenant_service.list_tenants_for_user(db, user_id)
|
||||||
|
return {"items": tenants}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_tenant(
|
||||||
|
body: TenantCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Create a new tenant (admin only)."""
|
||||||
|
tenant = await tenant_service.create_tenant(db, body.name, body.slug)
|
||||||
|
return {
|
||||||
|
"id": str(tenant.id),
|
||||||
|
"name": tenant.name,
|
||||||
|
"slug": tenant.slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}/users")
|
||||||
|
async def list_tenant_users(
|
||||||
|
tenant_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""List users in a tenant (admin only)."""
|
||||||
|
try:
|
||||||
|
tid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
users = await tenant_service.list_tenant_users(db, tid)
|
||||||
|
return {"items": users}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tenant_id}/users")
|
||||||
|
async def assign_user_to_tenant(
|
||||||
|
tenant_id: str,
|
||||||
|
body: TenantUserAssign,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Assign a user to a tenant (admin only)."""
|
||||||
|
try:
|
||||||
|
tid = uuid.UUID(tenant_id)
|
||||||
|
uid = uuid.UUID(body.user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||||
|
|
||||||
|
ut = await tenant_service.assign_user_to_tenant(db, tid, uid)
|
||||||
|
return {"message": "User assigned to tenant"}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""User management routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi import Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.notifications import create_notification
|
||||||
|
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
|
||||||
|
from app.schemas.user import UserCreate, UserUpdate
|
||||||
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(25, ge=1, le=100),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""List users (admin only, paginated)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await user_service.list_users(db, tenant_id, page, page_size, search)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
user = await user_service.create_user(
|
||||||
|
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "user", user.id,
|
||||||
|
changes={"email": body.email, "name": body.name, "role": body.role},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification
|
||||||
|
await create_notification(
|
||||||
|
db, tenant_id, user.id, "info",
|
||||||
|
"Account created",
|
||||||
|
f"Your account has been created by {current_user['name']}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"role": user.role,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"tenant_id": str(user.tenant_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}")
|
||||||
|
async def get_user(
|
||||||
|
user_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single user."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
user = await user_service.get_user(db, tenant_id, uid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"role": user.role,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"tenant_id": str(user.tenant_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
body: UserUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Update a user (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
changes: dict[str, Any] = {}
|
||||||
|
if body.name is not None:
|
||||||
|
changes["name"] = body.name
|
||||||
|
if body.role is not None:
|
||||||
|
changes["role"] = body.role
|
||||||
|
if body.is_active is not None:
|
||||||
|
changes["is_active"] = body.is_active
|
||||||
|
|
||||||
|
user = await user_service.update_user(
|
||||||
|
db, tenant_id, uid, body.name, body.role, body.is_active,
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||||
|
|
||||||
|
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"role": user.role,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"tenant_id": str(user.tenant_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||||
|
|
||||||
|
# Get user snapshot for audit before deletion
|
||||||
|
user = await user_service.get_user(db, tenant_id, uid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||||
|
|
||||||
|
success = await user_service.delete_user(db, tenant_id, uid)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, acting_user_id, "delete", "user", uid,
|
||||||
|
changes={"email": user.email, "name": user.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Workflow routes — CRUD, instance lifecycle, advance/cancel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.workflow import WorkflowCreate, WorkflowUpdate, InstanceCreate, AdvanceRequest
|
||||||
|
from app.services import workflow_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Workflow CRUD ───
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_workflows(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List workflows with pagination."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await workflow_service.list_workflows(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_workflow(
|
||||||
|
body: WorkflowCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new workflow definition. Requires write permission."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "create"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump()
|
||||||
|
return await workflow_service.create_workflow(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
async def list_instances(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List workflow instances with optional status filter."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await workflow_service.list_instances(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
status_filter=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workflow_id}")
|
||||||
|
async def get_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single workflow by ID."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await workflow_service.get_workflow(db, tenant_id, workflow_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workflow_id}")
|
||||||
|
async def update_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
body: WorkflowUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a workflow definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "update"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
result = await workflow_service.update_workflow(db, tenant_id, user_id, workflow_id, data)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workflow_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a workflow definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "delete"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await workflow_service.delete_workflow(db, tenant_id, user_id, workflow_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Instance endpoints ───
|
||||||
|
|
||||||
|
@router.post("/{workflow_id}/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_instance(
|
||||||
|
workflow_id: str,
|
||||||
|
body: InstanceCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new workflow instance."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.create_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
context=body.context,
|
||||||
|
timeout_hours=body.timeout_hours,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}")
|
||||||
|
async def get_instance(
|
||||||
|
instance_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a workflow instance with step history."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await workflow_service.get_instance(db, tenant_id, instance_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/advance")
|
||||||
|
async def advance_instance(
|
||||||
|
instance_id: str,
|
||||||
|
body: AdvanceRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Advance or reject a workflow instance step.
|
||||||
|
|
||||||
|
Body decision: "approve" or "reject".
|
||||||
|
Returns 200 with updated instance.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.advance_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
decision=body.decision,
|
||||||
|
comment=body.comment,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": result["error"], "code": "invalid_state"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/cancel")
|
||||||
|
async def cancel_instance(
|
||||||
|
instance_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Cancel a workflow instance. Returns 200 with cancelled instance."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.cancel_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": result["error"], "code": "invalid_state"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
+10
-83
@@ -1,86 +1,13 @@
|
|||||||
"""Pydantic schemas for the CRM system."""
|
"""Pydantic schemas package."""
|
||||||
|
|
||||||
from app.schemas.account import (
|
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
|
||||||
AccountCreate,
|
from app.schemas.ai_copilot import (
|
||||||
AccountListItem,
|
CopilotQueryRequest, CopilotAction, CopilotQueryResponse,
|
||||||
AccountOut,
|
CopilotExecuteRequest, CopilotExecuteResponse,
|
||||||
AccountUpdate,
|
CopilotHistoryResponse, CopilotMessageResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.activity import (
|
from app.schemas.workflow import (
|
||||||
ActivityCompleteRequest,
|
WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse,
|
||||||
ActivityCreate,
|
InstanceCreate, InstanceResponse, InstanceDetailResponse, InstanceListResponse,
|
||||||
ActivityOut,
|
AdvanceRequest, StepHistoryResponse, WorkflowStep,
|
||||||
ActivityUpdate,
|
|
||||||
)
|
)
|
||||||
from app.schemas.auth import (
|
|
||||||
LogoutResponse,
|
|
||||||
RegisterResponse,
|
|
||||||
TokenResponse,
|
|
||||||
UserLoginRequest,
|
|
||||||
UserRegisterRequest,
|
|
||||||
)
|
|
||||||
from app.schemas.common import PaginatedResponse, PaginationParams
|
|
||||||
from app.schemas.contact import (
|
|
||||||
ContactCreate,
|
|
||||||
ContactListItem,
|
|
||||||
ContactOut,
|
|
||||||
ContactUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
|
||||||
from app.schemas.deal import (
|
|
||||||
DealCreate,
|
|
||||||
DealListItem,
|
|
||||||
DealOut,
|
|
||||||
DealPipelineOut,
|
|
||||||
DealStageUpdate,
|
|
||||||
DealUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
|
||||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
|
||||||
from app.schemas.user import (
|
|
||||||
UserCreateRequest,
|
|
||||||
UserListResponse,
|
|
||||||
UserOut,
|
|
||||||
UserUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AccountCreate",
|
|
||||||
"AccountListItem",
|
|
||||||
"AccountOut",
|
|
||||||
"AccountUpdate",
|
|
||||||
"ActivityCompleteRequest",
|
|
||||||
"ActivityCreate",
|
|
||||||
"ActivityFeedItem",
|
|
||||||
"ActivityOut",
|
|
||||||
"ActivityUpdate",
|
|
||||||
"ContactCreate",
|
|
||||||
"ContactListItem",
|
|
||||||
"ContactOut",
|
|
||||||
"ContactUpdate",
|
|
||||||
"DealCreate",
|
|
||||||
"DealListItem",
|
|
||||||
"DealOut",
|
|
||||||
"DealPipelineOut",
|
|
||||||
"DealStageUpdate",
|
|
||||||
"DealUpdate",
|
|
||||||
"KPIOut",
|
|
||||||
"LogoutResponse",
|
|
||||||
"NoteCreate",
|
|
||||||
"NoteOut",
|
|
||||||
"NoteUpdate",
|
|
||||||
"PaginatedResponse",
|
|
||||||
"PaginationParams",
|
|
||||||
"RegisterResponse",
|
|
||||||
"TagCreate",
|
|
||||||
"TagLinkCreate",
|
|
||||||
"TagLinkOut",
|
|
||||||
"TagOut",
|
|
||||||
"TokenResponse",
|
|
||||||
"UserCreateRequest",
|
|
||||||
"UserListResponse",
|
|
||||||
"UserLoginRequest",
|
|
||||||
"UserOut",
|
|
||||||
"UserRegisterRequest",
|
|
||||||
"UserUpdate",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Pydantic schemas for Account endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.account import AccountSize, Industry
|
|
||||||
|
|
||||||
|
|
||||||
class AccountBase(BaseModel):
|
|
||||||
"""Shared fields for create/update."""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
|
||||||
website: Optional[str] = Field(None, max_length=512)
|
|
||||||
industry: Optional[Industry] = None
|
|
||||||
size: Optional[AccountSize] = None
|
|
||||||
address: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountCreate(AccountBase):
|
|
||||||
"""Request body for POST /api/v1/accounts/."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AccountUpdate(BaseModel):
|
|
||||||
"""Request body for PATCH /api/v1/accounts/{id}. All optional."""
|
|
||||||
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
||||||
website: Optional[str] = Field(None, max_length=512)
|
|
||||||
industry: Optional[Industry] = None
|
|
||||||
size: Optional[AccountSize] = None
|
|
||||||
address: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountOut(AccountBase):
|
|
||||||
"""Response schema for an account."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: int
|
|
||||||
org_id: int
|
|
||||||
owner_id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
deleted_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountListItem(AccountOut):
|
|
||||||
"""Slim schema for list responses."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["AccountCreate", "AccountListItem", "AccountOut", "AccountUpdate"]
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Pydantic schemas for Activity endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
||||||
|
|
||||||
from app.models.activity import ActivityType
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityBase(BaseModel):
|
|
||||||
type: ActivityType
|
|
||||||
subject: str = Field(..., min_length=1, max_length=255)
|
|
||||||
body: Optional[str] = None
|
|
||||||
due_date: Optional[datetime] = None
|
|
||||||
account_id: Optional[int] = None
|
|
||||||
contact_id: Optional[int] = None
|
|
||||||
deal_id: Optional[int] = None
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _check_at_least_one_parent(self) -> "ActivityBase":
|
|
||||||
if self.account_id is None and self.contact_id is None and self.deal_id is None:
|
|
||||||
raise ValueError(
|
|
||||||
"At least one of account_id, contact_id, deal_id must be set"
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityCreate(ActivityBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityUpdate(BaseModel):
|
|
||||||
type: Optional[ActivityType] = None
|
|
||||||
subject: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
||||||
body: Optional[str] = None
|
|
||||||
due_date: Optional[datetime] = None
|
|
||||||
account_id: Optional[int] = None
|
|
||||||
contact_id: Optional[int] = None
|
|
||||||
deal_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityOut(ActivityBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: int
|
|
||||||
org_id: int
|
|
||||||
owner_id: int
|
|
||||||
completed_at: Optional[datetime] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
deleted_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityCompleteRequest(BaseModel):
|
|
||||||
"""Body for PATCH /api/v1/activities/{id}/complete."""
|
|
||||||
|
|
||||||
outcome: Optional[str] = Field(None, max_length=2048)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ActivityCompleteRequest",
|
|
||||||
"ActivityCreate",
|
|
||||||
"ActivityOut",
|
|
||||||
"ActivityUpdate",
|
|
||||||
]
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user