Compare commits
124 Commits
dc0ec0802c
...
22976abe92
| Author | SHA1 | Date | |
|---|---|---|---|
| 22976abe92 | |||
| f8193a6ab5 | |||
| 851e7999ba | |||
| 9678344f0e | |||
| dd16940bb2 | |||
| 3ab4925783 | |||
| 6520e88d53 | |||
| 578bb3a478 | |||
| d8f46abe0b | |||
| b122ce443b | |||
| 6cf8955ded | |||
| b5f7a220fe | |||
| 7104335334 | |||
| d5b6ec3090 | |||
| c7a0b795fc | |||
| be1b473b46 | |||
| 8d6f496d14 | |||
| 6a340f8131 | |||
| 498152d4a3 | |||
| 7a3e23704b | |||
| ff61126595 | |||
| 8b9a77290e | |||
| 525d86002c | |||
| 7024575c9a | |||
| 8b3febe1a7 | |||
| 2b9bd76a78 | |||
| 8d904c316b | |||
| 26fe49490d | |||
| f9a4ae2a00 | |||
| 57354f1fdd | |||
| e3526bad50 | |||
| 2d5f9e7a3e | |||
| 43d30e5f48 | |||
| f568b2b5ce | |||
| 6a252da85e | |||
| 4d100e158a | |||
| 51a4c36d7c | |||
| 03a0a40f72 | |||
| 961455fcb6 | |||
| 65c4547317 | |||
| f17e822bdb | |||
| cfa0909c60 | |||
| da260ebe84 | |||
| fd444ab5f6 | |||
| 3cd44c8b9f | |||
| 394a910941 | |||
| 53513546ed | |||
| 17dc06e38b | |||
| d4af99ee2f | |||
| e217bb1836 | |||
| 14e78478da | |||
| 65ad6a5139 | |||
| 52d7d3cb14 | |||
| ffcf80ecd9 | |||
| d733ca87a4 | |||
| ff91306b95 | |||
| b44b1f3fe3 | |||
| f73c340757 | |||
| 58885740b7 | |||
| 49631b50b8 | |||
| 8a574252af | |||
| bc29699134 | |||
| 4d79611640 | |||
| 4fba54330e | |||
| 38e8e74c2b | |||
| 04335a6ca6 | |||
| 2cbd1089a6 | |||
| be8963ca8a | |||
| 13a862c68d | |||
| b640f314af | |||
| e6eb1df072 | |||
| 8b20f0cba8 | |||
| 6fe0f9c7f1 | |||
| bf100b547a | |||
| c5bbf81634 | |||
| bf46a57872 | |||
| 7ba0d37e80 | |||
| bd7d7c9362 | |||
| d2bcb4f1f8 | |||
| 6d6e2c1e27 | |||
| fd6d04c532 | |||
| b903e0b0dc | |||
| c29a7cfcfb | |||
| b7d1398b9a | |||
| 1d2e2c342d | |||
| dd421f37ee | |||
| 18b67a32a8 | |||
| 4295f9d7b9 | |||
| 904b095e3c | |||
| a5efb0cf72 | |||
| ee2dc7baa2 | |||
| e813aeb31e | |||
| ddd5e3d5b1 | |||
| 1ea1c0301d | |||
| c6b3430ebc | |||
| f5e91f185d | |||
| 6001ae06f7 | |||
| 7c1dcb852d | |||
| 6d66592478 | |||
| b515bf7bd8 | |||
| e7ab652cba | |||
| 46157805b1 | |||
| 405375978f | |||
| c450f3579e | |||
| 9f1187f667 | |||
| 9e2d843e19 | |||
| 62d4eec260 | |||
| d677815d23 | |||
| e9a23b0739 | |||
| 3c6cf100a1 | |||
| d852eda392 | |||
| 7490831897 | |||
| 51f46b0b6d | |||
| c30c6a50fb | |||
| 998fca0bbc | |||
| 5ca27b8349 | |||
| e97afa7837 | |||
| 5dd6027849 | |||
| fc3ad9e04c | |||
| 64a7aaef95 | |||
| a94f5479c1 | |||
| 9323c64e01 | |||
| 88bb7d74e9 | |||
| 22f0b79971 |
@@ -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,16 @@
|
|||||||
|
# Current Status — LeoCRM
|
||||||
|
|
||||||
|
**Phase**: 3 — Implementation
|
||||||
|
**Status**: T09 COMPLETE — 238/238 tests pass, 84.12% T09 coverage
|
||||||
|
**Commit**: 14bd4e3 (pushed to Forgejo)
|
||||||
|
**Date**: 2026-06-29 02:46 CEST
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
- T01 ✅ Core Infrastructure + Auth (29 tests)
|
||||||
|
- T02 ✅ Companies + Contacts + Import/Export (27 tests)
|
||||||
|
- T03 ✅ Plugin System Framework (47 tests)
|
||||||
|
- T09 ✅ KI-Copilot API + Workflow Engine (135 tests: 30 AC + 105 coverage)
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
- Delegate T07a (Frontend SPA Shell — React 18 + Vite + TypeScript)
|
||||||
|
- T07a briefing ready at .a0/briefings/T07a_briefing.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Next Steps — LeoCRM
|
||||||
|
|
||||||
|
## Current: T09 COMPLETE ✅ (commit 14bd4e3, 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)
|
||||||
|
- T09 ✅ KI-Copilot API + Workflow Engine (commit 14bd4e3)
|
||||||
|
|
||||||
|
## Next: T07a — Frontend SPA Shell
|
||||||
|
- Deps: T01 ✅
|
||||||
|
- Reqs: 23 features, 27 ACs
|
||||||
|
- Stack: React 18 + Vite + TypeScript + React Router v6 + TanStack Query v5 + Zustand + react-i18next + Tailwind CSS + Vitest
|
||||||
|
- Briefing ready: .a0/briefings/T07a_briefing.md
|
||||||
|
|
||||||
|
## After T07a: T07b (Frontend Feature Pages)
|
||||||
|
## After T07b: T10 (Monitoring, Performance, Documentation)
|
||||||
|
|
||||||
|
## v1 Execution Order
|
||||||
|
T01 ✅ → T02 ✅ → T03 ✅ → T09 ✅ → T07a → T07b → T10
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## T09 — KI-Copilot API + Hybrid Workflow Engine Backend — COMPLETE ✅
|
||||||
|
**Date**: 2026-06-29 02:46
|
||||||
|
**Commit**: 14bd4e3 (pushed to Forgejo)
|
||||||
|
**Tests**: 238/238 full suite pass (30 AC + 105 coverage + 103 existing)
|
||||||
|
**Coverage**: 84.12% for T09 modules (target: 80% ✅)
|
||||||
|
**Migration**: 0004_ai_workflows.py applied (5 tables with RLS)
|
||||||
|
|
||||||
|
### Files Created (24 new)
|
||||||
|
- app/models/ai_conversation.py, app/models/workflow.py
|
||||||
|
- app/schemas/ai_copilot.py, app/schemas/workflow.py
|
||||||
|
- app/ai/__init__.py, app/ai/llm_client.py, app/ai/action_mapper.py
|
||||||
|
- app/services/ai_copilot_service.py (~500 lines), app/services/workflow_service.py (~675 lines)
|
||||||
|
- app/routes/ai_copilot.py, app/routes/workflows.py
|
||||||
|
- app/workflows/__init__.py, app/workflows/engine.py
|
||||||
|
- app/workflows/code/__init__.py, app/workflows/code/onboarding.py
|
||||||
|
- alembic/versions/0004_ai_workflows.py
|
||||||
|
- tests/test_ai_copilot.py (67 tests), tests/test_workflows.py (68 tests)
|
||||||
|
- test_report.md
|
||||||
|
|
||||||
|
### Files Modified (7)
|
||||||
|
- app/models/__init__.py, app/routes/__init__.py, app/schemas/__init__.py, app/services/__init__.py
|
||||||
|
- app/main.py (added ai_copilot + workflows routers)
|
||||||
|
- tests/conftest.py (added new tables to TRUNCATE + model imports)
|
||||||
|
- app/core/event_bus.py (added workflow event handler registration)
|
||||||
|
|
||||||
|
### Bugs Fixed
|
||||||
|
1. MissingGreenlet on async lazy-load of updated_at/created_at — fixed with _safe_iso() and _get_attr() helpers
|
||||||
|
2. _message_to_dict in ai_copilot_service.py — patched by orchestrator (m.created_at.isoformat() → _safe_iso(_get_attr(m, "created_at")))
|
||||||
|
|
||||||
|
### Coverage Breakdown
|
||||||
|
- app/workflows/engine.py: 0% → 90.00%
|
||||||
|
- app/services/ai_copilot_service.py: 38.89% → 98.61%
|
||||||
|
- app/ai/action_mapper.py: 43.44% → 96.72%
|
||||||
|
- app/ai/llm_client.py: 64.62% → 81.54%
|
||||||
|
- app/services/workflow_service.py: 62.54% → 75.95%
|
||||||
|
- app/routes/workflows.py: 59.48% → 62.93%
|
||||||
|
- app/routes/ai_copilot.py: 65% → 65.00%
|
||||||
|
- **Overall: 45.37% → 84.12%** ✅
|
||||||
|
|
||||||
|
### Verification (Orchestrator Independent)
|
||||||
|
- pytest tests/: 238/238 PASS (zero regressions)
|
||||||
|
- Migration 0004 applied via alembic upgrade head
|
||||||
|
- RLS policies on all 5 new tables (ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history)
|
||||||
|
- No forbidden patterns (.test TLD, SET LOCAL, raise HTTPException in middleware, POST without status_code)
|
||||||
|
- POST action endpoints (query/execute/advance/cancel) correctly use 200 default
|
||||||
|
- POST creation endpoints (workflows, instances) correctly use 201
|
||||||
|
- Pushed to Forgejo: 7a5a48f..14bd4e3
|
||||||
|
|
||||||
|
### Next: T07a (Frontend SPA Shell — React 18)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Secrets - NEVER copy to image
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
coverage.xml
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
data/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Documentation (not needed in image)
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Docker files themselves
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Test directory (not needed in production image)
|
||||||
|
tests/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
ENVIRONMENT=development
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# .env.docker.example — template for `docker compose --env-file .env.docker up`
|
||||||
|
#
|
||||||
|
# DO NOT COMMIT .env.docker. It contains real secrets.
|
||||||
|
# Usage:
|
||||||
|
# cp .env.docker.example .env.docker
|
||||||
|
# $EDITOR .env.docker
|
||||||
|
# docker compose --env-file .env.docker up --build
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- PostgreSQL (local container) ---------------------------------------------
|
||||||
|
POSTGRES_USER=crm_user
|
||||||
|
# Generate a strong password, e.g.:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||||
|
POSTGRES_PASSWORD=STRONG_PASSWORD_HERE
|
||||||
|
POSTGRES_DB=crm_db
|
||||||
|
|
||||||
|
# --- CRM Application ----------------------------------------------------------
|
||||||
|
# The host "postgres" is the docker-compose service name (internal DNS).
|
||||||
|
# The DRIVER is asyncpg for production PostgreSQL.
|
||||||
|
DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
||||||
|
|
||||||
|
# --- AUTH_SECRET (REQUIRED, min 32 chars) ------------------------------------
|
||||||
|
# JWT signing secret. MUST be at least 32 characters.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
AUTH_SECRET=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx
|
||||||
|
|
||||||
|
# --- CORS / environment -------------------------------------------------------
|
||||||
|
# Comma-separated, NO wildcards. In dev we allow localhost:8000 (the app) and
|
||||||
|
# :5173 (e.g. Vite dev server). In production, restrict to the real domain.
|
||||||
|
CORS_ORIGINS=http://localhost:8000,http://localhost:5173
|
||||||
|
ENVIRONMENT=production
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- JWT / bcrypt tuning (keep aligned with .env.example) ---------------------
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_EXPIRY_HOURS=24
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# LeoCRM v1.0 - Environment Variables Template
|
||||||
|
|
||||||
|
# === REQUIRED ===
|
||||||
|
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# === OPTIONAL (with defaults) ===
|
||||||
|
|
||||||
|
# Environment: development | production | testing
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Log level: DEBUG | INFO | WARNING | ERROR
|
||||||
|
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
|
||||||
+64
-3
@@ -1,5 +1,66 @@
|
|||||||
__pycache__/
|
# Secrets and environment files
|
||||||
*.pyc
|
|
||||||
*.db
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Test and coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
coverage.xml
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Alembic (autogenerated migrations excluded, but keep 0001)
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Frontend build artifacts (Phase 4c)
|
||||||
|
webui/node_modules/
|
||||||
|
webui/dist/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker-data/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Coolify Setup — CRM System v1.0
|
||||||
|
|
||||||
|
Production deployment guide for the **CRM System** to the Coolify PaaS instance
|
||||||
|
at `server.media-on.de` (server UUID `lw80w8scs4044gwcw084s00s4`).
|
||||||
|
|
||||||
|
The deploy consists of **two Coolify resources** in the same project/environment:
|
||||||
|
|
||||||
|
1. A **PostgreSQL 16** database resource (one-click or Docker image).
|
||||||
|
2. The **crm-app** Application (Dockerfile build from a Git repository).
|
||||||
|
|
||||||
|
The two resources talk to each other over the internal Docker network. The app
|
||||||
|
is exposed publicly on `https://crm.media-on.de:443` (Let's Encrypt via Coolify).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. ⚠️ Critical domain-format gotcha
|
||||||
|
|
||||||
|
Coolify's per-application **Domain field must contain an explicit port** in the
|
||||||
|
URL. If you enter the domain without `:443`, Let's Encrypt certificate issuance
|
||||||
|
will silently fail and Traefik will not route traffic correctly.
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ https://crm.media-on.de:443
|
||||||
|
❌ https://crm.media-on.de
|
||||||
|
❌ crm.media-on.de
|
||||||
|
```
|
||||||
|
|
||||||
|
> The same rule applies in the Coolify API: when calling
|
||||||
|
> `PATCH /api/v1/applications/{uuid}` you must set
|
||||||
|
> `{"domains": "https://crm.media-on.de:443"}` (note the `:443` suffix).
|
||||||
|
> This is a known bug-fix from earlier deployments — never drop the port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
- Coolify server reachable at `https://server.media-on.de`, API token created
|
||||||
|
in *Keys & Tokens → API tokens* (Bearer token, scope: `*`).
|
||||||
|
- The DNS **A record** for `crm.media-on.de` points to the public IP of the
|
||||||
|
Coolify server (Traefik will answer on `:443` and route by `Host` header).
|
||||||
|
- The CRM source code lives in a **Forgejo repository** that Coolify can
|
||||||
|
clone. Suggested location:
|
||||||
|
`https://forge.media-on.de/leopoldadmin/crm-system` (branch `master`).
|
||||||
|
> If the repo does not exist yet, create it and push the project:
|
||||||
|
> ```bash
|
||||||
|
> # One-time: create the repo via Forgejo API or UI
|
||||||
|
> git remote add origin https://leopoldadmin:<TOKEN>@forge.media-on.de/leopoldadmin/crm-system.git
|
||||||
|
> git push -u origin master
|
||||||
|
> ```
|
||||||
|
- You have the **internal host:port** of the Postgres resource that will be
|
||||||
|
provisioned in step 2 (Coolify will print it, e.g. `abc123-postgres:5432`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Resource A — PostgreSQL 16 database
|
||||||
|
|
||||||
|
In the Coolify UI:
|
||||||
|
|
||||||
|
1. Go to **Databases → + Add**.
|
||||||
|
2. Choose **PostgreSQL 16** (Alpine).
|
||||||
|
3. Configuration:
|
||||||
|
- **Name**: `crm-postgres`
|
||||||
|
- **Database name**: `crm_db`
|
||||||
|
- **User**: `crm_user`
|
||||||
|
- **Password**: *(generate a strong one — see Secret generation below)*
|
||||||
|
- **Public accessibility**: **disabled** (only the crm-app talks to it)
|
||||||
|
4. Click **Deploy** and wait for status `running:healthy`.
|
||||||
|
5. Note the **internal host:port** Coolify exposes (typically
|
||||||
|
`<resource-uuid>-postgres:5432`). You will need it in step 3.
|
||||||
|
|
||||||
|
> **Alternative (API):**
|
||||||
|
> ```bash
|
||||||
|
> curl -X POST http://server.media-on.de/api/v1/databases \
|
||||||
|
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
> -H "Content-Type: application/json" \
|
||||||
|
> -d '{"type":"postgresql","project_uuid":"...","environment_name":"production",
|
||||||
|
> "server_uuid":"lw80w8scs4044gwcw084s00s4",
|
||||||
|
> "name":"crm-postgres","postgres_user":"crm_user",
|
||||||
|
> "postgres_password":"<STRONG_PASSWORD>",
|
||||||
|
> "postgres_db":"crm_db","is_public":false}'
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Resource B — crm-app (Dockerfile build)
|
||||||
|
|
||||||
|
In the Coolify UI:
|
||||||
|
|
||||||
|
1. **Projects → + Add Project** if you don't have one yet (e.g. `CRM`).
|
||||||
|
2. **Environment → + Add Environment** → name: `production`.
|
||||||
|
3. Inside that environment, **+ Add → Application → Public/Private Repository**.
|
||||||
|
4. Fill in:
|
||||||
|
- **Git repository**: `https://forge.media-on.de/leopoldadmin/crm-system`
|
||||||
|
- **Branch**: `master`
|
||||||
|
- **Build pack**: `Dockerfile`
|
||||||
|
- **Dockerfile location**: `Dockerfile` (default, repo root)
|
||||||
|
- **Port**: `8000`
|
||||||
|
5. Click **Deploy** once to let Coolify create the resource (it will fail to
|
||||||
|
start without environment variables — that's expected).
|
||||||
|
6. Note the **Application UUID** (visible in the URL or via
|
||||||
|
`GET /api/v1/applications`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment variables (on the crm-app resource)
|
||||||
|
|
||||||
|
In **crm-app → Environment Variables**, set:
|
||||||
|
|
||||||
|
| Key | Value | Notes |
|
||||||
|
|-----|-------|-------|
|
||||||
|
| `DATABASE_URL` | `postgresql+asyncpg://crm_user:<PW>@<postgres-internal-host>:5432/crm_db` | Use the internal host from step 2 (e.g. `crm-postgres-xyz:5432`), **not** `localhost` and **not** the public DNS. |
|
||||||
|
| `AUTH_SECRET` | *see secret generation* | **MUST be ≥ 32 chars.** |
|
||||||
|
| `CORS_ORIGINS` | `https://crm.media-on.de:443` | Comma-separated, no wildcards, must match the domain where the browser actually loads the SPA. |
|
||||||
|
| `ENVIRONMENT` | `production` | |
|
||||||
|
| `LOG_LEVEL` | `INFO` | `DEBUG` only temporarily. |
|
||||||
|
| `BCRYPT_ROUNDS` | `12` | Aligned with `.env.example`. |
|
||||||
|
| `JWT_ALGORITHM` | `HS256` | Aligned with `.env.example`. |
|
||||||
|
| `JWT_EXPIRY_HOURS` | `24` | Aligned with `.env.example`. |
|
||||||
|
|
||||||
|
### Secret generation (run once, locally)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AUTH_SECRET (min 32 chars, recommended 48+)
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
|
||||||
|
# POSTGRES_PASSWORD (min 16 chars, recommended 24+)
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never commit these values.** Coolify stores them encrypted at rest, but they
|
||||||
|
are still rendered in the UI to anyone with read access to the environment.
|
||||||
|
|
||||||
|
> **Alternative (API — bulk update):**
|
||||||
|
> ```bash
|
||||||
|
> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID/envs/bulk \
|
||||||
|
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
> -H "Content-Type: application/json" \
|
||||||
|
> -d '{
|
||||||
|
> "data": [
|
||||||
|
> {"key":"DATABASE_URL", "value":"postgresql+asyncpg://crm_user:<PW>@<PG_HOST>:5432/crm_db"},
|
||||||
|
> {"key":"AUTH_SECRET", "value":"<TOKEN_URLSAFE_48>"},
|
||||||
|
> {"key":"CORS_ORIGINS", "value":"https://crm.media-on.de:443"},
|
||||||
|
> {"key":"ENVIRONMENT", "value":"production"},
|
||||||
|
> {"key":"LOG_LEVEL", "value":"INFO"},
|
||||||
|
> {"key":"BCRYPT_ROUNDS", "value":"12"},
|
||||||
|
> {"key":"JWT_ALGORITHM", "value":"HS256"},
|
||||||
|
> {"key":"JWT_EXPIRY_HOURS", "value":"24"}
|
||||||
|
> ]
|
||||||
|
> }'
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configure the public domain (with port!)
|
||||||
|
|
||||||
|
In **crm-app → Domains → + Add Domain**:
|
||||||
|
|
||||||
|
- **Domain**: `https://crm.media-on.de:443`
|
||||||
|
- ⚠️ **Port `:443` is mandatory.** See section 0.
|
||||||
|
- **Let's Encrypt**: **enabled** (default).
|
||||||
|
- Click **Save**. Coolify will issue the certificate and reload Traefik.
|
||||||
|
|
||||||
|
> **Alternative (API):**
|
||||||
|
> ```bash
|
||||||
|
> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID \
|
||||||
|
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
> -H "Content-Type: application/json" \
|
||||||
|
> -d '{"domains": "https://crm.media-on.de:443"}'
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Healthcheck (Coolify side)
|
||||||
|
|
||||||
|
In **crm-app → Advanced → Healthcheck**:
|
||||||
|
|
||||||
|
- **Healthcheck path**: `/health`
|
||||||
|
- **Healthcheck method**: `GET`
|
||||||
|
- **Healthcheck interval**: `30s`
|
||||||
|
- **Healthcheck timeout**: `10s`
|
||||||
|
- **Healthcheck retries**: `3`
|
||||||
|
- **Healthcheck start period**: `15s`
|
||||||
|
|
||||||
|
> The Dockerfile's in-container `HEALTHCHECK` is the source of truth for
|
||||||
|
> Docker-level health. The Coolify/Traefik healthcheck is what drives
|
||||||
|
> automatic rollbacks and load-balancer routing. Set both, identically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Build & deploy
|
||||||
|
|
||||||
|
In the Coolify UI: **crm-app → Deployments → Deploy**.
|
||||||
|
|
||||||
|
Watch the build log. The first deploy will:
|
||||||
|
|
||||||
|
1. Clone the repo (branch `master`).
|
||||||
|
2. Build the multi-stage Dockerfile (≈ 1–2 min, depending on cache).
|
||||||
|
3. Start the container. `prestart.sh` runs `alembic upgrade head` against the
|
||||||
|
Postgres database.
|
||||||
|
4. Uvicorn binds to `0.0.0.0:8000` and starts serving.
|
||||||
|
|
||||||
|
A healthy deploy ends with the container status `running:healthy`.
|
||||||
|
|
||||||
|
> **Alternative (API):**
|
||||||
|
> ```bash
|
||||||
|
> curl -X POST http://server.media-on.de/api/v1/deploy \
|
||||||
|
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
|
||||||
|
> -H "Content-Type: application/json" \
|
||||||
|
> -d "{\"uuid\":\"$APP_UUID\"}"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
From anywhere with internet access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Root health (used by Docker HEALTHCHECK & Coolify healthcheck)
|
||||||
|
curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/health
|
||||||
|
# → 200
|
||||||
|
|
||||||
|
# 2. API v1 health (mounted under the versioned router)
|
||||||
|
curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/api/v1/health
|
||||||
|
# → 200
|
||||||
|
|
||||||
|
# 3. Frontend SPA (served by the static-files mount)
|
||||||
|
curl -fsSL -o /dev/null -w "%{http_code} %{content_type}\n" \
|
||||||
|
https://crm.media-on.de:443/index.html
|
||||||
|
# → 200 text/html
|
||||||
|
|
||||||
|
# 4. Interactive API docs
|
||||||
|
# Open in a browser: https://crm.media-on.de:443/docs
|
||||||
|
# Register a user via POST /api/v1/auth/register
|
||||||
|
# Login via POST /api/v1/auth/login → access_token
|
||||||
|
# Use the token as `Authorization: Bearer <access_token>` on protected routes
|
||||||
|
```
|
||||||
|
|
||||||
|
If any of these return `502` / `503` / `504`:
|
||||||
|
|
||||||
|
- Check **crm-app → Logs** in Coolify (the UI is the only place with full
|
||||||
|
stdout/stderr, the API does not expose logs).
|
||||||
|
- Confirm the container is `running:healthy` (not `running:unhealthy`,
|
||||||
|
`exited`, or `starting`).
|
||||||
|
- Confirm the Postgres resource is `running:healthy` and the
|
||||||
|
`DATABASE_URL` host matches its internal DNS name.
|
||||||
|
|
||||||
|
For full incident response, see [`/a0/.a0/runbook-restore.md`](../../a0/runbook-restore.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Going forward — redeploys
|
||||||
|
|
||||||
|
- **Code change** → push to `master` on Forgejo → **Deployments → Deploy** in
|
||||||
|
Coolify. The Dockerfile layer-cache will reuse `pip install -r
|
||||||
|
requirements.txt` if `requirements.txt` is unchanged.
|
||||||
|
- **Environment variable change** → edit in Coolify UI (or `PATCH .../envs/bulk`
|
||||||
|
via API) → **Deploy** (Coolify does *not* auto-restart on ENV change alone).
|
||||||
|
- **Domain change** → use the API (`PATCH /api/v1/applications/{uuid}`) so it
|
||||||
|
is reproducible; the UI is a fallback only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. References
|
||||||
|
|
||||||
|
- Coolify v4 API — `/a0/usr/plugins/coolify_control/help/coolify-control/help.md`
|
||||||
|
- App architecture (Section 13 lockdown) — `/a0/.a0/02-architecture.md`
|
||||||
|
- Task graph (Phase 4d) — `/a0/.a0/03-task-graph.json`
|
||||||
|
- Restore runbook — `/a0/.a0/runbook-restore.md`
|
||||||
+72
-6
@@ -1,8 +1,74 @@
|
|||||||
FROM python:3.12-slim
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CRM System v1.0 - Production Dockerfile
|
||||||
|
# Multi-stage build: builder (with build tools) + runtime (slim, non-root)
|
||||||
|
# Base: python:3.12-slim
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# === Stage 1: Builder ===
|
||||||
|
# Installs build dependencies (needed for compiling asyncpg, cryptography, etc.)
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Build tools (gcc, libpq-dev) — needed for asyncpg + python-jose[cryptography]
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy ONLY requirements first for optimal layer caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
COPY . .
|
# Install all production dependencies into a user-local prefix
|
||||||
RUN python3 -c "from app import init_db; init_db()"
|
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||||
EXPOSE 5000
|
|
||||||
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]
|
# === Stage 2: Runtime ===
|
||||||
|
# Slim image, non-root user, no build tools
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PATH=/home/appuser/.local/bin:$PATH
|
||||||
|
|
||||||
|
# Runtime dependencies: libpq5 (for asyncpg), curl (for healthcheck)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd -g 1000 appuser \
|
||||||
|
&& useradd -m -u 1000 -g appuser appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy installed Python packages from builder
|
||||||
|
COPY --from=builder /root/.local /home/appuser/.local
|
||||||
|
|
||||||
|
# Copy application source (static files included via app/webui/)
|
||||||
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
|
# Make prestart.sh executable
|
||||||
|
RUN chmod +x /app/prestart.sh
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Internal port (Coolify/Traefik terminate SSL on 443 externally)
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Healthcheck: hits the root-level /health endpoint defined in app/main.py
|
||||||
|
# Interval 30s, timeout 10s, 3 retries, 15s start-period (migrations need time)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Entrypoint runs DB migrations first, then starts uvicorn as PID 1
|
||||||
|
ENTRYPOINT ["/app/prestart.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# CRM System v1.0
|
||||||
|
|
||||||
|
> Self-hosted CRM for small sales teams (5–25 sales reps).
|
||||||
|
> Stack: FastAPI + SQLAlchemy (async) + Alembic + Pydantic v2 + SQLite/PostgreSQL + Alpine.js + Tailwind + Docker + Coolify
|
||||||
|
|
||||||
|
## Quick Start (Development)
|
||||||
|
|
||||||
|
### 1. Clone and Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> crm-system
|
||||||
|
cd crm-system
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generate a secure AUTH_SECRET (min 32 chars)
|
||||||
|
python3 -c "import secrets; print('AUTH_SECRET=' + secrets.token_urlsafe(48))" >> .env
|
||||||
|
|
||||||
|
# Edit .env and set AUTH_SECRET (remove the placeholder line first)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# (Optional) Create migration after model changes
|
||||||
|
# alembic revision --autogenerate -m "description"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development with auto-reload
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Production-like
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
- API: http://localhost:8000
|
||||||
|
- Swagger UI: http://localhost:8000/docs
|
||||||
|
- ReDoc: http://localhost:8000/redoc
|
||||||
|
- Health: http://localhost:8000/health
|
||||||
|
|
||||||
|
### 5. Bootstrap First User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "secure-password-123",
|
||||||
|
"name": "First Admin"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the first user + a default org. After that, registration is disabled (use admin invite flow in v1.1).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crm-system/
|
||||||
|
├── app/ # Application package
|
||||||
|
│ ├── main.py # FastAPI entry point
|
||||||
|
│ ├── core/ # Core modules (config, db, security, deps)
|
||||||
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── schemas/ # Pydantic schemas (request/response)
|
||||||
|
│ ├── services/ # Business logic layer
|
||||||
|
│ ├── api/v1/ # API routers (versioned)
|
||||||
|
│ └── webui/ # Static frontend (Phase 4c)
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
│ ├── env.py # Async migration environment
|
||||||
|
│ └── versions/ # Migration scripts
|
||||||
|
├── tests/ # Test suite (pytest + pytest-asyncio)
|
||||||
|
├── requirements.txt # Production dependencies
|
||||||
|
├── requirements-dev.txt # Test/lint dependencies
|
||||||
|
├── pyproject.toml # Tool configuration
|
||||||
|
├── alembic.ini # Alembic configuration
|
||||||
|
├── .env.example # Environment template
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest -v --tb=short
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=app --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_auth.py -v
|
||||||
|
|
||||||
|
# Stop on first failure (for debugging)
|
||||||
|
pytest -x
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AUTH_SECRET` | ✅ | – | JWT signing secret (≥32 chars). Hard-fail if missing. |
|
||||||
|
| `DATABASE_URL` | ❌ | `sqlite+aiosqlite:///./dev.db` | Async DB URL (aiosqlite or asyncpg) |
|
||||||
|
| `JWT_ALGORITHM` | ❌ | `HS256` | JWT algorithm |
|
||||||
|
| `JWT_EXPIRY_HOURS` | ❌ | `24` | Token lifetime |
|
||||||
|
| `BCRYPT_ROUNDS` | ❌ | `12` | Password hashing cost |
|
||||||
|
| `CORS_ORIGINS` | ❌ | `http://localhost:5500,http://localhost:8000` | Allowed origins (comma-separated, NO wildcards) |
|
||||||
|
| `ENVIRONMENT` | ❌ | `development` | `development` or `production` |
|
||||||
|
| `LOG_LEVEL` | ❌ | `INFO` | Python log level |
|
||||||
|
|
||||||
|
## Architecture Decisions (ADR)
|
||||||
|
|
||||||
|
- **JWT Library**: `python-jose[cryptography]==3.3.0` (pattern reuse from wochenplaner)
|
||||||
|
- **Database**: SQLite (aiosqlite) for dev, PostgreSQL (asyncpg) for prod
|
||||||
|
- **Auth**: Stateless JWT in localStorage, bcrypt password hashing (12 rounds)
|
||||||
|
- **Security**: CORS whitelist (no wildcard), CSP middleware, no default admin user
|
||||||
|
- **Async**: All routers/services/DB operations are async (SQLAlchemy 2.0 + aiosqlite)
|
||||||
|
|
||||||
|
See `/a0/.a0/02-architecture.md` Section 13 for full lockdown decisions.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See `/a0/.a0/03-task-graph.json` Phase 4d for Docker + Coolify setup (out of scope for Phase 4a).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Internal project – proprietary.
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# A generic, single-database Alembic configuration for the CRM System.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# Path to migration scripts (relative to alembic.ini location).
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# Timezone for create date in migration files (UTC).
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
# Max identifier length (PostgreSQL compatibility).
|
||||||
|
truncate_slug_length = 40
|
||||||
|
|
||||||
|
# Set in env.py via app.core.config — do NOT hardcode here.
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
# Migration script location.
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# File template for version files.
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# Logging configuration.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Alembic migration environment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models import * # noqa: F401,F403
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -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)
|
||||||
@@ -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,256 +0,0 @@
|
|||||||
"""LeoCRM - Minimal CRM with auth, companies, contacts."""
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, session, g
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.secret_key = os.urandom(24).hex()
|
|
||||||
DATABASE = os.path.join(os.path.dirname(__file__), 'leocrm.db')
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
if 'db' not in g:
|
|
||||||
g.db = sqlite3.connect(DATABASE)
|
|
||||||
g.db.row_factory = sqlite3.Row
|
|
||||||
g.db.execute("PRAGMA foreign_keys = ON")
|
|
||||||
return g.db
|
|
||||||
|
|
||||||
|
|
||||||
@app.teardown_appcontext
|
|
||||||
def close_db(exception):
|
|
||||||
db = g.pop('db', None)
|
|
||||||
if db is not None:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
|
||||||
db = sqlite3.connect(DATABASE)
|
|
||||||
db.executescript('''
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS companies (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
address TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
email TEXT,
|
|
||||||
website TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
first_name TEXT NOT NULL,
|
|
||||||
last_name TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
position TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
company_id INTEGER NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
''')
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if 'user_id' not in session:
|
|
||||||
flash('Bitte melde dich an.', 'warning')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
if 'user_id' in session:
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
|
||||||
def register():
|
|
||||||
if request.method == 'POST':
|
|
||||||
username = request.form['username'].strip()
|
|
||||||
password = request.form['password']
|
|
||||||
if not username or not password:
|
|
||||||
flash('Benutzername und Passwort sind erforderlich.', 'danger')
|
|
||||||
return render_template('register.html')
|
|
||||||
db = get_db()
|
|
||||||
existing = db.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone()
|
|
||||||
if existing:
|
|
||||||
flash('Benutzername bereits vergeben.', 'danger')
|
|
||||||
return render_template('register.html')
|
|
||||||
db.execute('INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
|
||||||
(username, generate_password_hash(password)))
|
|
||||||
db.commit()
|
|
||||||
flash('Registrierung erfolgreich! Bitte melde dich an.', 'success')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
return render_template('register.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
if request.method == 'POST':
|
|
||||||
username = request.form['username'].strip()
|
|
||||||
password = request.form['password']
|
|
||||||
db = get_db()
|
|
||||||
user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
|
||||||
if user and check_password_hash(user['password_hash'], password):
|
|
||||||
session['user_id'] = user['id']
|
|
||||||
session['username'] = user['username']
|
|
||||||
flash(f'Willkommen, {username}!', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
flash('Ungültige Anmeldedaten.', 'danger')
|
|
||||||
return render_template('login.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
def logout():
|
|
||||||
session.clear()
|
|
||||||
flash('Du wurdest abgemeldet.', 'info')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/dashboard')
|
|
||||||
@login_required
|
|
||||||
def dashboard():
|
|
||||||
db = get_db()
|
|
||||||
companies = db.execute(
|
|
||||||
'SELECT * FROM companies WHERE user_id = ? ORDER BY name',
|
|
||||||
(session['user_id'],)
|
|
||||||
).fetchall()
|
|
||||||
return render_template('dashboard.html', companies=companies)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/create', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def company_create():
|
|
||||||
if request.method == 'POST':
|
|
||||||
db = get_db()
|
|
||||||
db.execute('INSERT INTO companies (name, address, phone, email, website, notes, user_id) VALUES (?,?,?,?,?,?,?)',
|
|
||||||
(request.form['name'], request.form.get('address',''), request.form.get('phone',''),
|
|
||||||
request.form.get('email',''), request.form.get('website',''), request.form.get('notes',''),
|
|
||||||
session['user_id']))
|
|
||||||
db.commit()
|
|
||||||
flash('Firma erstellt.', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
return render_template('company_form.html', company=None)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/<int:id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def company_edit(id):
|
|
||||||
db = get_db()
|
|
||||||
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
|
|
||||||
(id, session['user_id'])).fetchone()
|
|
||||||
if not company:
|
|
||||||
flash('Firma nicht gefunden.', 'danger')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
if request.method == 'POST':
|
|
||||||
db.execute('UPDATE companies SET name=?, address=?, phone=?, email=?, website=?, notes=? WHERE id=?',
|
|
||||||
(request.form['name'], request.form.get('address',''), request.form.get('phone',''),
|
|
||||||
request.form.get('email',''), request.form.get('website',''), request.form.get('notes',''), id))
|
|
||||||
db.commit()
|
|
||||||
flash('Firma aktualisiert.', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
return render_template('company_form.html', company=company)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/<int:id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def company_delete(id):
|
|
||||||
db = get_db()
|
|
||||||
db.execute('DELETE FROM companies WHERE id = ? AND user_id = ?', (id, session['user_id']))
|
|
||||||
db.commit()
|
|
||||||
flash('Firma gelöscht.', 'info')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/<int:company_id>/contacts')
|
|
||||||
@login_required
|
|
||||||
def contact_list(company_id):
|
|
||||||
db = get_db()
|
|
||||||
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
|
|
||||||
(company_id, session['user_id'])).fetchone()
|
|
||||||
if not company:
|
|
||||||
flash('Firma nicht gefunden.', 'danger')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
contacts = db.execute('SELECT * FROM contacts WHERE company_id = ? ORDER BY last_name, first_name',
|
|
||||||
(company_id,)).fetchall()
|
|
||||||
return render_template('contact_list.html', company=company, contacts=contacts)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/<int:company_id>/contacts/create', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_create(company_id):
|
|
||||||
db = get_db()
|
|
||||||
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
|
|
||||||
(company_id, session['user_id'])).fetchone()
|
|
||||||
if not company:
|
|
||||||
flash('Firma nicht gefunden.', 'danger')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
if request.method == 'POST':
|
|
||||||
db.execute('INSERT INTO contacts (first_name, last_name, email, phone, position, notes, company_id) VALUES (?,?,?,?,?,?,?)',
|
|
||||||
(request.form['first_name'], request.form['last_name'], request.form.get('email',''),
|
|
||||||
request.form.get('phone',''), request.form.get('position',''), request.form.get('notes',''), company_id))
|
|
||||||
db.commit()
|
|
||||||
flash('Kontaktperson erstellt.', 'success')
|
|
||||||
return redirect(url_for('contact_list', company_id=company_id))
|
|
||||||
return render_template('contact_form.html', company=company, contact=None)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/contacts/<int:id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_edit(id):
|
|
||||||
db = get_db()
|
|
||||||
contact = db.execute('''SELECT c.*, co.name as company_name, co.user_id
|
|
||||||
FROM contacts c JOIN companies co ON c.company_id = co.id
|
|
||||||
WHERE c.id = ? AND co.user_id = ?''',
|
|
||||||
(id, session['user_id'])).fetchone()
|
|
||||||
if not contact:
|
|
||||||
flash('Kontakt nicht gefunden.', 'danger')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
if request.method == 'POST':
|
|
||||||
db.execute('UPDATE contacts SET first_name=?, last_name=?, email=?, phone=?, position=?, notes=? WHERE id=?',
|
|
||||||
(request.form['first_name'], request.form['last_name'], request.form.get('email',''),
|
|
||||||
request.form.get('phone',''), request.form.get('position',''), request.form.get('notes',''), id))
|
|
||||||
db.commit()
|
|
||||||
flash('Kontakt aktualisiert.', 'success')
|
|
||||||
return redirect(url_for('contact_list', company_id=contact['company_id']))
|
|
||||||
return render_template('contact_form.html', company={'id': contact['company_id'], 'name': contact['company_name']}, contact=contact)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/contacts/<int:id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def contact_delete(id):
|
|
||||||
db = get_db()
|
|
||||||
contact = db.execute('''SELECT c.company_id, co.user_id
|
|
||||||
FROM contacts c JOIN companies co ON c.company_id = co.id
|
|
||||||
WHERE c.id = ?''', (id,)).fetchone()
|
|
||||||
if contact and contact['user_id'] == session['user_id']:
|
|
||||||
db.execute('DELETE FROM contacts WHERE id = ?', (id,))
|
|
||||||
db.commit()
|
|
||||||
flash('Kontakt gelöscht.', 'info')
|
|
||||||
return redirect(url_for('contact_list', company_id=contact['company_id']))
|
|
||||||
flash('Kontakt nicht gefunden.', 'danger')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
init_db()
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""LeoCRM backend application package."""
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API v1 package."""
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"])
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
"""FastAPI application - LeoCRM backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.middleware import CSRFMiddleware
|
||||||
|
from app.core.db import close_engine, get_engine
|
||||||
|
from app.core.service_container import get_container
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan: startup and shutdown."""
|
||||||
|
# Initialize service container
|
||||||
|
container = get_container()
|
||||||
|
await container.initialize()
|
||||||
|
|
||||||
|
# Initialize plugin registry and discover built-in plugins
|
||||||
|
registry = get_registry()
|
||||||
|
registry.initialize(get_engine(), app)
|
||||||
|
registry.discover_builtins()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application."""
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
|
||||||
|
max_age=3600,
|
||||||
|
)
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(roles.router)
|
||||||
|
app.include_router(tenants.router)
|
||||||
|
app.include_router(notifications.router)
|
||||||
|
app.include_router(companies.router)
|
||||||
|
app.include_router(contacts.router)
|
||||||
|
app.include_router(import_export.router)
|
||||||
|
app.include_router(plugins.router)
|
||||||
|
app.include_router(ai_copilot.router)
|
||||||
|
app.include_router(workflows.router)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""SQLAlchemy models for LeoCRM."""
|
||||||
|
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.user import User, UserTenant
|
||||||
|
from app.models.role import Role
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.audit import AuditLog, DeletionLog
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.auth import PasswordResetToken, ApiToken
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.contact import Contact, CompanyContact
|
||||||
|
from app.models.plugin import Plugin, PluginMigration
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||||
|
"AuditLog", "DeletionLog", "Notification",
|
||||||
|
"PasswordResetToken", "ApiToken", "Company",
|
||||||
|
"Contact", "CompanyContact",
|
||||||
|
"Plugin", "PluginMigration",
|
||||||
|
]
|
||||||
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||||
|
"AuditLog", "DeletionLog", "Notification",
|
||||||
|
"PasswordResetToken", "ApiToken", "Company",
|
||||||
|
"Contact", "CompanyContact",
|
||||||
|
"Plugin", "PluginMigration",
|
||||||
|
"AIConversation", "AIMessage",
|
||||||
|
"Workflow", "WorkflowInstance", "WorkflowStepHistory",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Contact and CompanyContact (N:M join) models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
Boolean,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base, TenantMixin):
|
||||||
|
"""Contact person entity — can be linked to multiple companies via CompanyContact."""
|
||||||
|
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
|
Index("ix_contacts_tenant_name", "tenant_id", "last_name", "first_name"),
|
||||||
|
Index("ix_contacts_email", "email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyContact(Base, TenantMixin):
|
||||||
|
"""N:M join table between Company and 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)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""User and UserTenant models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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 app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TenantMixin):
|
||||||
|
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenant(Base):
|
||||||
|
"""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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Pydantic schemas package."""
|
||||||
|
|
||||||
|
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
|
||||||
|
from app.schemas.ai_copilot import (
|
||||||
|
CopilotQueryRequest, CopilotAction, CopilotQueryResponse,
|
||||||
|
CopilotExecuteRequest, CopilotExecuteResponse,
|
||||||
|
CopilotHistoryResponse, CopilotMessageResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.workflow import (
|
||||||
|
WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse,
|
||||||
|
InstanceCreate, InstanceResponse, InstanceDetailResponse, InstanceListResponse,
|
||||||
|
AdvanceRequest, StepHistoryResponse, WorkflowStep,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""AI Copilot schemas — query, execute, history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotQueryRequest(BaseModel):
|
||||||
|
"""Natural language query to the AI copilot."""
|
||||||
|
query: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
conversation_id: str | None = None
|
||||||
|
context: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotAction(BaseModel):
|
||||||
|
"""A proposed API action derived from NL input."""
|
||||||
|
method: str = Field(..., pattern="^(GET|POST|PATCH|DELETE)$")
|
||||||
|
path: str = Field(..., min_length=1)
|
||||||
|
body: dict | None = None
|
||||||
|
description: str = ""
|
||||||
|
confidence: float = Field(0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotQueryResponse(BaseModel):
|
||||||
|
"""Response from copilot query — proposed actions for user confirmation."""
|
||||||
|
conversation_id: str
|
||||||
|
message: str
|
||||||
|
proposed_actions: list[CopilotAction] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotExecuteRequest(BaseModel):
|
||||||
|
"""Execute a proposed action after user confirmation."""
|
||||||
|
conversation_id: str
|
||||||
|
action: CopilotAction
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotExecuteResponse(BaseModel):
|
||||||
|
"""Result of executing a proposed action."""
|
||||||
|
conversation_id: str
|
||||||
|
success: bool
|
||||||
|
status_code: int
|
||||||
|
data: dict | list | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotMessageResponse(BaseModel):
|
||||||
|
"""A single message in conversation history."""
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
proposed_actions: list[dict] | None = None
|
||||||
|
executed_action: dict | None = None
|
||||||
|
execution_result: dict | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotHistoryResponse(BaseModel):
|
||||||
|
"""Paginated conversation history."""
|
||||||
|
items: list[CopilotMessageResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Auth schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirm(BaseModel):
|
||||||
|
token: str = Field(..., min_length=1)
|
||||||
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchTenantRequest(BaseModel):
|
||||||
|
tenant_id: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
tenant_id: str
|
||||||
|
tenant_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Common schemas for pagination, errors, etc."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
detail: str
|
||||||
|
code: str | None = None
|
||||||
|
fields: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
body: str | None = None
|
||||||
|
read_at: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
items: list[NotificationResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Company schemas — create, update, read, list, pagination, search, filter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
account_number: str | None = Field(None, max_length=40)
|
||||||
|
industry: str | None = Field(None, max_length=50)
|
||||||
|
phone: str | None = Field(None, max_length=30)
|
||||||
|
email: str | None = Field(None, max_length=255)
|
||||||
|
website: str | None = Field(None, max_length=500)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
account_number: str | None = Field(None, max_length=40)
|
||||||
|
industry: str | None = Field(None, max_length=50)
|
||||||
|
phone: str | None = Field(None, max_length=30)
|
||||||
|
email: str | None = Field(None, max_length=255)
|
||||||
|
website: str | None = Field(None, max_length=500)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
account_number: str | None = None
|
||||||
|
industry: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyDetailResponse(CompanyResponse):
|
||||||
|
contacts: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyListResponse(BaseModel):
|
||||||
|
items: list[CompanyResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyExportRequest(BaseModel):
|
||||||
|
format: str = Field("csv", pattern="^(csv|xlsx)$")
|
||||||
|
industry: str | None = None
|
||||||
|
search: str | None = None
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Contact schemas — create, update, read, list."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreate(BaseModel):
|
||||||
|
first_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
last_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
email: str | None = Field(None, max_length=255)
|
||||||
|
phone: str | None = Field(None, max_length=30)
|
||||||
|
mobile: str | None = Field(None, max_length=30)
|
||||||
|
position: str | None = Field(None, max_length=100)
|
||||||
|
department: str | None = Field(None, max_length=100)
|
||||||
|
linkedin_url: str | None = Field(None, max_length=500)
|
||||||
|
notes: str | None = None
|
||||||
|
company_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
email: str | None = Field(None, max_length=255)
|
||||||
|
phone: str | None = Field(None, max_length=30)
|
||||||
|
mobile: str | None = Field(None, max_length=30)
|
||||||
|
position: str | None = Field(None, max_length=100)
|
||||||
|
department: str | None = Field(None, max_length=100)
|
||||||
|
linkedin_url: str | None = Field(None, max_length=500)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
mobile: str | None = None
|
||||||
|
position: str | None = None
|
||||||
|
department: str | None = None
|
||||||
|
linkedin_url: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactDetailResponse(ContactResponse):
|
||||||
|
companies: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactListResponse(BaseModel):
|
||||||
|
items: list[ContactResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyLinkRequest(BaseModel):
|
||||||
|
role_at_company: str | None = Field(None, max_length=100)
|
||||||
|
is_primary: bool = False
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Pydantic schemas for plugin API responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInfo(BaseModel):
|
||||||
|
"""Plugin status information returned in list endpoint."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
version: str
|
||||||
|
status: str = Field(description="discovered | installed | active | inactive")
|
||||||
|
installed: bool = False
|
||||||
|
active: bool = False
|
||||||
|
description: str = ""
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
events: list[str] = Field(default_factory=list)
|
||||||
|
migrations: list[str] = Field(default_factory=list)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginListResponse(BaseModel):
|
||||||
|
"""Response for GET /api/v1/plugins."""
|
||||||
|
|
||||||
|
plugins: list[PluginInfo]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PluginActionResponse(BaseModel):
|
||||||
|
"""Response for install/activate/deactivate/uninstall actions."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
version: str
|
||||||
|
status: str
|
||||||
|
installed: bool = False
|
||||||
|
active: bool = False
|
||||||
|
dropped_tables: list[str] = Field(default_factory=list, description="Tables dropped (uninstall with remove_data=true)")
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginUninstallResponse(PluginActionResponse):
|
||||||
|
"""Response for DELETE /api/v1/plugins/{name}."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestFieldDoc(BaseModel):
|
||||||
|
"""Documentation for a single manifest field."""
|
||||||
|
type: str
|
||||||
|
required: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestSchemaResponse(BaseModel):
|
||||||
|
"""Response for GET /api/v1/plugins/manifest."""
|
||||||
|
|
||||||
|
fields: dict[str, ManifestFieldDoc]
|
||||||
|
example: dict
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Role schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
field_permissions: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
permissions: dict[str, Any] | None = None
|
||||||
|
field_permissions: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
permissions: dict[str, Any]
|
||||||
|
field_permissions: dict[str, Any]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Tenant schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
slug: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUserAssign(BaseModel):
|
||||||
|
user_id: str = Field(..., min_length=1)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""User schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
role: str = Field(default="viewer")
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
role: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedUsers(BaseModel):
|
||||||
|
items: list[UserResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Workflow schemas — create, update, read, instance lifecycle, step history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStep(BaseModel):
|
||||||
|
"""A single step in a workflow definition."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
type: str = Field(..., pattern="^(action|approval|notification|condition)$")
|
||||||
|
config: dict = Field(default_factory=dict)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCreate(BaseModel):
|
||||||
|
"""Create a new workflow definition."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[WorkflowStep] = Field(..., min_length=1)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowUpdate(BaseModel):
|
||||||
|
"""Update an existing workflow definition."""
|
||||||
|
name: str | None = Field(None, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[WorkflowStep] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponse(BaseModel):
|
||||||
|
"""Workflow definition response."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[dict]
|
||||||
|
is_active: bool
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowListResponse(BaseModel):
|
||||||
|
"""Paginated workflow list."""
|
||||||
|
items: list[WorkflowResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceCreate(BaseModel):
|
||||||
|
"""Create a new workflow instance."""
|
||||||
|
context: dict = Field(default_factory=dict)
|
||||||
|
timeout_hours: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceResponse(BaseModel):
|
||||||
|
"""Workflow instance response with current state and history."""
|
||||||
|
id: str
|
||||||
|
workflow_id: str
|
||||||
|
status: str
|
||||||
|
current_step_index: int
|
||||||
|
context: dict
|
||||||
|
initiated_by: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
timeout_hours: int | None = None
|
||||||
|
timeout_at: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceDetailResponse(InstanceResponse):
|
||||||
|
"""Instance with step history."""
|
||||||
|
history: list[dict] = Field(default_factory=list)
|
||||||
|
workflow_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceListResponse(BaseModel):
|
||||||
|
"""Paginated instance list."""
|
||||||
|
items: list[InstanceResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdvanceRequest(BaseModel):
|
||||||
|
"""Advance or reject a workflow instance step."""
|
||||||
|
decision: str = Field(..., pattern="^(approve|reject)$")
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StepHistoryResponse(BaseModel):
|
||||||
|
"""Step history entry."""
|
||||||
|
id: str
|
||||||
|
instance_id: str
|
||||||
|
step_index: int
|
||||||
|
step_type: str
|
||||||
|
action: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
details: dict | None = None
|
||||||
|
created_at: str | None = None
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Service layer package."""
|
||||||
|
|
||||||
|
from app.services.plugin_service import PluginService, get_plugin_service
|
||||||
|
from app.services.ai_copilot_service import (
|
||||||
|
process_query as copilot_process_query,
|
||||||
|
execute_action as copilot_execute_action,
|
||||||
|
get_history as copilot_get_history,
|
||||||
|
)
|
||||||
|
from app.services.workflow_service import (
|
||||||
|
create_workflow, list_workflows, get_workflow,
|
||||||
|
update_workflow, delete_workflow,
|
||||||
|
create_instance, list_instances, get_instance,
|
||||||
|
advance_instance, cancel_instance,
|
||||||
|
check_timeout, auto_reject_timeout,
|
||||||
|
find_workflows_for_event, start_instance_for_event,
|
||||||
|
)
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, desc, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.ai.llm_client import get_llm_client
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.auth import check_permission, filter_fields_by_permission
|
||||||
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.models.workflow import Workflow
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_iso(dt) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr(obj, name, default=None):
|
||||||
|
try:
|
||||||
|
val = getattr(obj, name)
|
||||||
|
return val if val is not None else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(c.id),
|
||||||
|
"title": c.title,
|
||||||
|
"context": c.context,
|
||||||
|
"created_at": _safe_iso(_get_attr(c, "created_at")),
|
||||||
|
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content,
|
||||||
|
"proposed_actions": m.proposed_actions,
|
||||||
|
"executed_action": m.executed_action,
|
||||||
|
"execution_result": m.execution_result,
|
||||||
|
"created_at": _safe_iso(_get_attr(m, "created_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_query(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
query: str,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a natural language query and return proposed actions.
|
||||||
|
|
||||||
|
1. Get or create conversation
|
||||||
|
2. Store user message
|
||||||
|
3. Call LLM client (mock or real) to get proposed actions
|
||||||
|
4. Store assistant message with proposed actions
|
||||||
|
5. Return response with conversation_id and proposed_actions
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
|
||||||
|
# Get or create conversation
|
||||||
|
if conversation_id:
|
||||||
|
conv_uuid = uuid.UUID(conversation_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIConversation).where(
|
||||||
|
AIConversation.id == conv_uuid,
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return {"error": "Conversation not found", "status_code": 404}
|
||||||
|
else:
|
||||||
|
conversation = AIConversation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=query[:100] if query else "Untitled",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
db.add(conversation)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(conversation)
|
||||||
|
|
||||||
|
# Get next message index
|
||||||
|
count_q = select(func.count()).select_from(
|
||||||
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_q)
|
||||||
|
msg_index = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Store user message
|
||||||
|
user_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="user",
|
||||||
|
content=query,
|
||||||
|
message_index=msg_index,
|
||||||
|
)
|
||||||
|
db.add(user_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user_msg)
|
||||||
|
|
||||||
|
# Call LLM client
|
||||||
|
llm = get_llm_client()
|
||||||
|
llm_response = await llm.generate(query, context)
|
||||||
|
|
||||||
|
# Store assistant message
|
||||||
|
assistant_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=llm_response.message,
|
||||||
|
proposed_actions=llm_response.proposed_actions,
|
||||||
|
message_index=msg_index + 1,
|
||||||
|
)
|
||||||
|
db.add(assistant_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(assistant_msg)
|
||||||
|
|
||||||
|
# Log to audit
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="query",
|
||||||
|
entity_type="ai_copilot",
|
||||||
|
entity_id=conversation.id,
|
||||||
|
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": str(conversation.id),
|
||||||
|
"message": llm_response.message,
|
||||||
|
"proposed_actions": llm_response.proposed_actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
role: str,
|
||||||
|
conversation_id: str,
|
||||||
|
action: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute a proposed action with RBAC enforcement.
|
||||||
|
|
||||||
|
1. Validate conversation belongs to tenant
|
||||||
|
2. Check RBAC permissions for the action
|
||||||
|
3. Execute the action (direct DB or API call)
|
||||||
|
4. Store execution result in message
|
||||||
|
5. Log to audit
|
||||||
|
"""
|
||||||
|
conv_uuid = uuid.UUID(conversation_id)
|
||||||
|
|
||||||
|
# Validate conversation ownership
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIConversation).where(
|
||||||
|
AIConversation.id == conv_uuid,
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return {"error": "Conversation not found", "status_code": 404}
|
||||||
|
|
||||||
|
method = action.get("method", "GET").upper()
|
||||||
|
path = action.get("path", "")
|
||||||
|
body = action.get("body") or {}
|
||||||
|
|
||||||
|
# Determine module and action_type from path for RBAC
|
||||||
|
module, action_type = _derive_rbac_from_path(method, path)
|
||||||
|
|
||||||
|
if not check_permission(role, module, action_type):
|
||||||
|
return {
|
||||||
|
"error": "Insufficient permissions for this action",
|
||||||
|
"status_code": 403,
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the action
|
||||||
|
try:
|
||||||
|
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body)
|
||||||
|
except Exception as exc:
|
||||||
|
exec_result = {"error": str(exc), "status_code": 500}
|
||||||
|
|
||||||
|
# Get next message index
|
||||||
|
count_q = select(func.count()).select_from(
|
||||||
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_q)
|
||||||
|
msg_index = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Store execution message
|
||||||
|
exec_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=f"Executed {method} {path}",
|
||||||
|
executed_action=action,
|
||||||
|
execution_result=exec_result,
|
||||||
|
message_index=msg_index,
|
||||||
|
)
|
||||||
|
db.add(exec_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(exec_msg)
|
||||||
|
|
||||||
|
# Log to audit
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="execute",
|
||||||
|
entity_type="ai_copilot",
|
||||||
|
entity_id=conversation.id,
|
||||||
|
changes={"action": action, "result": exec_result},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": str(conversation.id),
|
||||||
|
"success": exec_result.get("success", True),
|
||||||
|
"status_code": exec_result.get("status_code", 200),
|
||||||
|
"data": exec_result.get("data"),
|
||||||
|
"error": exec_result.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get paginated conversation history for the current user."""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(AIConversation).where(
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
AIConversation.user_id == user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.order_by(desc(AIConversation.created_at)).offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
conversations = result.scalars().all()
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for conv in conversations:
|
||||||
|
# Get messages for each conversation
|
||||||
|
msg_q = select(AIMessage).where(
|
||||||
|
AIMessage.conversation_id == conv.id,
|
||||||
|
AIMessage.tenant_id == tenant_id,
|
||||||
|
).order_by(AIMessage.message_index)
|
||||||
|
msg_result = await db.execute(msg_q)
|
||||||
|
messages = msg_result.scalars().all()
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
**_conversation_to_dict(conv),
|
||||||
|
"messages": [_message_to_dict(m) for m in messages],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_api_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute an API action directly against the database.
|
||||||
|
|
||||||
|
Supports companies and contacts CRUD, plus workflow listing.
|
||||||
|
"""
|
||||||
|
# Parse path to determine entity and operation
|
||||||
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||||
|
entity = parts[0] if parts else ""
|
||||||
|
entity_id = parts[1] if len(parts) > 1 else None
|
||||||
|
|
||||||
|
if entity == "companies":
|
||||||
|
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
elif entity == "contacts":
|
||||||
|
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
elif entity == "workflows":
|
||||||
|
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
else:
|
||||||
|
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_companies(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute company operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(c.id), "name": c.name, "industry": c.industry}
|
||||||
|
for c in companies
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "POST":
|
||||||
|
company = Company(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.get("name", "Untitled"),
|
||||||
|
industry=body.get("industry"),
|
||||||
|
phone=body.get("phone"),
|
||||||
|
email=body.get("email"),
|
||||||
|
website=body.get("website"),
|
||||||
|
description=body.get("description"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 201,
|
||||||
|
"data": {"id": str(company.id), "name": company.name},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "DELETE":
|
||||||
|
if not entity_id or entity_id == "{id}":
|
||||||
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||||
|
comp_uuid = uuid.UUID(entity_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.id == comp_uuid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||||
|
company.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
return {"success": True, "status_code": 200, "data": {"id": str(company.id), "deleted": True}}
|
||||||
|
|
||||||
|
elif method == "PATCH":
|
||||||
|
if not entity_id or entity_id == "{id}":
|
||||||
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||||
|
comp_uuid = uuid.UUID(entity_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.id == comp_uuid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||||
|
for key in ("name", "industry", "phone", "email", "website", "description"):
|
||||||
|
if key in body:
|
||||||
|
setattr(company, key, body[key])
|
||||||
|
company.updated_by = user_id
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_contacts(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute contact operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contacts = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(c.id), "name": c.name, "email": c.email}
|
||||||
|
for c in contacts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "POST":
|
||||||
|
contact = Contact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.get("name", "Untitled"),
|
||||||
|
email=body.get("email"),
|
||||||
|
phone=body.get("phone"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(contact)
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 201,
|
||||||
|
"data": {"id": str(contact.id), "name": contact.name},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_workflows(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute workflow operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
Workflow.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflows = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
|
||||||
|
for w in workflows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
|
||||||
|
"""Derive module and action_type from HTTP method and path for RBAC.
|
||||||
|
|
||||||
|
Returns (module, action_type) suitable for check_permission().
|
||||||
|
"""
|
||||||
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||||
|
entity = parts[0] if parts else ""
|
||||||
|
|
||||||
|
method_to_action = {
|
||||||
|
"GET": "read",
|
||||||
|
"POST": "create",
|
||||||
|
"PATCH": "update",
|
||||||
|
"DELETE": "delete",
|
||||||
|
}
|
||||||
|
action_type = method_to_action.get(method, "read")
|
||||||
|
|
||||||
|
# Map path entities to permission modules
|
||||||
|
entity_to_module = {
|
||||||
|
"companies": "companies",
|
||||||
|
"contacts": "contacts",
|
||||||
|
"workflows": "workflows",
|
||||||
|
"ai": "ai_copilot",
|
||||||
|
}
|
||||||
|
module = entity_to_module.get(entity, entity)
|
||||||
|
|
||||||
|
return module, action_type
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Authentication service — login, logout, password reset, session management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.auth import (
|
||||||
|
create_session, get_session_data, hash_password, hash_token,
|
||||||
|
invalidate_session, update_session_tenant, verify_password,
|
||||||
|
)
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.models.auth import PasswordResetToken
|
||||||
|
from app.models.user import User, UserTenant
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Handles authentication operations."""
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
tenant_slug: str | None = None,
|
||||||
|
) -> tuple[str, str, User, Tenant] | None:
|
||||||
|
"""Authenticate user and create session.
|
||||||
|
Returns (session_id, csrf_token, user, tenant) or None.
|
||||||
|
"""
|
||||||
|
# Find user by email — need to check across tenants or use default tenant
|
||||||
|
q = select(User).where(User.email == email, User.is_active == True)
|
||||||
|
result = await db.execute(q)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get user's default tenant or the one matching slug
|
||||||
|
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||||
|
if tenant_slug:
|
||||||
|
ut_q = ut_q.join(Tenant, UserTenant.tenant_id == Tenant.id).where(
|
||||||
|
Tenant.slug == tenant_slug
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ut_q = ut_q.where(UserTenant.is_default == True)
|
||||||
|
ut_result = await db.execute(ut_q)
|
||||||
|
user_tenant = ut_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Fallback: just get first tenant membership
|
||||||
|
if user_tenant is None:
|
||||||
|
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||||
|
ut_result2 = await db.execute(ut_q2)
|
||||||
|
user_tenant = ut_result2.scalar_one_or_none()
|
||||||
|
if user_tenant is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tenant_q = select(Tenant).where(Tenant.id == user_tenant.tenant_id)
|
||||||
|
tenant_result = await db.execute(tenant_q)
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
if tenant is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
|
||||||
|
|
||||||
|
# Log the login in audit trail
|
||||||
|
await log_audit(
|
||||||
|
db, tenant.id, user.id, "login", "user", user.id,
|
||||||
|
changes={"email": email},
|
||||||
|
)
|
||||||
|
|
||||||
|
return session_id, csrf_token, user, tenant
|
||||||
|
|
||||||
|
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
|
||||||
|
"""Invalidate a session."""
|
||||||
|
await invalidate_session(redis, session_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_current_user_info(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
session_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get current user info from session."""
|
||||||
|
session_data = await get_session_data(redis, session_id)
|
||||||
|
if session_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch tenant name
|
||||||
|
tenant_q = select(Tenant).where(Tenant.id == uuid.UUID(session_data["tenant_id"]))
|
||||||
|
tenant_result = await db.execute(tenant_q)
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": session_data["user_id"],
|
||||||
|
"email": session_data["email"],
|
||||||
|
"name": session_data["name"],
|
||||||
|
"role": session_data["role"],
|
||||||
|
"tenant_id": session_data["tenant_id"],
|
||||||
|
"tenant_name": tenant.name if tenant else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def switch_tenant(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
session_id: str,
|
||||||
|
new_tenant_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Switch the active tenant for the current session."""
|
||||||
|
session_data = await get_session_data(redis, session_id)
|
||||||
|
if session_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = uuid.UUID(session_data["user_id"])
|
||||||
|
|
||||||
|
# Verify user is member of target tenant
|
||||||
|
ut_q = select(UserTenant).where(
|
||||||
|
UserTenant.user_id == user_id,
|
||||||
|
UserTenant.tenant_id == new_tenant_id,
|
||||||
|
)
|
||||||
|
ut_result = await db.execute(ut_q)
|
||||||
|
if ut_result.scalar_one_or_none() is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
updated = await update_session_tenant(redis, session_id, new_tenant_id)
|
||||||
|
if updated is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch tenant name
|
||||||
|
tenant_q = select(Tenant).where(Tenant.id == new_tenant_id)
|
||||||
|
tenant_result = await db.execute(tenant_q)
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
updated["tenant_name"] = tenant.name if tenant else None
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def request_password_reset(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
tenant_id: uuid.UUID | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Create a password reset token. Always returns True (no user enumeration)."""
|
||||||
|
q = select(User).where(User.email == email, User.is_active == True)
|
||||||
|
result = await db.execute(q)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return True # Don't reveal whether email exists
|
||||||
|
|
||||||
|
# Invalidate previous unused tokens
|
||||||
|
prev_q = select(PasswordResetToken).where(
|
||||||
|
PasswordResetToken.user_id == user.id,
|
||||||
|
PasswordResetToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
prev_result = await db.execute(prev_q)
|
||||||
|
for prev_token in prev_result.scalars().all():
|
||||||
|
prev_token.used_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
import secrets
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
settings = get_settings()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||||
|
|
||||||
|
reset_token = PasswordResetToken(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(reset_token)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# In production: send email via SMTP. For now, log it.
|
||||||
|
# The raw_token would be in the email link.
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def confirm_password_reset(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
token: str,
|
||||||
|
new_password: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Reset password using a valid token. Returns True on success."""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
q = select(PasswordResetToken).where(
|
||||||
|
PasswordResetToken.token_hash == token_hash,
|
||||||
|
PasswordResetToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
reset_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if reset_token is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if reset_token.expires_at < datetime.now(timezone.utc):
|
||||||
|
return False # Token expired
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user_q = select(User).where(User.id == reset_token.user_id)
|
||||||
|
user_result = await db.execute(user_q)
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
reset_token.used_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
|
||||||
|
"""Get the raw (unhashed) reset token for testing purposes.
|
||||||
|
This simulates what would be sent via email.
|
||||||
|
"""
|
||||||
|
# This is a test helper — in production the token goes via email only
|
||||||
|
import secrets
|
||||||
|
q = select(User).where(User.email == email)
|
||||||
|
result = await db.execute(q)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
settings = get_settings()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||||
|
|
||||||
|
reset_token = PasswordResetToken(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(reset_token)
|
||||||
|
await db.flush()
|
||||||
|
return raw_token
|
||||||
|
|
||||||
|
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
|
||||||
|
"""Create an already-expired reset token for testing."""
|
||||||
|
import secrets
|
||||||
|
q = select(User).where(User.email == email)
|
||||||
|
result = await db.execute(q)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
|
||||||
|
|
||||||
|
reset_token = PasswordResetToken(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(reset_token)
|
||||||
|
await db.flush()
|
||||||
|
return raw_token
|
||||||
|
|
||||||
|
|
||||||
|
auth_service = AuthService()
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""Company service — CRUD, FTS search, filter, pagination, soft-delete, N:M, export."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, or_, desc, asc, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit, log_deletion
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.contact import Contact, CompanyContact
|
||||||
|
|
||||||
|
|
||||||
|
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
|
||||||
|
"""Serialize a Company ORM object to dict."""
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"id": str(c.id),
|
||||||
|
"name": c.name,
|
||||||
|
"account_number": c.account_number,
|
||||||
|
"industry": c.industry,
|
||||||
|
"phone": c.phone,
|
||||||
|
"email": c.email,
|
||||||
|
"website": c.website,
|
||||||
|
"description": c.description,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||||
|
}
|
||||||
|
if include_contacts:
|
||||||
|
data["contacts"] = [] # populated by caller if needed
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def list_companies(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
search: str | None = None,
|
||||||
|
industry: str | None = None,
|
||||||
|
sort_by: str = "name",
|
||||||
|
sort_order: str = "asc",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List companies with pagination, FTS search, industry filter, and sorting.
|
||||||
|
|
||||||
|
Returns {items, total, page, page_size}.
|
||||||
|
"""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(Company).where(
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Industry filter
|
||||||
|
if industry:
|
||||||
|
base = base.where(Company.industry == industry)
|
||||||
|
|
||||||
|
# FTS search using PostgreSQL tsvector @@ plainto_tsquery, with ILIKE fallback
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search}%"
|
||||||
|
base = base.where(
|
||||||
|
or_(
|
||||||
|
Company.search_tsv.op("@@")(
|
||||||
|
func.plainto_tsquery("english", search)
|
||||||
|
),
|
||||||
|
Company.name.ilike(pattern),
|
||||||
|
Company.industry.ilike(pattern),
|
||||||
|
Company.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_col = getattr(Company, sort_by, Company.name)
|
||||||
|
if sort_order == "desc":
|
||||||
|
base = base.order_by(desc(sort_col))
|
||||||
|
else:
|
||||||
|
base = base.order_by(asc(sort_col))
|
||||||
|
|
||||||
|
# Count total (without pagination)
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [_company_to_dict(c) for c in companies],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_company_detail(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get a single company with its contacts array."""
|
||||||
|
q = select(Company).where(
|
||||||
|
Company.id == company_id,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = _company_to_dict(company, include_contacts=True)
|
||||||
|
# Fetch linked contacts
|
||||||
|
contacts_q = (
|
||||||
|
select(Contact, CompanyContact)
|
||||||
|
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
|
||||||
|
.where(
|
||||||
|
CompanyContact.company_id == company_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contacts_result = await db.execute(contacts_q)
|
||||||
|
contacts_list = []
|
||||||
|
for contact, link in contacts_result.all():
|
||||||
|
contacts_list.append({
|
||||||
|
"id": str(contact.id),
|
||||||
|
"first_name": contact.first_name,
|
||||||
|
"last_name": contact.last_name,
|
||||||
|
"email": contact.email,
|
||||||
|
"phone": contact.phone,
|
||||||
|
"position": contact.position,
|
||||||
|
"role_at_company": link.role_at_company,
|
||||||
|
"is_primary": link.is_primary,
|
||||||
|
})
|
||||||
|
data["contacts"] = contacts_list
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def create_company(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new company and audit-log it."""
|
||||||
|
company = Company(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=data["name"],
|
||||||
|
account_number=data.get("account_number"),
|
||||||
|
industry=data.get("industry"),
|
||||||
|
phone=data.get("phone"),
|
||||||
|
email=data.get("email"),
|
||||||
|
website=data.get("website"),
|
||||||
|
description=data.get("description"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(company)
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "company", company.id,
|
||||||
|
changes={"name": data["name"]},
|
||||||
|
)
|
||||||
|
return _company_to_dict(company)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_company(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update a company (partial update) and audit-log changes."""
|
||||||
|
q = select(Company).where(
|
||||||
|
Company.id == company_id,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
changes: dict[str, Any] = {}
|
||||||
|
for field in ("name", "account_number", "industry", "phone", "email", "website", "description"):
|
||||||
|
if field in data and data[field] is not None:
|
||||||
|
old_val = getattr(company, field)
|
||||||
|
changes[field] = {"old": old_val, "new": data[field]}
|
||||||
|
setattr(company, field, data[field])
|
||||||
|
company.updated_by = user_id
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(company)
|
||||||
|
await log_audit(db, tenant_id, user_id, "update", "company", company_id, changes=changes)
|
||||||
|
return _company_to_dict(company)
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_company(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
cascade: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Soft-delete a company. If cascade=True, also soft-delete linked CompanyContact rows."""
|
||||||
|
q = select(Company).where(
|
||||||
|
Company.id == company_id,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
company.deleted_at = datetime.now(timezone.utc)
|
||||||
|
company.updated_by = user_id
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
# Delete N:M links for this company
|
||||||
|
await db.execute(
|
||||||
|
delete(CompanyContact).where(
|
||||||
|
CompanyContact.company_id == company_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "delete", "company", company_id,
|
||||||
|
changes={"name": company.name, "cascade": cascade},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def link_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
role_at_company: str | None = None,
|
||||||
|
is_primary: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Link a contact to a company (N:M). Returns link data or None if either not found."""
|
||||||
|
# Verify company exists and is in tenant
|
||||||
|
comp_q = select(Company).where(
|
||||||
|
Company.id == company_id,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
comp_result = await db.execute(comp_q)
|
||||||
|
if comp_result.scalar_one_or_none() is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify contact exists and is in tenant
|
||||||
|
cont_q = select(Contact).where(
|
||||||
|
Contact.id == contact_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
cont_result = await db.execute(cont_q)
|
||||||
|
if cont_result.scalar_one_or_none() is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if link already exists
|
||||||
|
existing_q = select(CompanyContact).where(
|
||||||
|
CompanyContact.company_id == company_id,
|
||||||
|
CompanyContact.contact_id == contact_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
existing_result = await db.execute(existing_q)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
# Update existing link
|
||||||
|
existing.role_at_company = role_at_company
|
||||||
|
existing.is_primary = is_primary
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "link", "company_contact", existing.id,
|
||||||
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": str(existing.id),
|
||||||
|
"company_id": str(company_id),
|
||||||
|
"contact_id": str(contact_id),
|
||||||
|
"role_at_company": role_at_company,
|
||||||
|
"is_primary": is_primary,
|
||||||
|
}
|
||||||
|
|
||||||
|
link = CompanyContact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
contact_id=contact_id,
|
||||||
|
role_at_company=role_at_company,
|
||||||
|
is_primary=is_primary,
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "link", "company_contact", link.id,
|
||||||
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": str(link.id),
|
||||||
|
"company_id": str(company_id),
|
||||||
|
"contact_id": str(contact_id),
|
||||||
|
"role_at_company": role_at_company,
|
||||||
|
"is_primary": is_primary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def unlink_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""Unlink a contact from a company (N:M)."""
|
||||||
|
q = select(CompanyContact).where(
|
||||||
|
CompanyContact.company_id == company_id,
|
||||||
|
CompanyContact.contact_id == contact_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
link = result.scalar_one_or_none()
|
||||||
|
if link is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(link)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "unlink", "company_contact", link.id,
|
||||||
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def export_companies_csv(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
industry: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Export companies as CSV string."""
|
||||||
|
base = select(Company).where(
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if industry:
|
||||||
|
base = base.where(Company.industry == industry)
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search}%"
|
||||||
|
base = base.where(
|
||||||
|
or_(
|
||||||
|
Company.search_tsv.op("@@")(
|
||||||
|
func.plainto_tsquery("english", search)
|
||||||
|
),
|
||||||
|
Company.name.ilike(pattern),
|
||||||
|
Company.industry.ilike(pattern),
|
||||||
|
Company.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base = base.order_by(Company.name)
|
||||||
|
result = await db.execute(base)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["id", "name", "account_number", "industry", "phone", "email", "website", "description"])
|
||||||
|
for c in companies:
|
||||||
|
writer.writerow([
|
||||||
|
str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||||
|
c.phone or "", c.email or "", c.website or "", c.description or "",
|
||||||
|
])
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def export_companies_xlsx(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
industry: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Export companies as XLSX bytes."""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
base = select(Company).where(
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if industry:
|
||||||
|
base = base.where(Company.industry == industry)
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search}%"
|
||||||
|
base = base.where(
|
||||||
|
or_(
|
||||||
|
Company.search_tsv.op("@@")(
|
||||||
|
func.plainto_tsquery("english", search)
|
||||||
|
),
|
||||||
|
Company.name.ilike(pattern),
|
||||||
|
Company.industry.ilike(pattern),
|
||||||
|
Company.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base = base.order_by(Company.name)
|
||||||
|
result = await db.execute(base)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Companies"
|
||||||
|
headers = ["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
|
||||||
|
ws.append(headers)
|
||||||
|
for c in companies:
|
||||||
|
ws.append([
|
||||||
|
str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||||
|
c.phone or "", c.email or "", c.website or "", c.description or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_company_emails(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
||||||
|
# Verify company exists
|
||||||
|
q = select(Company).where(
|
||||||
|
Company.id == company_id,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
return [] # Caller should check existence separately
|
||||||
|
return []
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, desc, asc, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit, log_deletion
|
||||||
|
from app.models.contact import Contact, CompanyContact
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
|
||||||
|
"""Serialize a Contact ORM object to dict."""
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"id": str(c.id),
|
||||||
|
"first_name": c.first_name,
|
||||||
|
"last_name": c.last_name,
|
||||||
|
"email": c.email,
|
||||||
|
"phone": c.phone,
|
||||||
|
"mobile": c.mobile,
|
||||||
|
"position": c.position,
|
||||||
|
"department": c.department,
|
||||||
|
"linkedin_url": c.linkedin_url,
|
||||||
|
"notes": c.notes,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||||
|
}
|
||||||
|
if include_companies:
|
||||||
|
data["companies"] = []
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def list_contacts(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
search: str | None = None,
|
||||||
|
sort_by: str = "last_name",
|
||||||
|
sort_order: str = "asc",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List contacts with pagination and optional search."""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search}%"
|
||||||
|
base = base.where(
|
||||||
|
(Contact.first_name.ilike(pattern))
|
||||||
|
| (Contact.last_name.ilike(pattern))
|
||||||
|
| (Contact.email.ilike(pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
sort_col = getattr(Contact, sort_by, Contact.last_name)
|
||||||
|
if sort_order == "desc":
|
||||||
|
base = base.order_by(desc(sort_col))
|
||||||
|
else:
|
||||||
|
base = base.order_by(asc(sort_col))
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
contacts = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [_contact_to_dict(c) for c in contacts],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_contact_detail(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get a single contact with its linked companies array."""
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.id == contact_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = _contact_to_dict(contact, include_companies=True)
|
||||||
|
companies_q = (
|
||||||
|
select(Company, CompanyContact)
|
||||||
|
.join(CompanyContact, CompanyContact.company_id == Company.id)
|
||||||
|
.where(
|
||||||
|
CompanyContact.contact_id == contact_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
companies_result = await db.execute(companies_q)
|
||||||
|
companies_list = []
|
||||||
|
for company, link in companies_result.all():
|
||||||
|
companies_list.append({
|
||||||
|
"id": str(company.id),
|
||||||
|
"name": company.name,
|
||||||
|
"industry": company.industry,
|
||||||
|
"role_at_company": link.role_at_company,
|
||||||
|
"is_primary": link.is_primary,
|
||||||
|
})
|
||||||
|
data["companies"] = companies_list
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def create_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new contact, optionally linking to companies via company_ids."""
|
||||||
|
contact = Contact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
first_name=data["first_name"],
|
||||||
|
last_name=data["last_name"],
|
||||||
|
email=data.get("email"),
|
||||||
|
phone=data.get("phone"),
|
||||||
|
mobile=data.get("mobile"),
|
||||||
|
position=data.get("position"),
|
||||||
|
department=data.get("department"),
|
||||||
|
linkedin_url=data.get("linkedin_url"),
|
||||||
|
notes=data.get("notes"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(contact)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Link to companies if company_ids provided
|
||||||
|
company_ids = data.get("company_ids")
|
||||||
|
linked_companies = []
|
||||||
|
if company_ids:
|
||||||
|
for cid_str in company_ids:
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
# Verify company exists in tenant
|
||||||
|
comp_q = select(Company).where(
|
||||||
|
Company.id == cid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
comp_result = await db.execute(comp_q)
|
||||||
|
if comp_result.scalar_one_or_none() is None:
|
||||||
|
continue
|
||||||
|
link = CompanyContact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=cid,
|
||||||
|
contact_id=contact.id,
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
linked_companies.append(str(cid))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await db.refresh(contact)
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "contact", contact.id,
|
||||||
|
changes={"first_name": data["first_name"], "last_name": data["last_name"], "linked_companies": linked_companies},
|
||||||
|
)
|
||||||
|
return _contact_to_dict(contact)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update a contact (partial update) and audit-log changes."""
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.id == contact_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
changes: dict[str, Any] = {}
|
||||||
|
for field in ("first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes"):
|
||||||
|
if field in data and data[field] is not None:
|
||||||
|
old_val = getattr(contact, field)
|
||||||
|
changes[field] = {"old": old_val, "new": data[field]}
|
||||||
|
setattr(contact, field, data[field])
|
||||||
|
contact.updated_by = user_id
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(contact)
|
||||||
|
await log_audit(db, tenant_id, user_id, "update", "contact", contact_id, changes=changes)
|
||||||
|
return _contact_to_dict(contact)
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""Soft-delete a contact (set deleted_at)."""
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.id == contact_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
contact.deleted_at = datetime.now(timezone.utc)
|
||||||
|
contact.updated_by = user_id
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "delete", "contact", contact_id,
|
||||||
|
changes={"first_name": contact.first_name, "last_name": contact.last_name},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def gdpr_hard_delete_contact(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""GDPR hard-delete: physical delete + deletion_log entry with snapshot."""
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.id == contact_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Capture snapshot before deletion
|
||||||
|
snapshot = _contact_to_dict(contact)
|
||||||
|
|
||||||
|
# Delete N:M links first
|
||||||
|
await db.execute(
|
||||||
|
delete(CompanyContact).where(
|
||||||
|
CompanyContact.contact_id == contact_id,
|
||||||
|
CompanyContact.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Physical delete
|
||||||
|
await db.delete(contact)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Deletion log (immutable snapshot)
|
||||||
|
await log_deletion(
|
||||||
|
db, tenant_id, user_id, "contact", contact_id, snapshot,
|
||||||
|
)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Import/export service — CSV import with dry-run preview, CSV/XLSX export."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.services.company_service import _company_to_dict
|
||||||
|
from app.services.contact_service import _contact_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
# Expected CSV columns for each entity type
|
||||||
|
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
|
||||||
|
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv(content: str) -> list[dict[str, str]]:
|
||||||
|
"""Parse CSV content into list of dicts."""
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
return [dict(row) for row in reader]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_row(row: dict[str, str], required: list[str]) -> list[str]:
|
||||||
|
"""Validate a single row. Returns list of error messages (empty if valid)."""
|
||||||
|
errors = []
|
||||||
|
for col in required:
|
||||||
|
val = row.get(col, "").strip()
|
||||||
|
if not val:
|
||||||
|
errors.append(f"Missing required field: {col}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
async def import_companies(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
csv_content: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Import companies from CSV. If dry_run=True, no DB changes are made.
|
||||||
|
|
||||||
|
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
|
||||||
|
"""
|
||||||
|
rows = _parse_csv(csv_content)
|
||||||
|
total = len(rows)
|
||||||
|
valid_rows = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(rows, start=1):
|
||||||
|
row_errors = _validate_row(row, ["name"])
|
||||||
|
if row_errors:
|
||||||
|
for e in row_errors:
|
||||||
|
errors.append({"row": idx, "error": e})
|
||||||
|
else:
|
||||||
|
valid_rows.append(row)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"valid": len(valid_rows),
|
||||||
|
"invalid": len(errors),
|
||||||
|
"errors": errors,
|
||||||
|
"created": [],
|
||||||
|
"dry_run": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for row in valid_rows:
|
||||||
|
company = Company(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=row["name"].strip(),
|
||||||
|
industry=row.get("industry", "").strip() or None,
|
||||||
|
phone=row.get("phone", "").strip() or None,
|
||||||
|
email=row.get("email", "").strip() or None,
|
||||||
|
website=row.get("website", "").strip() or None,
|
||||||
|
description=row.get("description", "").strip() or None,
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "import", "company", company.id,
|
||||||
|
changes={"name": company.name},
|
||||||
|
)
|
||||||
|
created.append(_company_to_dict(company))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"valid": len(valid_rows),
|
||||||
|
"invalid": len(errors),
|
||||||
|
"errors": errors,
|
||||||
|
"created": created,
|
||||||
|
"dry_run": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def import_contacts(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
csv_content: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Import contacts from CSV. If dry_run=True, no DB changes are made.
|
||||||
|
|
||||||
|
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
|
||||||
|
"""
|
||||||
|
rows = _parse_csv(csv_content)
|
||||||
|
total = len(rows)
|
||||||
|
valid_rows = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(rows, start=1):
|
||||||
|
row_errors = _validate_row(row, ["first_name", "last_name"])
|
||||||
|
if row_errors:
|
||||||
|
for e in row_errors:
|
||||||
|
errors.append({"row": idx, "error": e})
|
||||||
|
else:
|
||||||
|
valid_rows.append(row)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"valid": len(valid_rows),
|
||||||
|
"invalid": len(errors),
|
||||||
|
"errors": errors,
|
||||||
|
"created": [],
|
||||||
|
"dry_run": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for row in valid_rows:
|
||||||
|
contact = Contact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
first_name=row["first_name"].strip(),
|
||||||
|
last_name=row["last_name"].strip(),
|
||||||
|
email=row.get("email", "").strip() or None,
|
||||||
|
phone=row.get("phone", "").strip() or None,
|
||||||
|
mobile=row.get("mobile", "").strip() or None,
|
||||||
|
position=row.get("position", "").strip() or None,
|
||||||
|
department=row.get("department", "").strip() or None,
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(contact)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "import", "contact", contact.id,
|
||||||
|
changes={"first_name": contact.first_name, "last_name": contact.last_name},
|
||||||
|
)
|
||||||
|
created.append(_contact_to_dict(contact))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"valid": len(valid_rows),
|
||||||
|
"invalid": len(errors),
|
||||||
|
"errors": errors,
|
||||||
|
"created": created,
|
||||||
|
"dry_run": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def import_csv(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
csv_content: str,
|
||||||
|
entity_type: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generic CSV import dispatcher based on entity_type ('companies' or 'contacts')."""
|
||||||
|
if entity_type == "companies":
|
||||||
|
return await import_companies(db, tenant_id, user_id, csv_content, dry_run=dry_run)
|
||||||
|
elif entity_type == "contacts":
|
||||||
|
return await import_contacts(db, tenant_id, user_id, csv_content, dry_run=dry_run)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"valid": 0,
|
||||||
|
"invalid": 0,
|
||||||
|
"errors": [{"row": 0, "error": f"Unknown entity_type: {entity_type}"}],
|
||||||
|
"created": [],
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def export_contacts_csv(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> str:
|
||||||
|
"""Export contacts as CSV string."""
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
).order_by(Contact.last_name, Contact.first_name)
|
||||||
|
result = await db.execute(q)
|
||||||
|
contacts = result.scalars().all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"])
|
||||||
|
for c in contacts:
|
||||||
|
writer.writerow([
|
||||||
|
str(c.id), c.first_name, c.last_name, c.email or "",
|
||||||
|
c.phone or "", c.mobile or "", c.position or "", c.department or "",
|
||||||
|
])
|
||||||
|
return output.getvalue()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Plugin service layer — business logic for plugin lifecycle operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.plugins.registry import PluginRegistry, get_registry
|
||||||
|
from app.plugins.migration_runner import MigrationValidationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginService:
|
||||||
|
"""Service layer for plugin lifecycle management.
|
||||||
|
|
||||||
|
Wraps the PluginRegistry with audit logging and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, registry: PluginRegistry | None = None) -> None:
|
||||||
|
self._registry = registry or get_registry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registry(self) -> PluginRegistry:
|
||||||
|
return self._registry
|
||||||
|
|
||||||
|
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||||||
|
"""List all discovered and installed plugins with their status."""
|
||||||
|
return await self._registry.list_plugins(db)
|
||||||
|
|
||||||
|
async def install_plugin(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
tenant_id: uuid.UUID | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Install a plugin by name.
|
||||||
|
|
||||||
|
Runs migrations, calls on_install hook, creates DB record.
|
||||||
|
Idempotent: returns existing record if already installed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
record = await self._registry.install(db, name)
|
||||||
|
if tenant_id and user_id:
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="plugin.install",
|
||||||
|
entity_type="plugin",
|
||||||
|
entity_id=getattr(record, "id", None),
|
||||||
|
changes={"name": name, "version": record.version},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": record.name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": record.status,
|
||||||
|
"installed": record.installed,
|
||||||
|
"active": record.active,
|
||||||
|
"message": "Plugin installed successfully",
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc))
|
||||||
|
except MigrationValidationError as exc:
|
||||||
|
raise MigrationValidationError(str(exc))
|
||||||
|
|
||||||
|
async def activate_plugin(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
tenant_id: uuid.UUID | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Activate a plugin by name.
|
||||||
|
|
||||||
|
Registers event listeners and routes. Idempotent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
record = await self._registry.activate(db, name)
|
||||||
|
was_already_active = record.active and record.status == "active"
|
||||||
|
if tenant_id and user_id:
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="plugin.activate",
|
||||||
|
entity_type="plugin",
|
||||||
|
entity_id=getattr(record, "id", None),
|
||||||
|
changes={"name": name, "was_already_active": was_already_active},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": record.name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": record.status,
|
||||||
|
"installed": record.installed,
|
||||||
|
"active": record.active,
|
||||||
|
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc))
|
||||||
|
|
||||||
|
async def deactivate_plugin(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
tenant_id: uuid.UUID | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Deactivate a plugin by name.
|
||||||
|
|
||||||
|
Unregisters event listeners and routes. Idempotent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
record = await self._registry.deactivate(db, name)
|
||||||
|
was_already_inactive = not record.active and record.status == "inactive"
|
||||||
|
if tenant_id and user_id:
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="plugin.deactivate",
|
||||||
|
entity_type="plugin",
|
||||||
|
entity_id=getattr(record, "id", None),
|
||||||
|
changes={"name": name, "was_already_inactive": was_already_inactive},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": record.name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": record.status,
|
||||||
|
"installed": record.installed,
|
||||||
|
"active": record.active,
|
||||||
|
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc))
|
||||||
|
|
||||||
|
async def uninstall_plugin(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
remove_data: bool = False,
|
||||||
|
tenant_id: uuid.UUID | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Uninstall a plugin by name.
|
||||||
|
|
||||||
|
Deactivates, calls on_uninstall hook, optionally drops tables, removes DB record.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
record = await self._registry.uninstall(db, name, remove_data=remove_data)
|
||||||
|
dropped_tables = getattr(record, "dropped_tables", [])
|
||||||
|
if tenant_id and user_id:
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="plugin.uninstall",
|
||||||
|
entity_type="plugin",
|
||||||
|
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"version": record.version,
|
||||||
|
"status": "uninstalled",
|
||||||
|
"installed": False,
|
||||||
|
"active": False,
|
||||||
|
"dropped_tables": dropped_tables,
|
||||||
|
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc))
|
||||||
|
|
||||||
|
def get_manifest_schema(self) -> dict[str, Any]:
|
||||||
|
"""Return the manifest schema documentation."""
|
||||||
|
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
|
||||||
|
return MANIFEST_SCHEMA_DOC.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance
|
||||||
|
_service: PluginService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_service() -> PluginService:
|
||||||
|
"""Get the global plugin service instance."""
|
||||||
|
global _service
|
||||||
|
if _service is None:
|
||||||
|
_service = PluginService()
|
||||||
|
return _service
|
||||||
|
|
||||||
|
|
||||||
|
def reset_plugin_service_for_testing(registry: PluginRegistry | None = None) -> PluginService:
|
||||||
|
"""Create a fresh plugin service for testing."""
|
||||||
|
global _service
|
||||||
|
_service = PluginService(registry=registry)
|
||||||
|
return _service
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Role management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.role import Role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleService:
|
||||||
|
"""Handles role CRUD operations."""
|
||||||
|
|
||||||
|
async def list_roles(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List all roles in a tenant."""
|
||||||
|
q = select(Role).where(Role.tenant_id == tenant_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
roles = result.scalars().all()
|
||||||
|
return [self._role_to_dict(r) for r in roles]
|
||||||
|
|
||||||
|
async def create_role(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
permissions: dict[str, Any],
|
||||||
|
field_permissions: dict[str, Any] | None = None,
|
||||||
|
) -> Role:
|
||||||
|
"""Create a new custom role."""
|
||||||
|
role = Role(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=name,
|
||||||
|
permissions=permissions,
|
||||||
|
field_permissions=field_permissions or {},
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
await db.flush()
|
||||||
|
return role
|
||||||
|
|
||||||
|
async def update_role(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
role_id: uuid.UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
permissions: dict[str, Any] | None = None,
|
||||||
|
field_permissions: dict[str, Any] | None = None,
|
||||||
|
) -> Role | None:
|
||||||
|
"""Update a role."""
|
||||||
|
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
role = result.scalar_one_or_none()
|
||||||
|
if role is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
role.name = name
|
||||||
|
if permissions is not None:
|
||||||
|
role.permissions = permissions
|
||||||
|
if field_permissions is not None:
|
||||||
|
role.field_permissions = field_permissions
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return role
|
||||||
|
|
||||||
|
async def delete_role(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
role_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a role."""
|
||||||
|
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
role = result.scalar_one_or_none()
|
||||||
|
if role is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(role)
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _role_to_dict(self, role: Role) -> dict[str, Any]:
|
||||||
|
"""Convert role to response dict."""
|
||||||
|
return {
|
||||||
|
"id": str(role.id),
|
||||||
|
"name": role.name,
|
||||||
|
"permissions": role.permissions,
|
||||||
|
"field_permissions": role.field_permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
role_service = RoleService()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tenant management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.user import User, UserTenant
|
||||||
|
|
||||||
|
|
||||||
|
class TenantService:
|
||||||
|
"""Handles tenant CRUD and user-tenant membership."""
|
||||||
|
|
||||||
|
async def list_tenants_for_user(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List all tenants a user belongs to."""
|
||||||
|
q = (
|
||||||
|
select(Tenant, UserTenant.is_default)
|
||||||
|
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
|
||||||
|
.where(UserTenant.user_id == user_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"name": t.name,
|
||||||
|
"slug": t.slug,
|
||||||
|
"is_default": is_default,
|
||||||
|
}
|
||||||
|
for t, is_default in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_tenant(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
slug: str,
|
||||||
|
) -> Tenant:
|
||||||
|
"""Create a new tenant."""
|
||||||
|
tenant = Tenant(name=name, slug=slug)
|
||||||
|
db.add(tenant)
|
||||||
|
await db.flush()
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
async def get_tenant(self, db: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
|
||||||
|
"""Get a tenant by ID."""
|
||||||
|
q = select(Tenant).where(Tenant.id == tenant_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_tenant_users(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List users in a tenant."""
|
||||||
|
q = select(User).where(User.tenant_id == tenant_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
users = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(u.id),
|
||||||
|
"email": u.email,
|
||||||
|
"name": u.name,
|
||||||
|
"role": u.role,
|
||||||
|
"is_active": u.is_active,
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
async def assign_user_to_tenant(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> UserTenant:
|
||||||
|
"""Assign a user to a tenant."""
|
||||||
|
ut = UserTenant(
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
db.add(ut)
|
||||||
|
await db.flush()
|
||||||
|
return ut
|
||||||
|
|
||||||
|
|
||||||
|
tenant_service = TenantService()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user