Compare commits
385 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 801743ba11 | |||
| 59fdb614e0 | |||
| b7ad5294a5 | |||
| 49949066d3 | |||
| d87fc4e55c | |||
| 44511a8fd7 | |||
| a7699d3598 | |||
| 1f4a621910 | |||
| 637cfa7940 | |||
| 35e2cc8ff2 | |||
| 80775959db | |||
| 309c7a1d70 | |||
| 8e041538ad | |||
| 3e9d944b83 | |||
| 5dbdf50f39 | |||
| 2506641b32 | |||
| 29b3e1acb9 | |||
| 0fdcdb511c | |||
| 84508b3826 | |||
| 0d59389c40 | |||
| ba86d588b9 | |||
| 40e3ea8876 | |||
| 0d8f26fa2a | |||
| 53695b69ea | |||
| cddc143b05 | |||
| c93ba9d94e | |||
| 5d92ce7b3b | |||
| a10a435662 | |||
| f9048ef073 | |||
| 45bd511831 | |||
| b13ab4975a | |||
| 46a5b2cac4 | |||
| ae46812895 | |||
| 7e3ff9d5c7 | |||
| dfd22916ef | |||
| c50cd58d9e | |||
| 849c21ad59 | |||
| ebf31980cf | |||
| 7df0f5d711 | |||
| a852f2914e | |||
| aaf3142942 | |||
| 1eac7546bc | |||
| fb98e06cec | |||
| daaa88a53d | |||
| 880dd6408c | |||
| dc699bee86 | |||
| e5c8b7beba | |||
| a0477ddac0 | |||
| 51265c29be | |||
| d43cd45aac | |||
| f7f8a302f8 | |||
| 624699bf8d | |||
| 7d80e09226 | |||
| 3be812ea00 | |||
| 85af047bca | |||
| 6189cff376 | |||
| db4701bae7 | |||
| 40cc99af5c | |||
| a0269248b4 | |||
| f6c67a9b6c | |||
| dada44cbe7 | |||
| 3f9132622f | |||
| c2e261dd17 | |||
| b05204db14 | |||
| 57f4f3daca | |||
| c19b08e068 | |||
| 79c3c84681 | |||
| baf4af26b2 | |||
| 05043b123a | |||
| 2e17066031 | |||
| c9d16c51cf | |||
| 3caa08c460 | |||
| 3e3fadac71 | |||
| 4581264935 | |||
| f6d9fc8124 | |||
| 47b74dfa8c | |||
| e3e913f4fb | |||
| 5998127f86 | |||
| b107d25fc2 | |||
| 063e41e995 | |||
| 00edc43e5c | |||
| 1f8c4d5177 | |||
| c48513349e | |||
| d16bd388b1 | |||
| 11b45cffac | |||
| ceb972d771 | |||
| c02fc75421 | |||
| f0bf53f0b3 | |||
| d3618d8365 | |||
| f7d2abf967 | |||
| 37f6868328 | |||
| 33162b5283 | |||
| 1a00fe8e0b | |||
| 70da76c86b | |||
| 16d15bcfbf | |||
| 6c197e1fc5 | |||
| 7f9a2bca50 | |||
| b59289fc6e | |||
| 9f89cb17a0 | |||
| 7f61dfb25b | |||
| 94c7c8fff5 | |||
| 5d708c0905 | |||
| e9b8936091 | |||
| 6555655ecf | |||
| b3dea611b4 | |||
| 72e3756c60 | |||
| e92034f1b6 | |||
| 283409513e | |||
| a614ab337b | |||
| 4e1a414b05 | |||
| c3c3089891 | |||
| eaa4000429 | |||
| 4fe3b2365b | |||
| f348a5fea7 | |||
| 3f8a8c93a7 | |||
| 13e9865e8d | |||
| e59db34a6d | |||
| fbcfbbced6 | |||
| 6264ed4752 | |||
| ea45bab4ad | |||
| c4fa771dd8 | |||
| f1dc99b319 | |||
| 5c31c53b5f | |||
| e635f2cf06 | |||
| 62793a001c | |||
| b540f4b2ab | |||
| 4ab91284c9 | |||
| bca40117e0 | |||
| 333b9aee89 | |||
| a9e7195b93 | |||
| adc86ad980 | |||
| 883e22e9b1 | |||
| 864824d8cd | |||
| 404e085ebd | |||
| d01664b92a | |||
| f79eb9354a | |||
| e6790d9b81 | |||
| 1631b0cd1f | |||
| ce08f2464a | |||
| 2a173c9909 | |||
| 10b1f83fb3 | |||
| 9a20ae5528 | |||
| fbd0324d6b | |||
| 03b386e82c | |||
| e30da26722 | |||
| 5c62f49e6d | |||
| f1040c1749 | |||
| 9bf8157667 | |||
| 36531d24a1 | |||
| 7923f6f79c | |||
| 79d683688d | |||
| d47b7615dd | |||
| f2217104a9 | |||
| 7eae8dbf84 | |||
| 2aeb41a58e | |||
| a63c7138dc | |||
| 6889ba8780 | |||
| 9f6b14d0f9 | |||
| 8c78d5711b | |||
| 8ee88d9b41 | |||
| f3fbb5d1e8 | |||
| 7d86592e54 | |||
| 9e37c41871 | |||
| 13aaab78de | |||
| 43f98e5488 | |||
| 3854a19705 | |||
| 8ad8e252d8 | |||
| 0670fdb437 | |||
| 92ac6229d2 | |||
| 534adc9aaa | |||
| 94c61a439d | |||
| bf5e22f5dc | |||
| df261b1b2c | |||
| 0c9a1e1820 | |||
| 6feca2ba98 | |||
| e8060f6259 | |||
| 33b1597f9f | |||
| dc1321c78b | |||
| 610af39f75 | |||
| a36df3509b | |||
| 44ec84136e | |||
| 6b9beec8cf | |||
| c2ddf34c53 | |||
| 9f9c38906c | |||
| e002272278 | |||
| 240a49321d | |||
| a6e593dc40 | |||
| a29dc58bcd | |||
| 70bbfe93e6 | |||
| e09e33e225 | |||
| 396fdf3c9a | |||
| e50f6a601f | |||
| 59f05de621 | |||
| 1bf776dff5 | |||
| 9866fb2d14 | |||
| db41e60042 | |||
| 4ec2ac9eb5 | |||
| c90c58945a | |||
| a323c706bd | |||
| df57cd389d | |||
| 45ebbee26f | |||
| 40fd633917 | |||
| f888785b39 | |||
| 680557087e | |||
| ff08ea8012 | |||
| a53dcc38d5 | |||
| 06b281ba74 | |||
| 131d761936 | |||
| 8ed6d27885 | |||
| 7ed79d3c1f | |||
| 158feec374 | |||
| 638e3f3e1e | |||
| dbeadd8ab1 | |||
| c760b5961c | |||
| da9be1e2f2 | |||
| 976a0ab55d | |||
| 3622022482 | |||
| 765d6d3ab4 | |||
| c6a727fbab | |||
| 30c2e2d7c8 | |||
| c3cc19da10 | |||
| aa20aba2e7 | |||
| 93a53a43f6 | |||
| 7c6f33983d | |||
| cbb17c4ddd | |||
| 81be3478ff | |||
| d294f22e81 | |||
| 76a1da372f | |||
| 3bbe8ce029 | |||
| 47e4ebcbfb | |||
| e0a5a41a6b | |||
| 371f2a55fe | |||
| 8de35a24a7 | |||
| 0a1ba30ed7 | |||
| 5b0b1e093a | |||
| d50615e870 | |||
| c3595a1bac | |||
| f265eef5ae | |||
| daa7fe805a | |||
| e25a1b4fec | |||
| db97a39133 | |||
| abbe7a18fc | |||
| 3d9b76cea4 | |||
| 60f30d021b | |||
| a4d0f0c35d | |||
| 29410f19d3 | |||
| e7ae0ad5ce | |||
| fd14e0076b | |||
| 25b2581653 | |||
| 0e72d4624d | |||
| b5546ea7bd | |||
| 1baa9481a2 | |||
| 78963f2ca9 | |||
| ae228bb484 | |||
| b231c2d0d3 | |||
| bb36378494 | |||
| 8c04c85d35 | |||
| 4dce01f4b9 | |||
| 02b040a57b | |||
| 7a81a5f072 | |||
| a3a26d1f66 | |||
| 211242a807 | |||
| e9164979b5 | |||
| e3ca3b3d28 | |||
| 3d8210637e | |||
| 4cb2712c5a | |||
| 20a7ee2ad1 | |||
| 8e4a85b683 | |||
| e24a64bbab | |||
| f8423def8b | |||
| 7c648e41c1 | |||
| fb444d88c6 | |||
| 42e97ebce0 | |||
| 30454e1a5f | |||
| 5d1b2396a7 | |||
| 1b1cbc05dd | |||
| 1ed97d6727 | |||
| d08e09a3bb | |||
| fdabd2e74c | |||
| 8d2aa58665 | |||
| 05ac3d96cc | |||
| 935946e6db | |||
| c2a15fb9cb | |||
| fde2b0c756 | |||
| 0c985818b1 | |||
| 34d3ea2607 | |||
| 2ebc64be47 | |||
| 1167644824 | |||
| 47aa42ed09 | |||
| b430ae97a5 | |||
| 0f4c872c72 | |||
| e9f990b039 | |||
| 5ac6fb36de | |||
| c78d9a5c7f | |||
| 00420ad165 | |||
| 7c8f2a2222 | |||
| 19ecc0cd71 | |||
| 5dc878dfb1 | |||
| aab2f3d898 | |||
| 20288da567 | |||
| 0eb6d7621e | |||
| 9f79107fa7 | |||
| 627360113f | |||
| 8060505baa | |||
| a0c7a80381 | |||
| 04d6562f5b | |||
| 67015ef82b | |||
| bf60e8090a | |||
| 5051ffd40f | |||
| 5b7d93cd0e | |||
| 85fcb90b32 | |||
| 6631615bef | |||
| 0ae8db4932 | |||
| 407c373173 | |||
| acbf144329 | |||
| 4b41b4f7af | |||
| eb074bfb4d | |||
| 48e6b15bb2 | |||
| b3133abbc1 | |||
| 549c11018c | |||
| 2d25dc35e0 | |||
| c278597757 | |||
| 4b72530566 | |||
| 92d60badd3 | |||
| f15c3bec46 | |||
| b115d8211e | |||
| 16648f543a | |||
| fcc1c92b33 | |||
| 6881e8abde | |||
| 5d408934ba | |||
| b60500d455 | |||
| efba5ceb9c | |||
| 17765e47b4 | |||
| 2bacadabc2 | |||
| 9fd17e7a00 | |||
| 7d976276ae | |||
| 25a97356d8 | |||
| aaf2784a9a | |||
| 157e454fcc | |||
| b77b40c34f | |||
| 000c969b13 | |||
| 597aea1c23 | |||
| cfb4c5ae8b | |||
| f704f7b032 | |||
| a26405f15e | |||
| 247d4165ea | |||
| 0ce3d43ae2 | |||
| 0ebc411fd8 | |||
| 4b00204b63 | |||
| 0d06e73fe5 | |||
| 51c9b467b2 | |||
| 7d3007b6c4 | |||
| e7edc46286 | |||
| 4a104af615 | |||
| 6481996334 | |||
| 51a2c44238 | |||
| 40e9943e69 | |||
| e17b9c9e56 | |||
| 93a330ae40 | |||
| d43407ca77 | |||
| 4f970a11eb | |||
| bd9fc15418 | |||
| f043be44be | |||
| 3622120cd6 | |||
| 662916a8cb | |||
| 5863004727 | |||
| 5d35f0064e | |||
| 67c05f39f1 | |||
| 1271101acd | |||
| 19dd0aa74f | |||
| fe6a4fdd54 | |||
| d5daeb8dfd | |||
| 485fbd9877 | |||
| f4364f30e0 | |||
| 0260f3410d | |||
| 8d82df3076 | |||
| 8b683c7da7 | |||
| 29d55cb187 | |||
| ff975ca0a6 | |||
| 4efdc8e036 | |||
| 8ad0a19f25 | |||
| ea797b033a | |||
| 07d4587499 | |||
| 3eb11b1745 | |||
| a760a759eb |
@@ -1,106 +0,0 @@
|
||||
# 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"}}
|
||||
```
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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"}}
|
||||
```
|
||||
@@ -1,227 +0,0 @@
|
||||
# T04 — DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Share Links)
|
||||
|
||||
## Project Root
|
||||
/a0/usr/workdir/dev-projects/leocrm
|
||||
|
||||
## Context Files (READ FIRST)
|
||||
- `app/plugins/base.py` — BasePlugin abstract class
|
||||
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef
|
||||
- `app/plugins/builtins/tags/` — Reference plugin (subdirectory pattern)
|
||||
- `app/plugins/builtins/permissions/` — Already has share links + file permissions
|
||||
- `app/plugins/builtins/entity_links/` — Links files to companies/contacts
|
||||
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
|
||||
- `app/models/company.py` — Model pattern reference
|
||||
- `app/routes/companies.py` — Route pattern reference
|
||||
- `app/schemas/company.py` — Schema pattern reference
|
||||
- `architecture.md` — Architecture decisions
|
||||
- `requirements.md` — F-DMS-*, F-FILE-*, F-FILEUI-* requirements
|
||||
|
||||
## Overview
|
||||
Implement DMS (Document Management System) plugin as `app/plugins/builtins/dms/`.
|
||||
|
||||
## Plugin Structure
|
||||
```
|
||||
app/plugins/builtins/dms/
|
||||
├── __init__.py # Export DmsPlugin
|
||||
├── plugin.py # DmsPlugin(BasePlugin) with manifest
|
||||
├── models.py # Folder, File models
|
||||
├── schemas.py # Pydantic schemas for all endpoints
|
||||
├── routes.py # FastAPI APIRouter with all endpoints
|
||||
└── migrations/
|
||||
└── 0001_initial.sql # Create folders + files tables
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
### Folder
|
||||
- id (UUID, PK)
|
||||
- name (str, not null)
|
||||
- parent_id (UUID, FK to folders.id, nullable — null = root)
|
||||
- tenant_id (UUID, not null)
|
||||
- created_by (UUID, not null)
|
||||
- deleted_at (datetime, nullable — soft delete)
|
||||
- created_at, updated_at (TimestampMixin)
|
||||
- **Unique constraint**: (name, parent_id, tenant_id) where deleted_at IS NULL
|
||||
|
||||
### File
|
||||
- id (UUID, PK)
|
||||
- name (str, not null)
|
||||
- folder_id (UUID, FK to folders.id, nullable — null = root)
|
||||
- tenant_id (UUID, not null)
|
||||
- uploaded_by (UUID, not null)
|
||||
- mime_type (str, not null)
|
||||
- size_bytes (int, not null)
|
||||
- storage_path (str, not null — relative path on disk)
|
||||
- deleted_at (datetime, nullable — soft delete)
|
||||
- created_at, updated_at (TimestampMixin)
|
||||
|
||||
## File Storage
|
||||
- Store files at: `/data/dms/{tenant_id}/{file_uuid}` (configurable via plugin config)
|
||||
- Use `shutil.copyfileobj` for upload streaming
|
||||
- Generate UUID for filename on disk, keep original name in DB
|
||||
- Create directory with `os.makedirs(path, exist_ok=True)`
|
||||
|
||||
## Endpoints (19 ACs)
|
||||
|
||||
### Folders
|
||||
1. `GET /api/v1/dms/folders` → 200, folder tree (recursive tree structure)
|
||||
- Query param `parent_id` (optional, null = root level)
|
||||
- Returns list of folders with children nested
|
||||
2. `POST /api/v1/dms/folders` → 201, create folder
|
||||
- Body: `{name, parent_id?}`
|
||||
- Returns created folder with full path
|
||||
3. `PATCH /api/v1/dms/folders/{id}` → 200, rename/move folder
|
||||
- Body: `{name?, parent_id?}`
|
||||
4. `DELETE /api/v1/dms/folders/{id}` → 204, soft-delete (set deleted_at)
|
||||
- Cascade: soft-delete all child folders and files
|
||||
|
||||
### Files
|
||||
5. `POST /api/v1/dms/files/upload` → 201, multipart upload
|
||||
- Form fields: `file` (UploadFile), `folder_id?` (optional)
|
||||
- Store file on disk, create metadata record
|
||||
- Max file size: 100MB (configurable)
|
||||
6. `GET /api/v1/dms/files/{id}` → 200, file metadata
|
||||
7. `PATCH /api/v1/dms/files/{id}` → 200, rename/move
|
||||
- Body: `{name?, folder_id?}`
|
||||
8. `DELETE /api/v1/dms/files/{id}` → 204, soft-delete
|
||||
9. `POST /api/v1/dms/files/{id}/restore` → 200, restore from trash
|
||||
|
||||
### Preview & Edit
|
||||
10. `GET /api/v1/dms/files/{id}/preview` → 200, PDF stream
|
||||
- Only for PDF files (mime_type == application/pdf)
|
||||
- Return `StreamingResponse` with `media_type='application/pdf'`
|
||||
- Non-PDF files: return 400
|
||||
11. `POST /api/v1/dms/files/{id}/edit-session` → 200, OnlyOffice config
|
||||
- Return JSON config for OnlyOffice editor:
|
||||
```json
|
||||
{
|
||||
"document": {"fileType": "docx", "key": "<uuid>", "title": "<filename>", "url": "<download_url>"},
|
||||
"editorConfig": {"mode": "edit", "callbackUrl": "<callback_url>", "user": {"id": "<user_id>", "name": "<user_name>"}}
|
||||
}
|
||||
```
|
||||
- Only for Office files (docx, xlsx, pptx)
|
||||
- Non-Office files: return 400
|
||||
|
||||
### Sharing (INTERNAL — different from T11 permissions plugin)
|
||||
**NOTE**: T11 permissions plugin already handles:
|
||||
- `POST /api/v1/dms/files/{id}/share-link` — public share links with token
|
||||
- `GET /api/public/share/{token}` — public access
|
||||
- `POST /api/v1/dms/files/{id}/permissions` — grant permissions
|
||||
|
||||
T04 DMS plugin handles INTERNAL sharing (different endpoints):
|
||||
12. `POST /api/v1/dms/files/{id}/share` → 200, internal share
|
||||
- Body: `{user_ids?: [uuid], group_ids?: [uuid], access_level: 'read'|'write'}`
|
||||
- Creates permission records (reuse permissions plugin Permission model OR DMS-specific)
|
||||
13. `DELETE /api/v1/dms/files/{id}/share` → 204, remove share
|
||||
- Body: `{user_id?: uuid, group_id?: uuid}`
|
||||
|
||||
**IMPORTANT**: For `GET /api/public/share/{token}` (AC14, AC15) — T11 permissions plugin ALREADY implements this endpoint. Do NOT create a duplicate. If T11's endpoint already handles password-protected links (401 without password), then AC14 and AC15 are already satisfied. Verify by reading `app/plugins/builtins/permissions/routes.py`.
|
||||
|
||||
### Search & Bulk
|
||||
14. `GET /api/v1/dms/search?q=text` → 200, matching files
|
||||
- Case-insensitive filename search with ILIKE
|
||||
- Search across all files in tenant (not deleted)
|
||||
15. `GET /api/v1/dms/shared-with-me` → 200, shared files list
|
||||
- Files where user has been granted permission (via permissions plugin)
|
||||
16. `POST /api/v1/dms/files/bulk-move` → 200
|
||||
- Body: `{file_ids: [uuid], target_folder_id: uuid?}`
|
||||
17. `POST /api/v1/dms/files/bulk-delete` → 200
|
||||
- Body: `{file_ids: [uuid]}` — soft-delete all
|
||||
|
||||
## Migration SQL
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_by UUID NOT NULL,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_folders_tenant ON folders(tenant_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
uploaded_by UUID NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_path VARCHAR(1024) NOT NULL,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_tenant ON files(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_name ON files USING gin (to_tsvector('simple', name));
|
||||
```
|
||||
|
||||
## Register Plugin
|
||||
Add to `app/plugins/builtins/__init__.py`:
|
||||
```python
|
||||
from app.plugins.builtins.dms import DmsPlugin
|
||||
__all__.append("DmsPlugin")
|
||||
```
|
||||
|
||||
## Test File
|
||||
Create `tests/test_dms.py` with tests for ALL 19 ACs.
|
||||
|
||||
### Test Patterns
|
||||
- Follow existing test patterns in `tests/test_tags.py`, `tests/test_permissions.py`
|
||||
- Use async test client via `httpx.AsyncClient` with `ASGITransport`
|
||||
- Use existing fixtures from `tests/conftest.py`
|
||||
- For file upload tests: use `httpx.AsyncClient.post` with `files={'file': ('test.pdf', b'%PDF-1.4...', 'application/pdf')}`
|
||||
- For preview tests: verify response status 200 + content-type application/pdf
|
||||
- For OnlyOffice: verify config structure returned
|
||||
- For bulk operations: create multiple files, then bulk-move/bulk-delete
|
||||
|
||||
## Verification Commands
|
||||
```bash
|
||||
cd /a0/usr/workdir/dev-projects/leocrm
|
||||
python -m pytest tests/test_dms.py -v --tb=short
|
||||
python -m pytest tests/test_dms.py --cov=app/plugins/builtins/dms --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Acceptance Criteria (19 — ALL must pass)
|
||||
1. GET /api/v1/dms/folders → 200 + folder tree
|
||||
2. POST /api/v1/dms/folders → 201, folder created with path
|
||||
3. PATCH /api/v1/dms/folders/{id} → 200, folder renamed/moved
|
||||
4. DELETE /api/v1/dms/folders/{id} → 204, soft-delete
|
||||
5. POST /api/v1/dms/files/upload (multipart) → 201, file stored + metadata
|
||||
6. GET /api/v1/dms/files/{id} → 200 + file metadata
|
||||
7. PATCH /api/v1/dms/files/{id} → 200, renamed/moved
|
||||
8. DELETE /api/v1/dms/files/{id} → 204, soft-delete
|
||||
9. POST /api/v1/dms/files/{id}/restore → 200, restored from trash
|
||||
10. GET /api/v1/dms/files/{id}/preview → 200 + PDF stream
|
||||
11. POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config
|
||||
12. POST /api/v1/dms/files/{id}/share → 200, internal share created
|
||||
13. DELETE /api/v1/dms/files/{id}/share → 204, share removed
|
||||
14. GET /api/public/share/{token} → 200 (no auth, public access) — MAY already exist via T11
|
||||
15. GET /api/public/share/{token} mit password → 401 ohne password — MAY already exist via T11
|
||||
16. GET /api/v1/dms/search?q=text → 200 + matching files
|
||||
17. GET /api/v1/dms/shared-with-me → 200 + shared files list
|
||||
18. POST /api/v1/dms/files/bulk-move → 200, files moved
|
||||
19. POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted
|
||||
|
||||
## Rules
|
||||
- No placeholder code. No Lorem Ipsum.
|
||||
- Follow existing patterns exactly (SQLAlchemy 2.0, FastAPI APIRouter, Pydantic v2)
|
||||
- All routes must have tenant_id scoping
|
||||
- Use `# noqa: F401` for __init__.py re-exports
|
||||
- Use `from None` in except blocks (B904)
|
||||
- File storage path: `/data/dms/{tenant_id}/{file_uuid}`
|
||||
- OnlyOffice: generate config only, don't run OnlyOffice server
|
||||
- For AC14/AC15: check if T11 permissions plugin already satisfies these. If yes, write tests that verify existing endpoint. If no, implement in DMS plugin.
|
||||
|
||||
## Deliverables
|
||||
- All plugin files created
|
||||
- Plugin registered in builtins __init__.py
|
||||
- tests/test_dms.py with all 19 ACs tested
|
||||
- All tests passing
|
||||
- Coverage ≥80%
|
||||
- Report: files created, test count + pass/fail, coverage %
|
||||
@@ -1,335 +0,0 @@
|
||||
# T05 — Calendar Plugin Backend Briefing
|
||||
|
||||
## Project Root
|
||||
/a0/usr/workdir/dev-projects/leocrm
|
||||
|
||||
## Context Files (read first)
|
||||
- `app/plugins/base.py` — BasePlugin abstract class
|
||||
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef
|
||||
- `app/plugins/builtins/tags/` — Reference plugin (subdirectory pattern)
|
||||
- `app/plugins/builtins/dms/` — Most recent plugin (complex reference)
|
||||
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
|
||||
- `app/models/company.py` — Model pattern reference
|
||||
- `app/routes/companies.py` — Route pattern reference
|
||||
- `tests/test_dms.py` — Test pattern reference (uses authed_client from conftest)
|
||||
- `tests/conftest.py` — Shared fixtures (dms_app, dms_client, authed_client — adapt for calendar)
|
||||
- `architecture.md` — Calendar tables + endpoints (search for 'Calendar Plugin')
|
||||
|
||||
## Plugin Structure
|
||||
```
|
||||
app/plugins/builtins/calendar/
|
||||
├── __init__.py — Exports CalendarPlugin
|
||||
├── plugin.py — CalendarPlugin(BasePlugin) with manifest
|
||||
├── routes.py — 21 endpoints
|
||||
├── models.py — 7 SQLAlchemy models
|
||||
├── schemas.py — Pydantic schemas
|
||||
├── recurrence.py — Recurrence engine (RRULE-style)
|
||||
├── ics_utils.py — ICS export/import utilities
|
||||
└── migrations/
|
||||
└── 0001_initial.sql — 7 tables
|
||||
```
|
||||
|
||||
## Models (7 tables)
|
||||
|
||||
### Calendar
|
||||
- id (UUID PK), tenant_id, name (str, not null), color (str, default '#3B82F6')
|
||||
- type (str: personal/team/project/company, default 'personal')
|
||||
- owner_id (UUID, not null), created_at, updated_at, deleted_at (soft delete)
|
||||
- Unique: (name, tenant_id) WHERE deleted_at IS NULL
|
||||
|
||||
### CalendarEntry
|
||||
- id (UUID PK), tenant_id, calendar_id (FK→calendars.id)
|
||||
- entry_type (str: appointment/task, not null)
|
||||
- subtype (str: normal/follow_up/private, default 'normal')
|
||||
- title (str, not null), description (TEXT, nullable)
|
||||
- start_at (TIMESTAMPTZ, nullable — for appointments)
|
||||
- end_at (TIMESTAMPTZ, nullable — for appointments)
|
||||
- all_day (bool, default false)
|
||||
- location (str, nullable)
|
||||
- due_date (DATE, nullable — for tasks)
|
||||
- priority (str: low/medium/high, default 'medium')
|
||||
- status (str: open/in_progress/done/cancelled, default 'open')
|
||||
- assigned_to (UUID, nullable — for tasks)
|
||||
- reminder (JSONB, nullable: {value: int, unit: str, channel: str})
|
||||
- recurrence (JSONB, nullable: {pattern: str, custom_rule: str, end_date: date, exceptions: [date]})
|
||||
- source_mail_id (UUID, nullable)
|
||||
- created_by (UUID, not null), created_at, updated_at, deleted_at
|
||||
- Index: (tenant_id, calendar_id), (tenant_id, start_at), (tenant_id, due_date), (tenant_id, assigned_to, status)
|
||||
|
||||
### CalendarEntryLink
|
||||
- id (UUID PK), tenant_id, entry_id (FK→calendar_entries.id)
|
||||
- entity_type (str: company/contact, not null), entity_id (UUID, not null)
|
||||
|
||||
### CalendarShare
|
||||
- id (UUID PK), tenant_id, calendar_id (FK→calendars.id)
|
||||
- user_id (UUID, nullable), group_id (UUID, nullable)
|
||||
- permission (str: read/write, not null)
|
||||
|
||||
### UserCalendarVisibility
|
||||
- user_id (FK→users.id), calendar_id (FK→calendars.id), tenant_id
|
||||
- visible (bool, default true)
|
||||
- PK: (user_id, calendar_id)
|
||||
|
||||
### Subtask
|
||||
- id (UUID PK), tenant_id, entry_id (FK→calendar_entries.id)
|
||||
- title (str, not null), completed (bool, default false)
|
||||
- created_at
|
||||
|
||||
### Resource
|
||||
- id (UUID PK), tenant_id, name (str, not null)
|
||||
- type (str: room/equipment, not null)
|
||||
|
||||
### ResourceBooking
|
||||
- id (UUID PK), tenant_id, resource_id (FK→resources.id)
|
||||
- entry_id (FK→calendar_entries.id), start_at (TIMESTAMPTZ, not null), end_at (TIMESTAMPTZ, not null)
|
||||
|
||||
## Endpoints (21 total)
|
||||
|
||||
### Calendars (6)
|
||||
1. `GET /api/v1/calendars` → 200 + calendar list (filtered by tenant + visibility)
|
||||
2. `POST /api/v1/calendars` → 201, calendar created (name, color, type)
|
||||
3. `PATCH /api/v1/calendars/{id}` → 200, updated (name, color)
|
||||
4. `DELETE /api/v1/calendars/{id}` → 204, cascade delete entries + shares + visibility
|
||||
5. `POST /api/v1/calendars/{id}/share` → 200, calendar shared (user_id/group_id, permission)
|
||||
6. `GET /api/v1/calendars/{id}/permissions` → 200 + permission list
|
||||
|
||||
### Entries (10)
|
||||
7. `GET /api/v1/calendar/entries?start=2026-01-01&end=2026-12-31` → 200 + entries in range
|
||||
8. `POST /api/v1/calendar/entries` (appointment) → 201, entry created with start_at/end_at
|
||||
9. `POST /api/v1/calendar/entries` (task) → 201, entry created with due_date/priority/status
|
||||
10. `GET /api/v1/calendar/entries/{id}` → 200 + entry detail with links+subtasks
|
||||
11. `PATCH /api/v1/calendar/entries/{id}` → 200, updated (drag&drop: PATCH start_at+end_at, or status change)
|
||||
12. `PATCH /api/v1/calendar/entries/{id}` status=done → 200, status updated
|
||||
13. `DELETE /api/v1/calendar/entries/{id}` → 204
|
||||
14. `POST /api/v1/calendar/entries/{id}/link` → 200, linked to company/contact (entity_type, entity_id)
|
||||
15. `POST /api/v1/calendar/entries/{id}/subtasks` → 201, subtask created (title)
|
||||
16. `PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id}` → 200, completed toggled
|
||||
|
||||
### Bulk + Kanban + Export (3)
|
||||
17. `POST /api/v1/calendar/entries/bulk` → 200, bulk status change/delete (entry_ids, action)
|
||||
18. `GET /api/v1/calendar/kanban` → 200 + tasks grouped by status columns (open/in_progress/done/cancelled)
|
||||
19. `GET /api/v1/calendar/entries/export?format=csv` → 200 + CSV stream
|
||||
|
||||
### ICS (2)
|
||||
20. `GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid` → 200 + text/calendar (NO auth, token-based)
|
||||
21. `GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid` → 401
|
||||
22. `POST /api/v1/calendar/import` (multipart .ics file) → 200 + import result (entries_created count)
|
||||
|
||||
### Resources (2)
|
||||
23. `POST /api/v1/resources` → 201 (admin only, 403 for non-admin)
|
||||
24. `POST /api/v1/calendar/entries/{id}/book-resource` → 200, resource booked (resource_id)
|
||||
25. `POST /api/v1/calendar/entries/{id}/book-resource` (conflict) → 409
|
||||
|
||||
## Recurrence Engine
|
||||
- Patterns: daily, weekly, monthly, yearly
|
||||
- Custom rules: e.g. 'every 2nd Tuesday' → store as JSONB {pattern: 'custom', custom_rule: 'BYDAY=TU;BYSETPOS=2'}
|
||||
- Exceptions: array of dates excluded from occurrence generation
|
||||
- Occurrence generation: given a date range query (start, end), generate all occurrence instances
|
||||
- Max 2 years forward for appointments
|
||||
- Tasks: generate next instance on completion (post-completion, not on-the-fly)
|
||||
|
||||
## ICS Export Format
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//LeoCRM//Calendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:<entry_uuid>@leocrm
|
||||
DTSTART:<start_at>
|
||||
DTEND:<end_at>
|
||||
SUMMARY:<title>
|
||||
DESCRIPTION:<description>
|
||||
LOCATION:<location>
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
- Token auth: each calendar has an `ics_token` (generate on first feed request, store in calendar_shares or a separate field)
|
||||
- Invalid token → 401
|
||||
|
||||
## ICS Import
|
||||
- Parse .ics file (VCALENDAR → VEVENT blocks)
|
||||
- Create CalendarEntry per VEVENT
|
||||
- Map: DTSTART→start_at, DTEND→end_at, SUMMARY→title, DESCRIPTION→description, LOCATION→location
|
||||
- Return: {entries_created: N, errors: [...]}
|
||||
- Use Python `icalendar` library if available, else parse manually
|
||||
|
||||
## Reminder System
|
||||
- reminder JSONB: {value: 15, unit: 'minutes', channel: 'in_app'}
|
||||
- When reminder is set on entry creation/update, schedule an ARQ job
|
||||
- ARQ job fires at (start_at - reminder) for appointments, (due_date - reminder) for tasks
|
||||
- Job sends in-app notification via EventBus
|
||||
- For now: just store the reminder config and schedule the ARQ job (don't need to implement the actual notification delivery)
|
||||
|
||||
## Calendar Sharing
|
||||
- Owner can share with user_id or group_id, permission read/write
|
||||
- User with read permission: can view entries, cannot edit (403 on PATCH/POST/DELETE)
|
||||
- User with write permission: can create/edit entries
|
||||
- Private subtype entries: only owner + admin can see (filter in query)
|
||||
|
||||
## Migration SQL
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS calendars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
color VARCHAR(20) DEFAULT '#3B82F6',
|
||||
type VARCHAR(20) DEFAULT 'personal',
|
||||
owner_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX idx_calendars_tenant ON calendars(tenant_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
calendar_id UUID REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
entry_type VARCHAR(15) NOT NULL,
|
||||
subtype VARCHAR(20) DEFAULT 'normal',
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
start_at TIMESTAMPTZ,
|
||||
end_at TIMESTAMPTZ,
|
||||
all_day BOOLEAN DEFAULT false,
|
||||
location VARCHAR(500),
|
||||
due_date DATE,
|
||||
priority VARCHAR(10) DEFAULT 'medium',
|
||||
status VARCHAR(15) DEFAULT 'open',
|
||||
assigned_to UUID,
|
||||
reminder JSONB,
|
||||
recurrence JSONB,
|
||||
source_mail_id UUID,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX idx_entries_tenant_cal ON calendar_entries(tenant_id, calendar_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_entries_tenant_start ON calendar_entries(tenant_id, start_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_entries_tenant_due ON calendar_entries(tenant_id, due_date) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_entries_assigned ON calendar_entries(tenant_id, assigned_to, status) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_entry_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
calendar_id UUID REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
user_id UUID,
|
||||
group_id UUID,
|
||||
permission VARCHAR(10) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_calendar_visibility (
|
||||
user_id UUID NOT NULL,
|
||||
calendar_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
visible BOOLEAN DEFAULT true,
|
||||
PRIMARY KEY (user_id, calendar_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
completed BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
resource_id UUID REFERENCES resources(id) ON DELETE CASCADE,
|
||||
entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Registration
|
||||
Add to `app/plugins/builtins/__init__.py`:
|
||||
```python
|
||||
from app.plugins.builtins.calendar import CalendarPlugin
|
||||
__all__ = [..., "CalendarPlugin"]
|
||||
```
|
||||
|
||||
## Test File: tests/test_calendar.py
|
||||
- Use shared fixtures from conftest.py (adapt: create calendar_app, calendar_client, calendar_authed_client)
|
||||
- OR add calendar fixtures to conftest.py (preferred — same pattern as DMS)
|
||||
- Test ALL 29 ACs
|
||||
- Coverage target: ≥80%
|
||||
- Add `concurrency = ["greenlet"]` already in pyproject.toml (done in T04)
|
||||
|
||||
## Test Patterns
|
||||
- httpx AsyncClient with ASGITransport
|
||||
- `ORIGIN_HEADER` from conftest
|
||||
- `authed_client` returns (client, seed) with admin_a, viewer_a, editor_a
|
||||
- Multipart upload for ICS import: `files={'file': ('test.ics', ics_content, 'text/calendar')}`
|
||||
- ICS feed: no auth header, just `?token=valid_or_invalid`
|
||||
- Recurrence test: create weekly entry, query range, verify occurrences
|
||||
- Resource conflict: create 2 bookings with overlapping time → 409
|
||||
|
||||
## Verification Commands
|
||||
```bash
|
||||
cd /a0/usr/workdir/dev-projects/leocrm
|
||||
python -m pytest tests/test_calendar.py -v --tb=short
|
||||
python -m pytest tests/test_calendar.py --cov=app/plugins/builtins/calendar --cov-report=term-missing
|
||||
ruff check app/plugins/builtins/calendar/ tests/test_calendar.py
|
||||
ruff format --check app/plugins/builtins/calendar/ tests/test_calendar.py
|
||||
```
|
||||
|
||||
## 29 Acceptance Criteria — ALL must pass
|
||||
1. GET /api/v1/calendars → 200 + calendar list
|
||||
2. POST /api/v1/calendars → 201, calendar created
|
||||
3. PATCH /api/v1/calendars/{id} → 200
|
||||
4. DELETE /api/v1/calendars/{id} → 204, cascade delete entries
|
||||
5. POST /api/v1/calendars/{id}/share → 200, calendar shared
|
||||
6. GET /api/v1/calendars/{id}/permissions → 200 + permission list
|
||||
7. GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range
|
||||
8. POST /api/v1/calendar/entries (appointment) → 201, with start_at/end_at
|
||||
9. POST /api/v1/calendar/entries (task) → 201, with due_date/priority/status
|
||||
10. GET /api/v1/calendar/entries/{id} → 200 + detail with links+subtasks
|
||||
11. PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop)
|
||||
12. PATCH /api/v1/calendar/entries/{id} status=done → 200
|
||||
13. DELETE /api/v1/calendar/entries/{id} → 204
|
||||
14. POST /api/v1/calendar/entries/{id}/link → 200, linked
|
||||
15. POST /api/v1/calendar/entries/{id}/subtasks → 201
|
||||
16. PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, toggled
|
||||
17. POST /api/v1/calendar/entries/bulk → 200, bulk status/delete
|
||||
18. GET /api/v1/calendar/kanban → 200 + tasks grouped by status
|
||||
19. GET /api/v1/calendar/entries/export?format=csv → 200 + CSV
|
||||
20. GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar
|
||||
21. GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid → 401
|
||||
22. POST /api/v1/calendar/import mit .ics file → 200 + import result
|
||||
23. POST /api/v1/resources → 201 (admin only, 403 non-admin)
|
||||
24. POST /api/v1/calendar/entries/{id}/book-resource → 200
|
||||
25. POST /api/v1/calendar/entries/{id}/book-resource (conflict) → 409
|
||||
26. Recurrence: weekly entry generates correct occurrences for date range query
|
||||
27. Recurrence: exception date excluded from occurrences
|
||||
28. Reminder: ARQ job scheduled when reminder JSONB set
|
||||
29. Calendar share: user with read permission can view, cannot edit (403)
|
||||
30. Private subtype: only owner+admin can see entry
|
||||
|
||||
## Rules
|
||||
- No `# noqa: F401` for re-exports in routes/models (only in __init__.py)
|
||||
- Use `from None` in except blocks (B904)
|
||||
- No blocking file I/O in async functions without `# noqa: ASYNC230`
|
||||
- Follow existing plugin patterns exactly (see tags/dms plugins)
|
||||
- Tenant scoping on ALL queries
|
||||
- Soft delete for calendars and entries (deleted_at)
|
||||
- ICS feed endpoint: NO auth header, token-based only
|
||||
@@ -1,89 +0,0 @@
|
||||
# T06: Mail Plugin Backend — Implementation Briefing
|
||||
|
||||
## Task
|
||||
Implement the complete Mail Plugin as a built-in plugin under `app/plugins/builtins/mail/`.
|
||||
|
||||
## Requirements (F-MAIL-01 bis F-MAIL-19)
|
||||
- F-MAIL-01: Standard-Ordner (Posteingang, Postausgang, Entwürfe, Spam) + IMAP-Sync
|
||||
- F-MAIL-02: E-Mail schreiben, antworten, weiterleiten (HTML-Editor, SMTP)
|
||||
- F-MAIL-03: Volltext-Suche über Mails (body_tsv, FTS)
|
||||
- F-MAIL-04: Anhänge (hochladen, herunterladen, DMS-Link)
|
||||
- F-MAIL-05: Threading (Konversationen gruppieren, References/In-Reply-To)
|
||||
- F-MAIL-06: Vorlagen/Templates (Platzhalter-Substitution)
|
||||
- F-MAIL-07: Filter/Regeln (Condition → Action: move/label/flag/forward)
|
||||
- F-MAIL-08: Abwesenheitsnotiz (Auto-Reply, dedup via vacation_sent_log)
|
||||
- F-MAIL-09: Labels/Flags (Stern, Wichtig, Custom Labels, farbig)
|
||||
- F-MAIL-10: Kontakt-Verknüpfung (auto aus Email-Adressen, manuell)
|
||||
- F-MAIL-11: Kalender-Integration (Termin aus Mail erstellen)
|
||||
- F-MAIL-12: PGP-Verschlüsselung (Key-Import, encrypt/decrypt, contact public keys)
|
||||
- F-MAIL-13: Signaturen (pro User, pro Postfach, HTML-Content)
|
||||
- F-MAIL-14: Mehrere Postfächer (IMAP/SMTP pro User konfigurierbar)
|
||||
- F-MAIL-15: Geteilte Postfächer (Gruppen-Postfach, Seen-By-Tracking)
|
||||
- F-MAIL-16: Stellvertretung (Delegate access: read/full)
|
||||
- F-MAIL-17: Sende-Berechtigungen (wer darf als Gruppe senden)
|
||||
- F-MAIL-18: Postfach-Konfiguration (IMAP/SMTP, AES-256 encrypted credentials, Verbindungstest)
|
||||
- F-MAIL-19: Mail-Ordner verwalten (Erstellen, Umbenennen, Löschen, IMAP-Sync)
|
||||
|
||||
## Acceptance Criteria (40 ACs)
|
||||
See task_graph.json T06.acceptance_criteria — ALL must pass.
|
||||
|
||||
## Architecture
|
||||
- Plugin Pattern: Follow `app/plugins/builtins/dms/` structure exactly
|
||||
- Files to create:
|
||||
- `app/plugins/builtins/mail/__init__.py`
|
||||
- `app/plugins/builtins/mail/plugin.py` (MailPlugin class, PluginManifest)
|
||||
- `app/plugins/builtins/mail/models.py` (14+ SQLAlchemy models)
|
||||
- `app/plugins/builtins/mail/schemas.py` (Pydantic schemas for all entities)
|
||||
- `app/plugins/builtins/mail/routes.py` (APIRouter with all endpoints)
|
||||
- `app/plugins/builtins/mail/services.py` (Service layer: IMAP sync, SMTP send, rules, vacation, PGP)
|
||||
- `app/plugins/builtins/mail/migrations/0001_initial.sql` (DB migration)
|
||||
- `tests/test_mail.py` (Test all 40 ACs)
|
||||
|
||||
## Models Required
|
||||
mail_accounts, mail_folders, mails, mail_attachments, mail_labels, mail_label_assignments, mail_rules, mail_templates, mail_signatures, vacation_sent_log, mail_seen_by, mail_account_delegates, mail_account_send_permissions, pgp_keys, contact_pgp_keys
|
||||
|
||||
## Key Technical Details
|
||||
- AES-256 encryption for mail account passwords (use `cryptography` package)
|
||||
- IMAP sync as ARQ background job (arq already in requirements.txt)
|
||||
- body_tsv column with PostgreSQL FTS (tsvector)
|
||||
- PGP via `pgpy` or `python-gnupg` package
|
||||
- HTML sanitization (no script tags) — use `bleach` or `nh3`
|
||||
- Plugin manifest: name="mail", dependencies=["permissions"] or []
|
||||
- Routes prefix: `/api/v1/mail`
|
||||
- Follow existing test pattern from `tests/test_dms.py` (use authed_client, ORIGIN_HEADER)
|
||||
- All routes need `get_current_user` dependency from `app.deps`
|
||||
|
||||
## Test Spec
|
||||
- Test file: `tests/test_mail.py`
|
||||
- Run: `cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_mail.py -v --tb=short`
|
||||
- Coverage: `python -m pytest tests/test_mail.py --cov=app/plugins/builtins/mail --cov-report=term-missing`
|
||||
- Coverage target: 80%
|
||||
- Follow `tests/test_dms.py` pattern: conftest fixtures (authed_client, ORIGIN_HEADER, login_client)
|
||||
|
||||
## Dependencies to Add (requirements.txt)
|
||||
- `cryptography>=42.0` (AES-256 encryption)
|
||||
- `pgpy>=0.6.0` or `python-gnupg>=0.5` (PGP)
|
||||
- `bleach>=6.0` or `nh3>=0.2` (HTML sanitization)
|
||||
- `aiosmtplib>=3.0` (async SMTP)
|
||||
- `aioimaplib>=1.0` (async IMAP)
|
||||
|
||||
## Forbidden Patterns
|
||||
- No synchronous IMAP/SMTP in route handlers — use async or ARQ jobs
|
||||
- No plaintext password storage — AES-256 encryption mandatory
|
||||
- No raw HTML in API responses without sanitization
|
||||
- No credential values in any API response
|
||||
- No `time.sleep()` in tests — use `asyncio.sleep()` or mocking
|
||||
|
||||
## Existing Code References
|
||||
- Plugin base class: `app/plugins/base.py` → BasePlugin
|
||||
- Plugin manifest: `app/plugins/manifest.py` → PluginManifest, PluginRouteDef
|
||||
- DMS plugin (pattern to follow): `app/plugins/builtins/dms/`
|
||||
- Calendar plugin (pattern to follow): `app/plugins/builtins/calendar/`
|
||||
- Test pattern: `tests/test_dms.py`, `tests/test_calendar.py`
|
||||
- DB deps: `app/core/db.py` → get_db
|
||||
- Auth deps: `app/deps.py` → get_current_user
|
||||
- Test fixtures: `tests/conftest.py` → authed_client, ORIGIN_HEADER, login_client
|
||||
|
||||
## Estimated Size
|
||||
- ~800 lines code (models + schemas + routes + services + plugin + migration)
|
||||
- ~400+ lines tests
|
||||
@@ -1,133 +0,0 @@
|
||||
# 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
|
||||
@@ -1,164 +0,0 @@
|
||||
# T07b Briefing — Frontend Feature Pages
|
||||
|
||||
## Project Root
|
||||
/a0/usr/workdir/dev-projects/leocrm
|
||||
|
||||
## Frontend Directory
|
||||
/a0/usr/workdir/dev-projects/leocrm/frontend/
|
||||
|
||||
## Task
|
||||
Build all feature pages for the LeoCRM SPA. T07a (shell, auth, routing, i18n, UI library) is complete.
|
||||
|
||||
## Tech Stack (already set up by T07a)
|
||||
- React 18 + Vite + TypeScript (strict)
|
||||
- TanStack Query v5 (hooks in src/api/hooks.ts)
|
||||
- Zustand (stores in src/store/)
|
||||
- react-i18next (de/en, src/i18n/)
|
||||
- React Hook Form + Zod
|
||||
- Tailwind CSS with design tokens
|
||||
- Vitest + @testing-library/react
|
||||
|
||||
## Existing API Hooks (src/api/hooks.ts)
|
||||
- useCompanies(page, pageSize, search) → { items, total, page, page_size }
|
||||
- useContacts(page, pageSize, search) → { items, total, page, page_size }
|
||||
- useUsers(page, pageSize) → paginated users
|
||||
- useCurrentUser() → current user
|
||||
- useNotifications() → notifications list
|
||||
- usePlugins() → plugins list
|
||||
- useLogin(), useLogout(), useSwitchTenant()
|
||||
- API client: src/api/client.ts (axios, withCredentials, 401→login, 422→validation)
|
||||
|
||||
## Backend API Endpoints
|
||||
- GET /api/v1/companies?page=1&page_size=25&search=...&industry=...&sort_by=...&sort_order=...
|
||||
- GET /api/v1/companies/{id} → CompanyDetailResponse (includes contacts[])
|
||||
- POST /api/v1/companies
|
||||
- PATCH /api/v1/companies/{id}
|
||||
- DELETE /api/v1/companies/{id}
|
||||
- GET /api/v1/companies/export?format=csv&search=...&industry=...
|
||||
- POST /api/v1/companies/import (CSV upload)
|
||||
- GET /api/v1/contacts?page=1&page_size=25&search=...
|
||||
- GET /api/v1/contacts/{id} → ContactDetailResponse (includes companies[])
|
||||
- POST /api/v1/contacts
|
||||
- PATCH /api/v1/contacts/{id}
|
||||
- DELETE /api/v1/contacts/{id}
|
||||
- GET /api/v1/users?page=1&page_size=25
|
||||
- GET /api/v1/users/{id}
|
||||
- POST /api/v1/users
|
||||
- PATCH /api/v1/users/{id}
|
||||
- DELETE /api/v1/users/{id}
|
||||
- GET /api/v1/notifications
|
||||
- GET /api/v1/plugins
|
||||
- GET /health
|
||||
|
||||
Note: No dedicated audit log or global search API endpoint exists yet. For audit log, create a frontend page that calls GET /api/v1/audit (may 404 — handle gracefully with empty state). For global search, call useCompanies and useContacts with search param in parallel.
|
||||
|
||||
## Company Schema (from backend)
|
||||
- name: string (required, 1-100)
|
||||
- account_number: string? (max 40)
|
||||
- industry: string? (max 50)
|
||||
- phone: string? (max 30)
|
||||
- email: string? (max 255)
|
||||
- website: string? (max 500)
|
||||
- description: string?
|
||||
- CompanyDetailResponse adds: contacts: list[dict]
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New API Hooks (add to src/api/hooks.ts)
|
||||
- useCompany(id), useCreateCompany(), useUpdateCompany(), useDeleteCompany()
|
||||
- useContact(id), useCreateContact(), useUpdateContact(), useDeleteContact()
|
||||
- useCompanyExport(), useCompanyImport()
|
||||
- useAuditLog(page, pageSize, filters)
|
||||
- useGlobalSearch(query, entityTypes)
|
||||
|
||||
### New Pages (src/pages/)
|
||||
- CompaniesList.tsx — TanStack Table with search/filter/sort/pagination, empty state
|
||||
- CompanyDetail.tsx — Tabs: overview, contacts, files, activity
|
||||
- CompanyForm.tsx — Create/edit with React Hook Form + Zod
|
||||
- ContactsList.tsx — TanStack Table, loading skeleton
|
||||
- ContactDetail.tsx — Tabs: overview, companies, files, activity
|
||||
- ContactForm.tsx — Create/edit with multi-company assignment
|
||||
- AuditLog.tsx — Filterable table (date, user, action, entity)
|
||||
- GlobalSearchResults.tsx — Filters (entity type, date), highlighting
|
||||
- SettingsProfile.tsx — Update name, email, password, avatar
|
||||
- SettingsRoles.tsx — Role editor: create, assign permissions
|
||||
- SettingsUsers.tsx — User management: list, invite, change role, deactivate
|
||||
|
||||
### Modify Existing Pages
|
||||
- Dashboard.tsx — Expand with stat cards + recent activity feed
|
||||
- Settings.tsx — Add tree navigation (Profile, Roles, Users, System)
|
||||
|
||||
### New Components (src/components/)
|
||||
- SearchDropdown.tsx — Global search dropdown in topbar
|
||||
- StatCard.tsx — Dashboard stat card
|
||||
- ActivityFeed.tsx — Recent activity feed
|
||||
- Tabs.tsx — Reusable tab component for detail pages
|
||||
- DataGrid.tsx — Wrapper around TanStack Table for list pages
|
||||
- CsvImportDialog.tsx — CSV upload → preview → import
|
||||
- UnsavedChangesGuard.tsx — Warn on navigation away with unsaved changes
|
||||
|
||||
### Update Routes (src/routes/index.tsx)
|
||||
Add routes for all new pages under ProtectedRoute children:
|
||||
- /companies, /companies/:id, /companies/new, /companies/:id/edit
|
||||
- /contacts, /contacts/:id, /contacts/new, /contacts/:id/edit
|
||||
- /audit-log
|
||||
- /search?q=...
|
||||
- /settings/profile, /settings/roles, /settings/users
|
||||
|
||||
### Tests (src/__tests__/)
|
||||
- companies/CompaniesList.test.tsx, CompanyDetail.test.tsx, CompanyForm.test.tsx
|
||||
- contacts/ContactsList.test.tsx, ContactDetail.test.tsx, ContactForm.test.tsx
|
||||
- settings/SettingsProfile.test.tsx, SettingsRoles.test.tsx, SettingsUsers.test.tsx
|
||||
- dashboard/Dashboard.test.tsx
|
||||
- search/GlobalSearch.test.tsx
|
||||
- AuditLog.test.tsx
|
||||
|
||||
## Acceptance Criteria (23 total)
|
||||
1. Companies list renders with TanStack Table (search, filter, sort, pagination)
|
||||
2. Company detail renders with tabs (overview, contacts, files, activity)
|
||||
3. Company form validates required fields (name) with Zod
|
||||
4. Company import: CSV upload → preview → import → success toast
|
||||
5. Company export: download CSV with current filters
|
||||
6. Contacts list renders with TanStack Table
|
||||
7. Contact detail renders with tabs (overview, companies, files, activity)
|
||||
8. Contact form validates required fields (first_name, last_name, email) with Zod
|
||||
9. Contact can be assigned to multiple companies
|
||||
10. Settings renders with tree navigation (Profile, Roles, Users, System)
|
||||
11. Profile settings: update name, email, password, avatar
|
||||
12. Role editor: create role, assign permissions, save
|
||||
13. User management: list users, invite user, change role, deactivate
|
||||
14. Audit log renders with filterable table (date, user, action, entity)
|
||||
15. Dashboard renders with stat cards and recent activity feed
|
||||
16. Global search bar in topbar returns results dropdown
|
||||
17. Global search results page renders with filters (entity type, date)
|
||||
18. Search results highlight matched terms
|
||||
19. Search works across companies, contacts (v1 scope)
|
||||
20. Companies list: empty state shows helpful message + create button
|
||||
21. Contacts list: loading state shows skeleton rows
|
||||
22. Company form: error state shows inline validation errors
|
||||
23. Settings: unsaved changes warning when navigating away
|
||||
|
||||
## Test Commands
|
||||
```bash
|
||||
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/companies/ src/__tests__/contacts/ src/__tests__/settings/ src/__tests__/dashboard/ src/__tests__/search/ --reporter=verbose
|
||||
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npm run build
|
||||
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Rules
|
||||
- TypeScript only, no .js files
|
||||
- No Lorem Ipsum — use real German/English content
|
||||
- All interactive elements need ARIA labels
|
||||
- 44px minimum touch targets on mobile
|
||||
- Use existing UI components from T07a (Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog)
|
||||
- Use existing i18n setup — add new keys to de.json and en.json
|
||||
- Use existing API client (src/api/client.ts) — don't create new axios instances
|
||||
- Coverage target: 80%
|
||||
- Keep responses under 50 lines
|
||||
- Use files_create for new files, reference by path
|
||||
|
||||
## Deliverables
|
||||
1. All files created and tests passing
|
||||
2. npm run build succeeds with 0 errors
|
||||
3. tsc --noEmit passes with 0 errors
|
||||
4. Report: test results, AC coverage, files created, bugs encountered
|
||||
@@ -1,158 +0,0 @@
|
||||
# T07b Continuation — Frontend Feature Pages (Part 2)
|
||||
|
||||
## Project Root
|
||||
/a0/usr/workdir/dev-projects/leocrm
|
||||
|
||||
## Frontend Directory
|
||||
/a0/usr/workdir/dev-projects/leocrm/frontend/
|
||||
|
||||
## What's Already Done (DO NOT recreate)
|
||||
|
||||
### API Hooks (src/api/hooks.ts — 432 lines, modified)
|
||||
16 new hooks already added: useCompany, useCreateCompany, useUpdateCompany, useDeleteCompany, useContact, useCreateContact, useUpdateContact, useDeleteContact, useCompanyExport, useCompanyImport, useAuditLog, useGlobalSearch, plus CRUD for users.
|
||||
|
||||
### Shared Components (src/components/shared/ — all exist)
|
||||
- `Tabs.tsx` (2055 bytes) — Tab navigation component
|
||||
- `StatCard.tsx` (1107 bytes) — Dashboard stat card
|
||||
- `ActivityFeed.tsx` (1372 bytes) — Activity feed list
|
||||
- `DataGrid.tsx` (6067 bytes) — TanStack Table wrapper with search/sort/pagination
|
||||
- `SearchDropdown.tsx` (6275 bytes) — Debounced search dropdown with highlighting
|
||||
- `CsvImportDialog.tsx` (5429 bytes) — CSV upload + preview dialog
|
||||
- `UnsavedChangesGuard.tsx` (821 bytes) — useBlocker-based unsaved changes warning
|
||||
|
||||
### Dependencies
|
||||
- @tanstack/react-table@8.21.3 installed
|
||||
|
||||
## What Remains (ALL of this must be created)
|
||||
|
||||
### 1. Feature Pages (src/pages/)
|
||||
|
||||
**Companies:**
|
||||
- `CompaniesList.tsx` — Use DataGrid component, search/filter/sort/pagination, CSV import (CsvImportDialog) + export buttons, empty state with create button (AC 1, 4, 5, 20)
|
||||
- `CompanyDetail.tsx` — Tabs: overview, contacts, files, activity (AC 2)
|
||||
- `CompanyForm.tsx` — RHF + Zod, validate name required, unsaved changes guard (AC 3, 22)
|
||||
|
||||
**Contacts:**
|
||||
- `ContactsList.tsx` — Use DataGrid, loading skeleton rows, empty state (AC 6, 21)
|
||||
- `ContactDetail.tsx` — Tabs: overview, companies, files, activity (AC 7)
|
||||
- `ContactForm.tsx` — RHF + Zod, validate first_name/last_name/email, multi-company assignment (AC 8, 9)
|
||||
|
||||
**Settings:**
|
||||
- `SettingsProfile.tsx` — Update name, email, password, avatar (AC 11)
|
||||
- `SettingsRoles.tsx` — Create role, assign permissions, save (AC 12)
|
||||
- `SettingsUsers.tsx` — List users, invite user, change role, deactivate (AC 13)
|
||||
|
||||
**Other:**
|
||||
- `AuditLog.tsx` — Filterable table (date, user, action, entity). Call useAuditLog hook. Handle 404 gracefully with empty state (AC 14)
|
||||
- `GlobalSearchResults.tsx` — Filters (entity type, date), highlight matched terms. Call useGlobalSearch hook (AC 17, 18)
|
||||
|
||||
### 2. Page Updates
|
||||
|
||||
- `Dashboard.tsx` — Replace placeholder with stat cards (StatCard component) + recent activity feed (ActivityFeed component) (AC 15)
|
||||
- `Settings.tsx` — Add tree navigation (Profile, Roles, Users, System). Render child routes (AC 10)
|
||||
- `TopBar.tsx` — Add SearchDropdown in topbar for global search (AC 16)
|
||||
|
||||
### 3. Routes (src/routes/index.tsx)
|
||||
|
||||
Add these routes:
|
||||
```
|
||||
/companies → CompaniesList
|
||||
/companies/:id → CompanyDetail
|
||||
/companies/new → CompanyForm
|
||||
/companies/:id/edit → CompanyForm
|
||||
/contacts → ContactsList
|
||||
/contacts/:id → ContactDetail
|
||||
/contacts/new → ContactForm
|
||||
/contacts/:id/edit → ContactForm
|
||||
/audit-log → AuditLog
|
||||
/search → GlobalSearchResults
|
||||
/settings/profile → SettingsProfile
|
||||
/settings/roles → SettingsRoles
|
||||
/settings/users → SettingsUsers
|
||||
```
|
||||
|
||||
### 4. i18n Updates (src/i18n/locales/de.json + en.json)
|
||||
|
||||
Add translation keys for all new pages: companies, contacts, settings, audit_log, search, dashboard sections.
|
||||
|
||||
### 5. Tests (src/__tests__/)
|
||||
|
||||
Create test files:
|
||||
- `companies/CompaniesList.test.tsx`
|
||||
- `companies/CompanyDetail.test.tsx`
|
||||
- `companies/CompanyForm.test.tsx`
|
||||
- `contacts/ContactsList.test.tsx`
|
||||
- `contacts/ContactDetail.test.tsx`
|
||||
- `contacts/ContactForm.test.tsx`
|
||||
- `settings/SettingsProfile.test.tsx`
|
||||
- `settings/SettingsRoles.test.tsx`
|
||||
- `settings/SettingsUsers.test.tsx`
|
||||
- `dashboard/Dashboard.test.tsx`
|
||||
- `search/GlobalSearch.test.tsx`
|
||||
- `AuditLog.test.tsx`
|
||||
|
||||
### 6. Verification
|
||||
|
||||
Run these commands and report results:
|
||||
```bash
|
||||
cd /a0/usr/workdir/dev-projects/leocrm/frontend
|
||||
npx vitest run src/__tests__/ --reporter=verbose
|
||||
npm run build
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
- React 18 + Vite + TypeScript
|
||||
- TanStack Query v5 (hooks in src/api/hooks.ts)
|
||||
- Zustand (stores in src/store/)
|
||||
- react-i18next (de/en locales)
|
||||
- React Hook Form + Zod
|
||||
- Tailwind CSS
|
||||
- Vitest + @testing-library/react
|
||||
- @tanstack/react-table v8
|
||||
|
||||
## Existing UI Components (src/components/ui/)
|
||||
Avatar, Badge, Button, Card, ConfirmDialog, EmptyState, Input, Modal, Pagination, Select, Skeleton, Table, Toast
|
||||
|
||||
## Existing Layout (src/components/layout/)
|
||||
AppShell, Sidebar, TopBar
|
||||
|
||||
## API Client (src/api/client.ts)
|
||||
Axios instance with interceptors. Base URL: http://localhost:8000. Auth via session cookie.
|
||||
|
||||
## Backend API Endpoints
|
||||
```
|
||||
GET/POST/PATCH/DELETE /api/v1/companies
|
||||
GET /api/v1/companies/{id} # includes contacts[]
|
||||
GET /api/v1/companies/export?format=csv
|
||||
POST /api/v1/companies/import # CSV upload
|
||||
GET/POST/PATCH/DELETE /api/v1/contacts
|
||||
GET /api/v1/contacts/{id} # includes companies[]
|
||||
GET/POST/PATCH/DELETE /api/v1/users
|
||||
GET /api/v1/users/{id}
|
||||
GET /api/v1/notifications
|
||||
GET /api/v1/plugins
|
||||
GET /health
|
||||
```
|
||||
Note: No /api/v1/audit endpoint exists yet. useAuditLog hook may 404 — handle gracefully.
|
||||
Note: No dedicated search endpoint. useGlobalSearch calls useCompanies + useContacts with search param.
|
||||
|
||||
## Company Schema
|
||||
```python
|
||||
name: str (required, 1-100 chars)
|
||||
account_number: str | None (max 40)
|
||||
industry: str | None (max 50)
|
||||
phone: str | None (max 30)
|
||||
email: str | None (max 255)
|
||||
website: str | None (max 500)
|
||||
description: str | None
|
||||
```
|
||||
|
||||
## Rules
|
||||
- TypeScript only, no .js files
|
||||
- No Lorem Ipsum — use real German/English content
|
||||
- Reuse existing UI components, don't recreate them
|
||||
- Keep responses under 50 lines — reference files by path
|
||||
- Use real content, not placeholder text
|
||||
- All 23 acceptance criteria must be covered
|
||||
- Test files must use @testing-library/react with vitest
|
||||
@@ -1,123 +0,0 @@
|
||||
# T08a: Frontend DMS + Tags + Permissions UI — Implementation Briefing
|
||||
|
||||
## Task
|
||||
Implement frontend UI for DMS plugin (file browser, upload, preview, share, trash), Tags UI (assign, bulk, tag cloud), and Permissions UI (share links, permission display).
|
||||
|
||||
## Requirements
|
||||
- F-DMS-01–07: DMS file browser, folder tree, upload, preview, share, trash, search
|
||||
- F-FILEUI-01–06: File UI components (dropzone, preview modal, share dialog, bulk actions, trash view)
|
||||
- F-TAG-01–04: Tags UI (assign, bulk assign, tag cloud, tag picker)
|
||||
- F-PERM-03–05: Permissions UI (share links, permission display)
|
||||
- F-LINK-01–05: Entity links UI
|
||||
|
||||
## Acceptance Criteria (12 ACs)
|
||||
1. DMS route /dms renders file browser with folder tree + file grid
|
||||
2. DMS upload: drag file to dropzone → upload progress → file appears in list
|
||||
3. DMS file preview modal opens with PDF.js for PDF files
|
||||
4. DMS share dialog: select user/group, set permission, share created
|
||||
5. DMS public share link: copy button generates URL, optional password+expiry fields
|
||||
6. DMS bulk select → bulk-move or bulk-delete actions appear
|
||||
7. DMS trash view: deleted files list, restore button per file
|
||||
8. Mail: shared mailbox selector (DO NOT IMPLEMENT — belongs to T08c)
|
||||
9. Tags: tag picker on company/contact detail → assign/unassign
|
||||
10. Tags: bulk select entities → bulk-tag dialog
|
||||
11. Plugin deactivate → plugin route+menu-item disappear from SPA
|
||||
12. Plugin activate → plugin route+menu-item appear in SPA
|
||||
|
||||
## Backend API Endpoints (already implemented)
|
||||
### DMS (/api/v1/dms)
|
||||
- GET /folders — list folder tree
|
||||
- POST /folders — create folder
|
||||
- PATCH /folders/{id} — rename/move folder
|
||||
- DELETE /folders/{id} — delete folder
|
||||
- POST /files/upload — upload file (multipart)
|
||||
- GET /files/{id} — get file detail
|
||||
- PATCH /files/{id} — update file (rename/move)
|
||||
- DELETE /files/{id} — soft-delete file
|
||||
- POST /files/{id}/restore — restore from trash
|
||||
- GET /files/{id}/preview — stream file for preview
|
||||
- POST /files/{id}/edit-session — create OnlyOffice edit session
|
||||
- POST /files/{id}/share — share file with user/group
|
||||
- DELETE /files/{id}/share — remove share
|
||||
- GET /search?q=text — search files
|
||||
- GET /shared-with-me — files shared with current user
|
||||
- POST /files/bulk-move — bulk move files
|
||||
- POST /files/bulk-delete — bulk delete files
|
||||
|
||||
### Tags (/api/v1/tags)
|
||||
- GET / — list tags
|
||||
- POST / — create tag
|
||||
- PATCH /{id} — update tag
|
||||
- DELETE /{id} — delete tag
|
||||
- POST /assign — assign tag to entity
|
||||
- DELETE /assign — unassign tag
|
||||
- POST /bulk-assign — bulk assign tags
|
||||
- GET /{id}/entities — list entities for tag
|
||||
|
||||
### Permissions (/api/v1/permissions)
|
||||
- GET /files/{id}/permissions — list permissions
|
||||
- POST /files/{id}/permissions — grant permission
|
||||
- DELETE /files/{id}/permissions/{user_id} — revoke permission
|
||||
- POST /files/{id}/share-link — create public share link
|
||||
- DELETE /share-links/{id} — revoke share link
|
||||
|
||||
## Frontend Architecture (follow existing patterns)
|
||||
- **Framework:** React + TypeScript + Vite
|
||||
- **Routing:** react-router-dom (createBrowserRouter, see src/routes/index.tsx)
|
||||
- **State:** TanStack Query (useQuery/useMutation)
|
||||
- **HTTP:** axios via src/api/client.ts (apiClient, baseURL /api/v1)
|
||||
- **API pattern:** See src/api/calendar.ts for plugin API client example
|
||||
- **UI components:** src/components/ui/ (Button, Card, Input, Modal, Table, Badge, ConfirmDialog, EmptyState, Pagination, Select, Skeleton, Toast)
|
||||
- **Shared components:** src/components/shared/ (DataGrid, SearchDropdown, Tabs, ActivityFeed)
|
||||
- **Store:** src/store/ (authStore, uiStore)
|
||||
- **Layout:** src/components/layout/AppShell (sidebar + main area)
|
||||
- **i18n:** src/i18n/ (add de.json + en.json keys for DMS/Tags)
|
||||
|
||||
## Files to Create
|
||||
- `src/api/dms.ts` — DMS API client (types + functions)
|
||||
- `src/api/tags.ts` — Tags API client
|
||||
- `src/api/permissions.ts` — Permissions API client
|
||||
- `src/pages/Dms.tsx` — DMS file browser page (folder tree + file grid)
|
||||
- `src/pages/DmsTrash.tsx` — DMS trash view
|
||||
- `src/components/dms/FolderTree.tsx` — folder tree sidebar
|
||||
- `src/components/dms/FileGrid.tsx` — file grid with icons
|
||||
- `src/components/dms/UploadDropzone.tsx` — drag-drop upload
|
||||
- `src/components/dms/FilePreviewModal.tsx` — file preview modal
|
||||
- `src/components/dms/ShareDialog.tsx` — share dialog
|
||||
- `src/components/dms/BulkActions.tsx` — bulk select actions
|
||||
- `src/components/tags/TagPicker.tsx` — tag assign/unassign picker
|
||||
- `src/components/tags/TagCloud.tsx` — tag cloud display
|
||||
- `src/components/tags/BulkTagDialog.tsx` — bulk tag assignment dialog
|
||||
- `src/__tests__/dms/DmsPage.test.tsx` — DMS page tests
|
||||
- `src/__tests__/dms/UploadDropzone.test.tsx` — upload tests
|
||||
- `src/__tests__/tags/TagPicker.test.tsx` — tag picker tests
|
||||
- `src/__tests__/tags/BulkTagDialog.test.tsx` — bulk tag tests
|
||||
- `src/__tests__/permissions/ShareDialog.test.tsx` — share dialog tests
|
||||
|
||||
## Files to Modify
|
||||
- `src/routes/index.tsx` — Add /dms, /dms/trash routes
|
||||
- `src/components/layout/AppShell.tsx` — Add DMS + Tags menu items to sidebar
|
||||
- `src/pages/CompanyDetail.tsx` — Add TagPicker component
|
||||
- `src/pages/ContactDetail.tsx` — Add TagPicker component
|
||||
- `src/i18n/locales/de.json` — Add DMS/Tags translations
|
||||
- `src/i18n/locales/en.json` — Add DMS/Tags translations
|
||||
|
||||
## Test Spec
|
||||
- Run: `cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/dms/ src/__tests__/tags/ src/__tests__/permissions/ --reporter=verbose`
|
||||
- Coverage: `npx vitest run src/__tests__/dms/ src/__tests__/tags/ --coverage`
|
||||
- Build: `npx vite build`
|
||||
- Type check: `npx tsc --noEmit`
|
||||
- Coverage target: 80%
|
||||
- Follow existing test pattern from src/__tests__/companies/ or src/__tests__/calendar/
|
||||
|
||||
## Forbidden Patterns
|
||||
- No inline styles — use Tailwind classes
|
||||
- No any types — use proper TypeScript interfaces
|
||||
- No direct fetch() — use apiClient from src/api/client.ts
|
||||
- No hardcoded strings — use i18n (t() function)
|
||||
- No Lorem Ipsum — use realistic test data
|
||||
- No missing loading/error/empty states
|
||||
|
||||
## Estimated Size
|
||||
- ~600 lines code (pages + components + API clients)
|
||||
- ~300+ lines tests
|
||||
@@ -1,148 +0,0 @@
|
||||
# T08c: Frontend Mail UI + Global Search UI — Implementation Briefing
|
||||
|
||||
## Task
|
||||
Implement frontend UI for Mail plugin (folder tree, mail list, reading pane, compose, templates, signatures, rules, labels, PGP, vacation, shared mailbox, delegates) and enhance Global Search UI with tabs.
|
||||
|
||||
## Acceptance Criteria (17 ACs — skip AC1/DMS and AC16/Docker, already done)
|
||||
2. Mail route /mail renders folder tree + mail list + reading pane
|
||||
3. Mail: click folder → mail list updates with folder mails
|
||||
4. Mail: click mail → detail with sanitized HTML body + attachments
|
||||
5. Mail: compose button → editor with toolbar (bold, italic, link, template insert)
|
||||
6. Mail: reply/forward buttons → compose pre-filled
|
||||
7. Mail: template picker dropdown in compose → inserts template body
|
||||
8. Mail: signature manager in settings → create/edit/delete signatures
|
||||
9. Mail: rule editor → condition builder + action selector
|
||||
10. Mail: label manager → create labels with colors, assign to mails
|
||||
11. Mail: PGP settings → import private key, view contact public keys
|
||||
12. Mail: vacation responder toggle → date range + auto-reply text
|
||||
13. Mail: shared mailbox selector → switch between personal+shared accounts
|
||||
14. Mail: attachment download → file stream downloaded
|
||||
15. Mail: create event from mail → calendar event modal pre-filled
|
||||
16. Global search results page → tabs for companies/contacts/mails/files/events
|
||||
17. Global search autocomplete in TopBar → dropdown with suggestions
|
||||
|
||||
## Backend API Endpoints (all implemented, prefix /api/v1/mail)
|
||||
### Accounts
|
||||
- GET /accounts — list accounts (password never returned)
|
||||
- POST /accounts — create account (AES-256 encrypted password)
|
||||
- PATCH /accounts/{id} — update account
|
||||
- DELETE /accounts/{id} — delete account
|
||||
- GET /accounts/shared — list shared mailboxes
|
||||
- POST /accounts/{id}/users — assign shared mailbox users
|
||||
- POST /accounts/{id}/delegates — create delegate access
|
||||
- POST /accounts/{id}/send-permissions — grant send permission
|
||||
- POST /accounts/{id}/test-connection — test IMAP connection
|
||||
- POST /accounts/{id}/sync — trigger IMAP sync
|
||||
|
||||
### Folders
|
||||
- GET /folders?account_id=X — list folders with counts
|
||||
- POST /folders — create folder
|
||||
- PATCH /folders/{id} — rename folder
|
||||
- DELETE /folders/{id} — delete folder
|
||||
|
||||
### Mails
|
||||
- GET /?folder_id=X&page=1 — paginated mail list
|
||||
- GET /{id} — mail detail (sanitized HTML, attachments)
|
||||
- POST /send — send mail via SMTP
|
||||
- POST /{id}/reply — reply with In-Reply-To
|
||||
- POST /{id}/forward — forward mail
|
||||
- PATCH /{id}/flags — toggle seen/flagged
|
||||
- POST /{id}/link — link to contact/company
|
||||
- POST /{id}/create-event — create calendar event from mail
|
||||
- POST /{id}/labels — assign label to mail
|
||||
|
||||
### Search & Threads
|
||||
- GET /search?q=text — FTS search
|
||||
- GET /threads — threaded view
|
||||
|
||||
### Attachments
|
||||
- GET /{mail_id}/attachments/{att_id} — file stream download
|
||||
|
||||
### Templates
|
||||
- POST /templates — create template
|
||||
- GET /templates — list templates
|
||||
- POST /templates/substitute — substitute variables
|
||||
|
||||
### Signatures
|
||||
- POST /signatures — create signature
|
||||
- GET /signatures — list signatures
|
||||
|
||||
### Rules
|
||||
- POST /rules — create rule (conditions + actions)
|
||||
- GET /rules — list rules sorted by priority
|
||||
- DELETE /rules/{id} — delete rule
|
||||
|
||||
### Vacation
|
||||
- POST /vacation — configure auto-reply
|
||||
- POST /vacation/test-dedup — test dedup
|
||||
|
||||
### PGP
|
||||
- POST /pgp/keys — import private key (encrypted)
|
||||
- GET /pgp/keys — list PGP keys
|
||||
- POST /pgp/encrypt — encrypt message
|
||||
- POST /contacts/{contact_id}/pgp-key — store contact public key
|
||||
|
||||
### Labels
|
||||
- POST /labels — create label (with color)
|
||||
- GET /labels — list labels
|
||||
|
||||
## Frontend Architecture (follow existing patterns)
|
||||
- **Framework:** React + TypeScript + Vite
|
||||
- **Routing:** react-router-dom (src/routes/index.tsx)
|
||||
- **State:** TanStack Query (useQuery/useMutation)
|
||||
- **HTTP:** axios via src/api/client.ts (apiClient, baseURL /api/v1)
|
||||
- **API pattern:** See src/api/calendar.ts or src/api/dms.ts
|
||||
- **UI components:** src/components/ui/ (Button, Card, Input, Modal, Table, Badge, etc.)
|
||||
- **Shared:** src/components/shared/ (DataGrid, SearchDropdown, Tabs)
|
||||
- **Layout:** src/components/layout/AppShell.tsx + Sidebar.tsx
|
||||
- **i18n:** src/i18n/ (add de.json + en.json keys for Mail)
|
||||
- **Existing search page:** src/pages/GlobalSearchResults.tsx (enhance with tabs)
|
||||
|
||||
## Files to Create
|
||||
- `src/api/mail.ts` — Mail API client (types + functions for all endpoints)
|
||||
- `src/pages/Mail.tsx` — Mail page (folder tree + mail list + reading pane)
|
||||
- `src/pages/MailSettings.tsx` — Mail settings (signatures, rules, PGP, vacation, labels)
|
||||
- `src/components/mail/MailFolderTree.tsx` — folder tree sidebar
|
||||
- `src/components/mail/MailList.tsx` — mail list with pagination
|
||||
- `src/components/mail/MailDetail.tsx` — reading pane (sanitized HTML, attachments)
|
||||
- `src/components/mail/ComposeModal.tsx` — compose editor (bold/italic/link/template)
|
||||
- `src/components/mail/TemplatePicker.tsx` — template dropdown
|
||||
- `src/components/mail/SignatureManager.tsx` — signature CRUD
|
||||
- `src/components/mail/RuleEditor.tsx` — rule condition builder + action selector
|
||||
- `src/components/mail/LabelManager.tsx` — label CRUD with colors
|
||||
- `src/components/mail/VacationResponder.tsx` — vacation toggle + date range
|
||||
- `src/components/mail/PgpSettings.tsx` — PGP key import + contact keys
|
||||
- `src/components/mail/SharedMailboxSelector.tsx` — account switcher
|
||||
- `src/components/mail/MailSearchBar.tsx` — mail search input
|
||||
- `src/__tests__/mail/MailPage.test.tsx` — mail page tests
|
||||
- `src/__tests__/mail/ComposeModal.test.tsx` — compose tests
|
||||
- `src/__tests__/mail/MailSettings.test.tsx` — settings tests
|
||||
- `src/__tests__/search/GlobalSearchTabs.test.tsx` — search tabs tests
|
||||
|
||||
## Files to Modify
|
||||
- `src/routes/index.tsx` — Add /mail, /mail/settings routes
|
||||
- `src/components/layout/Sidebar.tsx` — Add Mail nav link
|
||||
- `src/pages/GlobalSearchResults.tsx` — Add tabs (companies/contacts/mails/files/events)
|
||||
- `src/components/layout/AppShell.tsx` — Add search autocomplete in TopBar
|
||||
- `src/i18n/locales/de.json` — Mail translations
|
||||
- `src/i18n/locales/en.json` — Mail translations
|
||||
|
||||
## Test Spec
|
||||
- Run: `cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/mail/ src/__tests__/search/ --reporter=verbose`
|
||||
- Build: `npx vite build`
|
||||
- Type check: `npx tsc --noEmit`
|
||||
- Coverage target: 80%
|
||||
- Follow existing test pattern from src/__tests__/dms/ or src/__tests__/companies/
|
||||
|
||||
## Forbidden Patterns
|
||||
- No inline styles — use Tailwind classes
|
||||
- No any types — use proper TypeScript interfaces
|
||||
- No direct fetch() — use apiClient from src/api/client.ts
|
||||
- No hardcoded strings — use i18n (t() function)
|
||||
- No Lorem Ipsum — use realistic test data
|
||||
- No missing loading/error/empty states
|
||||
- No dangerouslySetInnerHTML without sanitization check
|
||||
|
||||
## Estimated Size
|
||||
- ~700 lines code (pages + components + API client)
|
||||
- ~350+ lines tests
|
||||
@@ -1,121 +0,0 @@
|
||||
# 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
|
||||
@@ -1,87 +0,0 @@
|
||||
# T10: Monitoring, Performance, Documentation & Environment Config — Implementation Briefing
|
||||
|
||||
## Task
|
||||
Three modules in one task: (1) Monitoring & Alerting, (2) Performance, (3) Documentation.
|
||||
|
||||
## Acceptance Criteria (18 ACs)
|
||||
### Monitoring (AC1-6)
|
||||
1. GET /api/v1/health → 200 + JSON with status, checks.database, checks.redis, checks.storage, checks.worker
|
||||
2. GET /api/v1/health mit DB down → 200 + status=degraded, checks.database.status=down
|
||||
3. GET /api/v1/metrics → 200 + text/plain Prometheus format (admin only, 403 for non-admin)
|
||||
4. Prometheus metrics include leocrm_http_requests_total, leocrm_db_pool_connections, leocrm_arq_jobs_total
|
||||
5. Structured JSON log entry for API request: {timestamp, level, event, method, path, status, duration_ms, tenant_id}
|
||||
6. Error log includes stacktrace and request context
|
||||
|
||||
### Performance (AC7-12)
|
||||
7. scripts/seed_perf_data.py --count 200000 → creates 200k contacts in test DB
|
||||
8. GET /api/v1/contacts?page=1&page_size=25 with 200k records → response time <500ms
|
||||
9. GET /api/v1/contacts?search=Mueller with 200k records → response time <500ms
|
||||
10. page_size > 100 → 422 (max page_size enforced)
|
||||
11. CSV export >1000 records → ARQ background job started → notification on completion
|
||||
12. Streaming CSV export: GET /api/v1/contacts/export?format=csv → text/csv stream (not buffered)
|
||||
|
||||
### Documentation (AC13-18)
|
||||
13. README.md exists with Setup-Anleitung (dev + prod), API section, links to admin-guide
|
||||
14. Swagger UI available at /api/v1/docs (FastAPI auto-gen)
|
||||
15. docs/admin-guide.md exists with Deploy, Backup, Restore, Env-Vars, Troubleshooting sections
|
||||
16. docs/api-overview.md exists with endpoint summary table
|
||||
17. .env.example file exists with all required variables documented (database, redis, smtp, storage, secret_key)
|
||||
18. Environment-specific config: dev, test, prod profiles documented in docs/admin-guide.md
|
||||
|
||||
## Existing Code References
|
||||
- **Health endpoint:** app/routes/health.py (simple, needs extension)
|
||||
- **Health test:** tests/test_health.py (basic 200 check)
|
||||
- **Main app:** app/main.py (FastAPI app with CORS, CSRF middleware)
|
||||
- **Config:** app/config.py (settings with pydantic-settings)
|
||||
- **DB:** app/core/db.py (async engine)
|
||||
- **Routes:** app/routes/ (auth, companies, contacts, etc.)
|
||||
- **Contacts route:** app/routes/contacts.py (has search param, pagination)
|
||||
- **Companies route:** app/routes/companies.py (has search, pagination, export)
|
||||
- **README.md:** exists (basic, needs update with prod setup, API section, admin-guide link)
|
||||
- **.env.example:** exists (good coverage, may need SMTP/storage additions)
|
||||
- **docs/:** only requirements docs, needs admin-guide.md + api-overview.md
|
||||
- **Docker:** docker-compose.yml + Dockerfile exist
|
||||
- **Coolify:** COOLIFY_SETUP.md exists
|
||||
|
||||
## Files to Create
|
||||
- `app/core/monitoring.py` — Health check extensions, Prometheus metrics, structured logging
|
||||
- `app/routes/metrics.py` — Prometheus metrics endpoint (admin-only)
|
||||
- `scripts/seed_perf_data.py` — Performance test data seeding script
|
||||
- `scripts/check_indexes.py` — DB index verification script
|
||||
- `tests/test_monitoring.py` — Monitoring tests (health, metrics, logging)
|
||||
- `tests/test_performance.py` — Performance tests (pagination, export, page_size limit)
|
||||
- `docs/admin-guide.md` — Admin guide (Deploy, Backup, Restore, Env-Vars, Troubleshooting)
|
||||
- `docs/api-overview.md` — API endpoint summary
|
||||
|
||||
## Files to Modify
|
||||
- `app/routes/health.py` — Extend health check with DB+Redis+Storage+Worker status
|
||||
- `app/main.py` — Add metrics route, structured logging middleware, request timing
|
||||
- `app/routes/contacts.py` — Enforce page_size max 100, add streaming CSV export
|
||||
- `app/routes/companies.py` — Enforce page_size max 100, add streaming CSV export
|
||||
- `app/config.py` — Add SMTP/storage config if missing
|
||||
- `README.md` — Update with prod setup, API section, admin-guide link, env profiles
|
||||
- `.env.example` — Add SMTP/storage/secret_key vars if missing
|
||||
- `tests/test_health.py` — Update for extended health check
|
||||
- `requirements.txt` — Add prometheus-client, structlog if needed
|
||||
|
||||
## Dependencies to Add (if not present)
|
||||
- `prometheus-client>=0.20` (Prometheus metrics)
|
||||
- `structlog>=24.0` (structured JSON logging)
|
||||
|
||||
## Test Spec
|
||||
- Run: `cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_monitoring.py tests/test_performance.py tests/test_health.py -v --tb=short`
|
||||
- Coverage: `python -m pytest tests/test_monitoring.py --cov=app/core/monitoring --cov-report=term-missing`
|
||||
- Docs check: `test -f README.md && test -f docs/admin-guide.md && test -f docs/api-overview.md && echo 'Docs OK'`
|
||||
- Coverage target: 80%
|
||||
- Follow existing test pattern from tests/test_health.py or tests/test_companies.py
|
||||
|
||||
## Forbidden Patterns
|
||||
- No blocking I/O in async health check — use async DB ping
|
||||
- No credentials in logs or metrics
|
||||
- No unbounded pagination — max 100 per page enforced
|
||||
- No buffering large CSV exports — use StreamingResponse
|
||||
- No hardcoded config — use app/config.py settings
|
||||
|
||||
## Estimated Size
|
||||
- ~500 lines code (monitoring + scripts + docs)
|
||||
- ~300+ lines tests
|
||||
@@ -1,154 +0,0 @@
|
||||
# T11 Briefing — Tags Plugin + Permissions Plugin + Entity Links Backend
|
||||
|
||||
## Project Root
|
||||
/a0/usr/workdir/dev-projects/leocrm
|
||||
|
||||
## Task
|
||||
Implement 3 builtin plugins: Tags, Permissions, Entity Links.
|
||||
|
||||
## Plugin Framework (existing — read these files first)
|
||||
- `app/plugins/base.py` — BasePlugin abstract class with lifecycle hooks
|
||||
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef schemas
|
||||
- `app/plugins/registry.py` — PluginRegistry (discovers builtins, manages lifecycle)
|
||||
- `app/plugins/builtins/test_sample.py` — Example plugin (reference pattern)
|
||||
- `app/plugins/builtins/migrations/` — Migration SQL files go here
|
||||
- `app/core/event_bus.py` — EventBus for pub/sub
|
||||
- `app/core/service_container.py` — DI container
|
||||
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
|
||||
- `app/models/company.py` — Company model (reference for model patterns)
|
||||
- `app/models/plugin.py` — Plugin + PluginMigration models
|
||||
|
||||
## Architecture Rules
|
||||
- Plugins live in `app/plugins/builtins/` as subdirectories (e.g. `app/plugins/builtins/tags/`)
|
||||
- Each plugin has: `__init__.py` (exports plugin class), `plugin.py` (BasePlugin subclass), `routes.py` (APIRouter), `models.py` (SQLAlchemy models), `schemas.py` (Pydantic schemas), `migrations/` (SQL files)
|
||||
- Migrations are plain SQL files in `app/plugins/builtins/<plugin>/migrations/`
|
||||
- Models use SQLAlchemy 2.0 style (Mapped, mapped_column) with PGUUID, TenantMixin
|
||||
- Routes use FastAPI APIRouter, registered via manifest routes list
|
||||
- Events: subscribe in on_activate, handlers named `on_<event_name>`
|
||||
|
||||
## 1. Tags Plugin (`app/plugins/builtins/tags/`)
|
||||
|
||||
### Requirements (F-TAG-01 through F-TAG-04)
|
||||
- Tags can be applied to files, folders, companies, contacts
|
||||
- Tags are global (not per-user), centrally managed
|
||||
- Multiple tags per entity (N:M)
|
||||
- Tag CRUD with color support
|
||||
- Tag filtering in lists (AND/OR combination)
|
||||
- Tag cloud/sidebar with entity counts
|
||||
|
||||
### Endpoints
|
||||
```
|
||||
GET /api/v1/tags → 200, list tags with entity counts
|
||||
POST /api/v1/tags → 201, create tag (name, color)
|
||||
PATCH /api/v1/tags/{id} → 200, update tag
|
||||
DELETE /api/v1/tags/{id} → 204, cascade delete assignments
|
||||
POST /api/v1/tags/assign → 200, assign tag to entity (tag_id, entity_type, entity_id)
|
||||
DELETE /api/v1/tags/assign → 204, remove tag assignment
|
||||
POST /api/v1/tags/bulk-assign → 200, assign multiple tags to entity
|
||||
GET /api/v1/tags/{id}/entities → 200, list entities with this tag
|
||||
```
|
||||
|
||||
### Models
|
||||
- `Tag`: id (UUID), name (str, unique per tenant), color (str, hex), tenant_id
|
||||
- `TagAssignment`: id, tag_id (FK), entity_type (str: company/contact/file/folder), entity_id (UUID), tenant_id
|
||||
|
||||
### Migration
|
||||
- `0001_initial.sql`: Create `tags` and `tag_assignments` tables with tenant_id columns
|
||||
|
||||
## 2. Permissions Plugin (`app/plugins/builtins/permissions/`)
|
||||
|
||||
### Requirements (F-PERM-01 through F-PERM-06)
|
||||
- Personal root folder per user ("Mein Bereich")
|
||||
- Shared root folders for teams/departments
|
||||
- Share files/folders with individual users (read/write)
|
||||
- Share files/folders with user groups (read/write)
|
||||
- Public share links (with password, expiry, download-only or preview+download)
|
||||
- Permission display (who has access?)
|
||||
|
||||
### Endpoints
|
||||
```
|
||||
GET /api/v1/dms/files/{id}/permissions → 200, permission list
|
||||
POST /api/v1/dms/files/{id}/permissions → 201, grant permission
|
||||
DELETE /api/v1/dms/files/{id}/permissions/{user_id} → 204, revoke
|
||||
POST /api/v1/dms/files/{id}/share-link → 200, create share link (returns public token URL)
|
||||
GET /api/public/share/{token} → 200 (file) or 410 (expired)
|
||||
DELETE /api/v1/dms/share-links/{id} → 204, revoke share link
|
||||
```
|
||||
|
||||
### Models
|
||||
- `Permission`: id, file_id (UUID), user_id (UUID), group_id (UUID nullable), access_level (read/write), tenant_id
|
||||
- `ShareLink`: id, file_id (UUID), token (str, unique), password_hash (nullable), expires_at (nullable), access_level (download/preview), tenant_id
|
||||
|
||||
### Migration
|
||||
- `0001_initial.sql`: Create `permissions` and `share_links` tables
|
||||
|
||||
### Special
|
||||
- Public share endpoint `/api/public/share/{token}` must NOT require auth
|
||||
- Expired links return 410 Gone
|
||||
- Password-protected links verify password before serving
|
||||
|
||||
## 3. Entity Links Backend (`app/plugins/builtins/entity_links/`)
|
||||
|
||||
### Requirements (F-LINK-01 through F-LINK-06)
|
||||
- Link files/folders to companies (N:M)
|
||||
- Link files/folders to contacts (N:M)
|
||||
- Reverse links (file shows linked entities)
|
||||
- Multi-links (one file → many entities)
|
||||
- Event cleanup: on company.deleted/contact.deleted → remove links
|
||||
|
||||
### Endpoints
|
||||
```
|
||||
POST /api/v1/dms/files/{id}/link → 200, link file to entity (entity_type, entity_id)
|
||||
DELETE /api/v1/dms/files/{id}/link → 204, remove link (entity_type, entity_id in body)
|
||||
GET /api/v1/dms/files/{id}/links → 200, list all linked entities for file
|
||||
GET /api/v1/companies/{id}/files → 200, list linked files for company
|
||||
GET /api/v1/contacts/{id}/files → 200, list linked files for contact
|
||||
```
|
||||
|
||||
### Models
|
||||
- `EntityLink`: id, file_id (UUID), entity_type (str: company/contact), entity_id (UUID), tenant_id, created_by (UUID)
|
||||
|
||||
### Migration
|
||||
- `0001_initial.sql`: Create `entity_links` table
|
||||
|
||||
### Event Handling
|
||||
- Subscribe to `company.deleted` → delete all EntityLink rows where entity_type='company' AND entity_id=deleted_id
|
||||
- Subscribe to `contact.deleted` → delete all EntityLink rows where entity_type='contact' AND entity_id=deleted_id
|
||||
|
||||
## Acceptance Criteria (14 total — ALL must pass)
|
||||
1. GET /api/v1/dms/files/{id}/permissions → 200 + permission list
|
||||
2. POST /api/v1/dms/files/{id}/link → 200, file linked to entity
|
||||
3. DELETE /api/v1/dms/files/{id}/link → 204, link removed
|
||||
4. POST /api/v1/dms/files/{id}/share-link → 200 + public token URL
|
||||
5. GET /api/public/share/{token} with expired link → 410
|
||||
6. GET /api/v1/tags → 200 + tags with counts
|
||||
7. POST /api/v1/tags → 201, tag created
|
||||
8. PATCH /api/v1/tags/{id} → 200
|
||||
9. DELETE /api/v1/tags/{id} → 204, cascade delete assignments
|
||||
10. POST /api/v1/tags/assign → 200, tag assigned to entity
|
||||
11. DELETE /api/v1/tags/assign → 204, tag removed
|
||||
12. POST /api/v1/tags/bulk-assign → 200, multiple tags assigned
|
||||
13. DMS plugin listens to company.deleted event → linked files cleanup
|
||||
14. Folder permissions enforced: user without read → 403
|
||||
|
||||
## Test Files (create in `tests/`)
|
||||
- `tests/test_tags.py` — Tag CRUD, assignment, bulk assign, cascade delete, counts
|
||||
- `tests/test_permissions.py` — Personal root, shared root, share with users/groups, share links (password, expiry), permission display, 403 enforcement
|
||||
- `tests/test_entity_links.py` — Link file to company, link to contact, reverse links, multi-links, event cleanup on deletion
|
||||
|
||||
## Verification Commands
|
||||
```bash
|
||||
cd /a0/usr/workdir/dev-projects/leocrm
|
||||
python -m pytest tests/test_tags.py tests/test_permissions.py tests/test_entity_links.py -v --tb=short
|
||||
python -m pytest tests/test_tags.py tests/test_permissions.py tests/test_entity_links.py --cov=app/plugins/builtins --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use text_editor:write for new files, text_editor:patch for updates
|
||||
- Read existing files before modifying
|
||||
- No Lorem Ipsum, no placeholder code
|
||||
- Follow existing patterns (SQLAlchemy 2.0, Pydantic v2, FastAPI APIRouter)
|
||||
- Each plugin must have manifest, plugin class, routes, models, schemas, migrations
|
||||
- Register plugins in `app/plugins/builtins/__init__.py`
|
||||
- Keep response under 50 lines
|
||||
- Report: files created, test count + pass/fail, coverage %
|
||||
@@ -1,47 +0,0 @@
|
||||
# LeoCRM — Current Status
|
||||
**Phase**: Fix Branch — 20/22 FIX-PLAN Items erledigt
|
||||
**Last update**: 2026-07-26 16:25
|
||||
**Branch**: main (leocrm-fix)
|
||||
|
||||
## FIX-PLAN Überprüfung (2026-07-26)
|
||||
Alle 22 Items gegen Codebasis verifiziert. 20 erledigt, 2 offen.
|
||||
|
||||
### Erledigt (20)
|
||||
- P0-1: Auth-Bypass entfernt ✅
|
||||
- P0-2: Migrationen repariert ✅
|
||||
- P0-3: Plugin-Upload deaktiviert ✅
|
||||
- P0-4: RLS FORCE + WITH CHECK ✅
|
||||
- P0-5: Plugin-Doppelregistrierung behoben ✅
|
||||
- P0-6: Persistent Volume ✅
|
||||
- P1-1: User/Tenant-Modell bereinigt ✅
|
||||
- P1-2: Redis zentralisiert ✅
|
||||
- P1-3: Worker ausgelagert ✅
|
||||
- P1-4: Transactional Outbox ✅
|
||||
- P1-5: XSS-Stellen geschlossen ✅
|
||||
- P1-6: DMS lastfest ✅
|
||||
- P1-7: Permission-System vereinheitlicht ✅
|
||||
- P1-8: Password Reset funktionsfähig ✅
|
||||
- P1-9: Metrics abgesichert ✅
|
||||
- P1-10: Coolify-Doku & Config korrigiert ✅
|
||||
- P1-11: Cross-Tenant FK ✅
|
||||
- P2-1: Contact Model normalisiert ✅
|
||||
- P2-3: Commands & Statusmaschinen ✅
|
||||
- P2-4: SPA Path-Traversal ✅
|
||||
|
||||
### Offen (2)
|
||||
- P0-7: App von öffentlicher Domain nehmen (operational — 30 Min)
|
||||
- P2-2: Plugin-Cross-Imports reduzieren (228 Imports — 1-2 Wochen)
|
||||
|
||||
## Previous: P1-4: Transactional Outbox — COMPLETE
|
||||
- Migration 0040_outbox.py created (down_revision=0039_contact_normalize)
|
||||
- event_outbox table: id, tenant_id, event_name, payload JSONB, status, attempts, max_attempts, next_retry_at, timestamps
|
||||
- app/core/outbox.py: enqueue_outbox_event() + process_outbox_batch() with FOR UPDATE SKIP LOCKED, exponential backoff retry
|
||||
- app/core/event_bus.py: added publish_with_results() for error-aware publishing; docstring note about outbox
|
||||
- app/core/worker.py: process_outbox_job cron (every 5s, Redis distributed lock)
|
||||
- app/services/contact_service.py: contact.created, lead.created, contact.updated → enqueue_outbox_event
|
||||
- app/models/outbox.py: SQLAlchemy ORM model for event_outbox
|
||||
- tests/test_outbox.py: 6 tests, all passing
|
||||
- py_compile: OK, alembic heads: single head 0040_outbox
|
||||
|
||||
## Previous: P2-1: Unified Contact Model normalisieren — COMPLETE
|
||||
- Migration 0039_contact_normalize.py (down_revision=0038_dms_content_hash)
|
||||
@@ -1,10 +0,0 @@
|
||||
# LeoCRM — Next Steps
|
||||
|
||||
## FIX-PLAN Offene Items (2026-07-26)
|
||||
1. P0-7: App von öffentlicher Domain nehmen (operational — 30 Min)
|
||||
2. P2-2: Plugin-Cross-Imports reduzieren (228 Imports — 1-2 Wochen)
|
||||
|
||||
## Abgeschlossen
|
||||
- P2-1: Unified Contact Model normalisieren — COMPLETE
|
||||
- P1-4: Transactional Outbox — COMPLETE
|
||||
- 20/22 FIX-PLAN Items erledigt (siehe .a0/current_status.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"project_name": "leocrm",
|
||||
"phase": "phase-6-complete",
|
||||
"status": "running:healthy",
|
||||
"last_commit": "047b59a",
|
||||
"forgejo_synced": true,
|
||||
"completed_tasks": ["T01","T02","T03","T04","T05","T06","T07a","T07b","T08a","T08b","T08c","T09","T10","T11"],
|
||||
"current_task": null,
|
||||
"next_task": "phase7-release",
|
||||
"test_results": {
|
||||
"backend_tests": "564/564 passed (as of 2026-07-02)",
|
||||
"frontend_tests": "318/318 passed (as of 2026-07-02)",
|
||||
"coverage": "85.41%"
|
||||
},
|
||||
"runtime_results": {
|
||||
"app_start": "successful",
|
||||
"health_endpoint": "200 OK — {status: healthy, database: up, redis: up, storage: up, worker: up}",
|
||||
"swagger": "200 OK"
|
||||
},
|
||||
"deployment_results": {
|
||||
"url": "https://crm.media-on.de",
|
||||
"status": "running:healthy",
|
||||
"health_check": "200 OK",
|
||||
"swagger": "200 OK",
|
||||
"coolify_uuid": "stvabl4vaqru7jclx4ittzr3",
|
||||
"deployed_commit": "047b59a",
|
||||
"deployed_at": "2026-07-04T18:17:48+02:00"
|
||||
},
|
||||
"updated_at": "2026-07-04T18:19:00+02:00"
|
||||
}
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
# LeoCRM Security & Data Risk Assessment
|
||||
|
||||
**Date:** 2026-07-26
|
||||
**Assessor:** Security Data Engineer (A0 Orchestrator)
|
||||
**Project:** LeoCRM at `/a0/usr/workdir/leocrm-fix`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 5 |
|
||||
| HIGH | 8 |
|
||||
| MEDIUM | 8 |
|
||||
| LOW | 5 |
|
||||
| **Total**| **26**|
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Issues
|
||||
|
||||
### C-1: Redis Default Password `changeme` in docker-compose.yml
|
||||
**File:** `docker-compose.yml:53`
|
||||
**Risk:** Redis stores session data, CSRF tokens, and rate-limit counters. The default password `changeme` is trivially guessable. If Redis port 6379 is exposed, an attacker can read/modify all sessions, steal CSRF tokens, and bypass rate limits.
|
||||
**Remediation:** Remove the default fallback. Require `REDIS_PASSWORD` as a mandatory variable (`${REDIS_PASSWORD:?REDIS_PASSWORD is required}`). Use a strong randomly generated password in production.
|
||||
|
||||
### C-2: No SECRET_KEY in `.env` — Insecure Default Active in Development
|
||||
**File:** `.env` (missing `SECRET_KEY`), `app/config.py:55`
|
||||
**Risk:** `.env` has no `SECRET_KEY`. The config defaults to `"change-me-in-production-use-a-secure-random-string"`. While `get_settings()` raises in production mode, `.env` sets `ENVIRONMENT=development`, so the default key is silently used. Any signing/token operation using `secret_key` is compromised.
|
||||
**Remediation:** Add a strong random `SECRET_KEY` (min 32 chars) to `.env`. Fail-fast in all environments if the default key is detected, not just production.
|
||||
|
||||
### C-3: PostgreSQL and Redis Ports Exposed to Host
|
||||
**File:** `docker-compose.yml:37-38, 56-57`
|
||||
**Risk:** `ports: "5432:5432"` and `ports: "6379:6379"` expose the database and Redis to the host network. Combined with weak/default credentials, this allows direct external access to all session data and the entire database.
|
||||
**Remediation:** Remove port mappings for production. Use Docker internal networking only (`crm-net`). If debug access is needed, bind to `127.0.0.1:5432:5432` and document it as dev-only.
|
||||
|
||||
### C-4: Unauthenticated Error Endpoint Forwards Data to External Forgejo
|
||||
**File:** `app/routes/errors.py:54-90`, `app/plugins/builtins/forgejo_error_reporter/service.py:151-250`
|
||||
**Risk:** The `/api/v1/errors` endpoint requires no authentication. CSRF middleware explicitly bypasses token checks for this path (line 48 of `middleware.py`). Any unauthenticated attacker can POST arbitrary error data (message, stack, URL, userAgent, and **arbitrary context dict**) which gets forwarded to an external Forgejo instance as a public issue. The `context` field accepts `dict[str, Any]` with no size limit on individual keys — an attacker can exfiltrate data or inject malicious content into Forgejo issues.
|
||||
**Remediation:** Require authentication for error reporting. If unauthenticated errors are needed, strip the `context` field entirely, add strict schema validation with size limits on all fields, and add a CAPTCHA or stricter rate limiting.
|
||||
|
||||
### C-5: Plaintext Database Password in `.env`
|
||||
**File:** `.env:1`
|
||||
**Risk:** `DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm` embeds the DB password `leocrm` in plaintext. While `.gitignore` covers `.env`, the password is weak and identical to the username. If the file is accessed via any path traversal, backup leak, or container escape, the database is fully compromised.
|
||||
**Remediation:** Use a strong unique password. Separate `DATABASE_URL` construction from credential storage where possible (e.g., use individual `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_DB` env vars and construct the URL in code).
|
||||
|
||||
---
|
||||
|
||||
## HIGH Issues
|
||||
|
||||
### H-1: Rate Limiter Trusts X-Forwarded-For Without Validation
|
||||
**File:** `app/core/rate_limit.py:43-45`
|
||||
**Risk:** `get_client_ip()` blindly trusts the `X-Forwarded-For` header. An attacker can set arbitrary values to bypass rate limits on login, password reset, and other endpoints. Each request with a different spoofed IP creates a new rate-limit counter.
|
||||
**Remediation:** Only trust `X-Forwarded-For` from known proxy IPs. Configure a trusted proxy list and validate the header chain. Use Starlette's `ProxyHeadersMiddleware` or validate against a `TRUSTED_PROXIES` env var.
|
||||
|
||||
### H-2: Duplicate `get_redis()` Functions — Connection Leak
|
||||
**File:** `app/core/auth.py:53-66` and `app/core/auth.py:94-96`
|
||||
**Risk:** Two `get_redis()` functions exist. The first (line 53) returns a singleton. The second (line 94) creates a **new Redis connection on every call**. Code importing `get_redis` may use either version. The middleware (line 69) creates its own Redis connection per request. This leads to connection pool exhaustion under load.
|
||||
**Remediation:** Remove the second `get_redis()` (line 94-96). Ensure all code uses the singleton version. The middleware should use `get_redis()` from `app.core.auth` instead of creating its own connection.
|
||||
|
||||
### H-3: CSRF Middleware Creates New Redis Connection Per Request
|
||||
**File:** `app/core/middleware.py:69-90`
|
||||
**Risk:** For every unsafe HTTP request, the middleware creates a new `aioredis.from_url()` connection, uses it, then closes it. Under load, this creates thousands of connections and can exhaust Redis connection limits.
|
||||
**Remediation:** Use the global Redis singleton via `from app.core.auth import get_redis`. Remove the per-request connection creation and the `finally: await redis.close()` block.
|
||||
|
||||
### H-4: CSRF Token Stored Plaintext in PostgreSQL
|
||||
**File:** `app/core/auth.py:141` (`SessionModel` stores `csrf_token`)
|
||||
**Risk:** The CSRF token is stored as plaintext in the PostgreSQL `sessions` table (audit trail). If the database is compromised, all active CSRF tokens are available for CSRF attacks.
|
||||
**Remediation:** Store only a hash of the CSRF token in PostgreSQL (like `hash_token()` already exists for session tokens). Compare hashes during validation.
|
||||
|
||||
### H-5: No File Upload Validation in Storage Backend
|
||||
**File:** `app/core/storage.py:69-128`
|
||||
**Risk:** `LocalStorage` performs no validation on uploaded files:
|
||||
- No path traversal protection: `os.path.join(self.base_path, path)` with a malicious `path` containing `../../` can write anywhere on the filesystem
|
||||
- No file type/extension whitelist
|
||||
- No file size limit
|
||||
- No content-type validation
|
||||
- `get_url()` returns the full filesystem path, leaking internal directory structure
|
||||
**Remediation:** Sanitize `path` with `os.path.realpath()` and verify it's within `base_path`. Enforce file size limits, extension whitelist, and MIME type validation. Return relative paths from `get_url()`, not absolute filesystem paths.
|
||||
|
||||
### H-6: WebSocket Connections Lack Authentication Verification
|
||||
**File:** `app/plugins/builtins/kommunikation/websocket_manager.py:23-28`, `app/plugins/builtins/ai_ui_control/websocket_manager.py:40-46`
|
||||
**Risk:** Both WebSocket managers accept connections via `connect(websocket, user_id)` without verifying that `user_id` is authenticated. The security depends entirely on the calling route. If any WebSocket route passes an untrusted `user_id` (e.g., from query params), an attacker can impersonate any user. There is also no origin verification on WebSocket connections.
|
||||
**Remediation:** Verify session cookie inside `connect()` before `websocket.accept()`. Validate the `Origin` header against allowed CORS origins. Add authentication middleware for WebSocket routes.
|
||||
|
||||
### H-7: In-Memory Rate Limiter in Error Endpoint — Fails with Multiple Workers
|
||||
**File:** `app/routes/errors.py:21-40`
|
||||
**Risk:** The error endpoint uses a process-local `defaultdict(deque)` for rate limiting. With multiple Uvicorn workers (common in production), each worker has its own counter. An attacker can make `RATE_LIMIT * num_workers` requests per minute.
|
||||
**Remediation:** Use the Redis-based `check_rate_limit()` from `app/core/rate_limit.py` instead of the in-memory implementation.
|
||||
|
||||
### H-8: No CSRF Protection on WebSocket Connections
|
||||
**File:** Both WebSocket managers
|
||||
**Risk:** WebSocket connections are not protected against CSRF. A malicious site can open a WebSocket to the CRM backend via JavaScript `new WebSocket()` and send commands as the authenticated user (cookies are sent automatically with SameSite=Strict for same-site, but cross-site WebSocket hijacking is still possible if SameSite is configured differently or cookies are sent via `credentials`).
|
||||
**Remediation:** Verify the `Origin` header on WebSocket upgrade requests. Reject connections from untrusted origins.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Issues
|
||||
|
||||
### M-1: Login Response Leaks `is_system_admin` Flag
|
||||
**File:** `app/routes/auth.py:78`
|
||||
**Risk:** The login response includes `"is_system_admin": user.is_system_admin`. An attacker who compromises a session or intercepts the response knows whether the account has system-wide privileges, enabling targeted attacks.
|
||||
**Remediation:** Do not include `is_system_admin` in the login response. The frontend can determine admin status via the `/me/permissions` endpoint.
|
||||
|
||||
### M-2: Weak Password Validation — No Complexity Requirements
|
||||
**File:** `app/schemas/auth.py:10` (login: `min_length=1`), `app/schemas/user.py:11` (create: `min_length=8`)
|
||||
**Risk:** Login accepts any password length (min_length=1). User creation requires min 8 chars but no complexity (uppercase, lowercase, digits, special chars). Users can set passwords like `aaaaaaaa`.
|
||||
**Remediation:** Add password complexity validation (min 12 chars, mixed case, digits, special chars) for user creation and password reset. Keep login min_length=1 to avoid leaking whether the password was partially correct.
|
||||
|
||||
### M-3: F-String Interpolation of Table/Column Names in Raw SQL
|
||||
**File:** `app/plugins/builtins/unified_search/embedding.py:194`, `search_engine.py:153`, `routes.py:294,300`, `jobs.py:183,228`
|
||||
**Risk:** Multiple raw SQL queries use f-strings to interpolate table and column names: `f"UPDATE {table} SET ..."`, `f"SELECT {emb_col} FROM {table_name} ..."`. While the values come from hardcoded `table_map` dicts (not user input), this pattern is fragile — a future change could introduce user-controlled values into the map.
|
||||
**Remediation:** Use SQLAlchemy ORM queries instead of raw SQL where possible. If raw SQL is needed, validate table/column names against an allowlist before interpolation, or use `sqlalchemy.sql.quoted_name` for safe identifier quoting.
|
||||
|
||||
### M-4: Forgejo Error Reporter Sends Full Context to External Service
|
||||
**File:** `app/plugins/builtins/forgejo_error_reporter/service.py:196-199`
|
||||
**Risk:** The error reporter serializes the entire `context` dict into the Forgejo issue body as JSON. If frontend error reporting includes sensitive data (user tokens, PII, tenant data), it will be written to an external Forgejo repository as a public issue.
|
||||
**Remediation:** Add a field-level allowlist for context data. Strip or redact sensitive keys (tokens, passwords, emails, phone numbers). Consider making Forgejo issues private/confidential.
|
||||
|
||||
### M-5: Config Has Hardcoded Default Secret Key
|
||||
**File:** `app/config.py:55`
|
||||
**Risk:** The default `secret_key = "change-me-in-production-use-a-secure-random-string"` is a known public value. While production mode checks for it, development mode silently uses it. If dev environments are exposed (even temporarily), all signed tokens are forgeable.
|
||||
**Remediation:** Remove the default value entirely. Make `secret_key` a required field with no default. Fail in all environments if not set.
|
||||
|
||||
### M-6: `LocalStorage.get_url()` Returns Absolute Filesystem Path
|
||||
**File:** `app/core/storage.py:116-117`
|
||||
**Risk:** `get_url()` returns `self._full_path(path)` which is the absolute filesystem path (e.g., `/data/uploads/tenant1/file.pdf`). If this URL is returned to the frontend or used in API responses, it leaks the internal directory structure and can aid path traversal attacks.
|
||||
**Remediation:** Return a relative path or a signed download URL that routes through an authenticated API endpoint.
|
||||
|
||||
### M-7: Inconsistent Environment Configuration in `.env`
|
||||
**File:** `.env:3,4`
|
||||
**Risk:** `.env` sets `ENVIRONMENT=development` but `SESSION_COOKIE_SECURE=true`. In development with HTTP, secure cookies won't be sent, causing auth failures. More importantly, the `ENVIRONMENT=development` setting disables the production safety checks in `get_settings()`, allowing the default `SECRET_KEY` to be used.
|
||||
**Remediation:** Use separate `.env.development` and `.env.production` files. Ensure development configs are never accidentally deployed.
|
||||
|
||||
### M-8: Permission Cache Falls Back to Stale Data on DB Error
|
||||
**File:** `app/core/permissions.py:337-344`
|
||||
**Risk:** When `_get_current_permission_version()` fails (DB error), the code sets `current_version = cached_version` and uses potentially stale cached permissions. If a user's permissions were revoked during the DB outage, they retain elevated access.
|
||||
**Remediation:** On DB error, either fail closed (deny access) or use a shorter stale-while-error TTL. Log the event as a security incident.
|
||||
|
||||
---
|
||||
|
||||
## LOW Issues
|
||||
|
||||
### L-1: `document.write()` with DOM Clone in Print Utility
|
||||
**File:** `frontend/src/utils/print.ts:54, 127`
|
||||
**Risk:** `printElement()` and `exportToPDF()` use `document.write()` with `clone.outerHTML`. If the printed DOM element contains user-controlled content (e.g., contact notes with HTML), it executes in a new window context. The new window is same-origin, limiting the impact, but it's still an unnecessary risk.
|
||||
**Remediation:** Use DOM APIs (`appendChild`, `importNode`) instead of `document.write()`. Alternatively, sanitize the cloned HTML before writing.
|
||||
|
||||
### L-2: Session Data Stored in Redis Without Encryption
|
||||
**File:** `app/core/auth.py:130-134`
|
||||
**Risk:** Session data (user_id, tenant_id, email, role, csrf_token, is_system_admin) is stored as plaintext JSON in Redis. Anyone with Redis access can read all active sessions.
|
||||
**Remediation:** Encrypt session data before storing in Redis, or accept the risk given Redis should be network-isolated. At minimum, ensure Redis requires authentication and is not exposed.
|
||||
|
||||
### L-3: No Security Headers Middleware
|
||||
**File:** No security headers middleware found
|
||||
**Risk:** The application does not set security headers like `X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`.
|
||||
**Remediation:** Add a security headers middleware or use `starlette-securehead`/`secure` package.
|
||||
|
||||
### L-4: No Origin Verification on WebSocket Upgrade
|
||||
**File:** Both WebSocket managers
|
||||
**Risk:** Neither WebSocket manager checks the `Origin` header before accepting connections. While cookies with `SameSite=Strict` provide some protection, some browsers and non-browser clients may not respect SameSite on WebSocket connections.
|
||||
**Remediation:** Check `websocket.headers.get("origin")` against `settings.cors_origin_list` before calling `websocket.accept()`.
|
||||
|
||||
### L-5: Unbounded Feedback/Command Storage in AI UI Control WebSocket
|
||||
**File:** `app/plugins/builtins/ai_ui_control/websocket_manager.py:94-103`
|
||||
**Risk:** `store_feedback()` stores feedback dicts without size limits. `cleanup_stale()` only runs when explicitly called. An attacker who can send WebSocket messages could fill memory with large feedback payloads.
|
||||
**Remediation:** Add size limits on feedback payloads. Run `cleanup_stale()` on a timer or on each `connect()`/`disconnect()`.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings
|
||||
|
||||
1. **Dockerfile security:** Multi-stage build, non-root user (`appuser` UID 1000), healthcheck configured, no secrets baked into image.
|
||||
2. **RLS implementation:** PostgreSQL Row Level Security with `FORCE` (migration 0028) ensures tenant isolation even for table owners. `set_tenant_context()` uses parameterized queries.
|
||||
3. **Password hashing:** bcrypt with configurable rounds (default 12).
|
||||
4. **Session tokens:** `secrets.token_urlsafe(32)` — cryptographically secure.
|
||||
5. **XSS protection:** `HtmlBlock.tsx` and `SignatureManager.tsx` use `DOMPurify.sanitize()` before `dangerouslySetInnerHTML`.
|
||||
6. **RBAC architecture:** Deny-list takes precedence over allow-list. Field-level permissions with strictest-wins merging. Permission version-based cache invalidation.
|
||||
7. **No user enumeration:** Password reset endpoint always returns 200.
|
||||
8. **SQL injection:** ORM queries use parameterized statements throughout. Raw SQL in `unified_search` uses hardcoded maps (not directly exploitable).
|
||||
9. **`.gitignore`** properly covers `.env`, `.env.*`, and excludes example files.
|
||||
10. **Production safety checks** in `get_settings()` validate `SECRET_KEY`, `SESSION_COOKIE_SECURE`, and `STORAGE_PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Migration & Data Loss Risks
|
||||
|
||||
1. **RLS policies:** Multiple migrations (0001, 0002, 0004, 0015, 0021, 0028) create and modify RLS policies. Migration 0028 adds `FORCE ROW LEVEL SECURITY`. Ensure all migrations are applied in order before production deployment.
|
||||
2. **Backup risk:** No backup/restore procedure found in the repository. The `last_backup_at` system setting is referenced in automation jobs but no backup script exists.
|
||||
3. **Volume persistence:** `docker-compose.yml` defines named volumes for `pgdata`, `redisdata`, and `storage`. Good for persistence, but no backup strategy documented.
|
||||
4. **Migration rollback:** Down migrations exist but should be tested. RLS policy down migrations disable RLS — running a rollback in production would expose all tenant data.
|
||||
|
||||
---
|
||||
|
||||
## Remediation Priority
|
||||
|
||||
1. **Immediate (before any production deploy):** C-1, C-2, C-3, C-4, C-5, H-1, H-2, H-3
|
||||
2. **Short-term (within 1 sprint):** H-4, H-5, H-6, H-7, H-8, M-1, M-2, M-5
|
||||
3. **Medium-term (within 2 sprints):** M-3, M-4, M-6, M-7, M-8, L-1, L-2, L-3, L-4, L-5
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
|
||||
## P1-4 — Transactional Outbox — COMPLETE ✅
|
||||
**Date**: 2026-07-25 19:17
|
||||
**Tests**: 6/6 outbox tests pass
|
||||
**Migration**: 0040_outbox.py (down_revision=0039_contact_normalize)
|
||||
|
||||
### Files Created (4 new)
|
||||
- alembic/versions/0040_outbox.py — event_outbox table with indexes
|
||||
- app/core/outbox.py — enqueue_outbox_event() + process_outbox_batch() with retry/backoff
|
||||
- app/models/outbox.py — SQLAlchemy ORM model
|
||||
- tests/test_outbox.py — 6 tests (enqueue, publish, retry, max_attempts, batch_size, empty)
|
||||
|
||||
### Files Modified (4)
|
||||
- app/core/event_bus.py — added publish_with_results(); docstring note about outbox for domain events
|
||||
- app/core/worker.py — process_outbox_job cron (every 5s, Redis distributed lock via _wrap_cron_with_lock)
|
||||
- app/services/contact_service.py — contact.created, lead.created, contact.updated → enqueue_outbox_event
|
||||
- tests/conftest.py — import EventOutbox model; add event_outbox to TRUNCATE list
|
||||
|
||||
### Verification
|
||||
- py_compile: ALL OK
|
||||
- alembic heads: single head 0040_outbox
|
||||
- pytest tests/test_outbox.py: 6/6 PASSED
|
||||
- test_contacts.py: 5 failed (pre-existing 403 RBAC issue, confirmed via git stash)
|
||||
|
||||
## 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)
|
||||
|
||||
## 2026-06-29 08:03 — T07a Complete
|
||||
- **Task**: T07a — Frontend Core SPA (Shell, Auth, Routing, i18n, UI Library, Accessibility)
|
||||
- **Commit**: 22976ab (pushed to Forgejo)
|
||||
- **Tests**: 111/111 passing (20 test files)
|
||||
- **tsc**: 0 errors
|
||||
- **Build**: Success (471KB JS, 24KB CSS gzipped)
|
||||
- **Files**: 66 files, 8598 insertions
|
||||
- **Fixes applied by orchestrator**:
|
||||
- Login form aria-label for role=form accessibility
|
||||
- Avatar img alt="" to prevent duplicate role=img
|
||||
- Avatar test null-safety with non-null assertion
|
||||
- index.css border-border → border-secondary-200 (Tailwind class missing)
|
||||
- .gitignore created to exclude node_modules/dist
|
||||
- Remote URL fixed from agent-zero to Forgejo leocrm repo
|
||||
- **Subagent**: implementation_engineer (hit context cap at ~90%, orchestrator completed remaining fixes)
|
||||
|
||||
## 2026-06-29 11:05 — T07b Complete
|
||||
- **Task**: T07b — Frontend Feature Pages
|
||||
- **Commit**: 700b7a7 (47 files, +4088 lines)
|
||||
- **Pushed**: Forgejo remote, HEAD=700b7a7
|
||||
- **Verification**: 141 tests pass, build success, tsc clean
|
||||
- **Deliverables**: 11 feature pages, 3 page updates, 13 routes, 12 test files, i18n updates, 7 shared components, 16 API hooks
|
||||
- **Subagents used**: 3 (implementation_engineer x2, a0-orchestrator-git x1)
|
||||
|
||||
## 2026-06-29 20:50 — T04 Complete
|
||||
- **Task**: T04 — DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Share Links)
|
||||
- **Commit**: fdb41da (14 files, +3760 lines)
|
||||
- **Pushed**: Forgejo remote, HEAD=fdb41da
|
||||
- **Verification**: 106 DMS tests pass (27 AC + 38 error + 41 coverage), 97.90% coverage, 412 total tests pass (full regression), 0 ruff errors
|
||||
- **Deliverables**: DMS plugin dir (6 files), 3 test files, conftest fixture sharing, pyproject.toml coverage config fix (concurrency=greenlet)
|
||||
- **Subagents used**: 2 (implementation_engineer x2 — initial + coverage improvement)
|
||||
- **Key finding**: coverage.py needed `concurrency = ["greenlet"]` for Python 3.13 async tracking
|
||||
|
||||
## 2026-06-29 14:05 — T11 Complete
|
||||
- **Task**: T11 — Tags Plugin + Permissions Plugin + Entity Links Backend
|
||||
- **Commit**: 5d18507 (26 files, +2863 lines)
|
||||
- **Pushed**: Forgejo remote, HEAD=5d18507
|
||||
- **Verification**: 68 tests pass, coverage 66.61% (dead code gaps explained)
|
||||
- **Deliverables**: 3 plugin dirs (tags, permissions, entity_links), 3 test files, migration_runner fix, builtins registration, conftest updates
|
||||
- **Subagents used**: 3 (implementation_engineer x3 — initial, fixes, coverage improvement)
|
||||
|
||||
## 2026-06-30 01:15 — T05 Complete
|
||||
- **Task**: T05 — Calendar Plugin Backend (Appointments, Tasks, Kanban, ICS, Resources, Recurrence)
|
||||
- **Commit**: 7fbeeda (14 files, +3674 lines)
|
||||
- **Pushed**: Forgejo remote, HEAD=7fbeeda
|
||||
- **Verification**: 69 calendar tests pass (33 AC + 36 recurrence unit), 86.87% coverage, 481 total tests pass (full regression), 0 ruff errors
|
||||
- **Deliverables**: Calendar plugin dir (8 files: __init__.py, plugin.py, routes.py, models.py, schemas.py, recurrence.py, ics_utils.py, migrations/0001_initial.sql), 2 test files (test_calendar.py 1075 lines, test_recurrence_unit.py), conftest.py calendar fixtures, builtins/__init__.py registration
|
||||
- **Subagents used**: 2 (implementation_engineer x2 — initial implementation + 8 bug fixes)
|
||||
- **Key fixes**: MissingGreenlet (db.refresh after flush), CSV export route ordering, ICS token commit, recurrence midnight boundary, datetime.UTC deprecation
|
||||
|
||||
## 2026-06-30 13:50 — T06: Test Fixes Complete
|
||||
- **11 test failures resolved** across all test suites
|
||||
- Input.tsx: added required={required} native attribute
|
||||
- Card.tsx: added ...rest spread for data-testid forwarding
|
||||
- CompanyForm.tsx + ContactForm.tsx: added noValidate to bypass native HTML5 validation in tests
|
||||
- Test files fixed: CompaniesList, CompanyDetail, CompanyForm, ContactsList, SettingsRoles
|
||||
- ARIA spec: aria-sort value corrected to 'ascending'
|
||||
- **Results:** 112/112 tests pass, tsc clean, vite build successful
|
||||
- **Commit:** e28d11f
|
||||
|
||||
## 2026-07-01 15:41 — T06: Mail Plugin Backend Complete
|
||||
- **Mail Plugin implementiert:** 8 neue Dateien, 4667 Zeilen
|
||||
- **14 Models:** mail_accounts, mail_folders, mails, attachments, labels, rules, templates, signatures, vacation_sent_log, seen_by, delegates, send_permissions, pgp_keys, contact_pgp_keys
|
||||
- **Features:** IMAP sync, SMTP send/reply/forward, threading, templates, rules, vacation (dedup), PGP, shared mailboxes, delegates, send permissions, HTML sanitization, FTS search, contact linking, calendar event creation
|
||||
- **Tests:** 46/46 pass, 74.56% coverage
|
||||
- **Regression:** 527/527 pass (0 failures)
|
||||
- **Ruff:** 0 errors, format clean
|
||||
- **Commit:** f646c59
|
||||
- **Risks:** Coverage 74.56% (target 80%), ILIKE fallback instead of tsvector, ARQ worker not wired
|
||||
|
||||
## 2026-07-01 16:54 — T08a: Frontend DMS + Tags + Permissions UI Complete
|
||||
- **18 neue Dateien, 6 modified** — 3368 Zeilen
|
||||
- **DMS:** File browser (folder tree + file grid), upload dropzone, preview modal, share dialog, bulk actions, trash view
|
||||
- **Tags:** TagPicker, TagCloud, BulkTagDialog — integriert in CompanyDetail + ContactDetail
|
||||
- **Permissions:** Share dialog, public share links, permission display
|
||||
- **API clients:** dms.ts, tags.ts, permissions.ts
|
||||
- **Routes:** /dms, /dms/trash
|
||||
- **i18n:** de.json + en.json translations
|
||||
- **Tests:** 33/33 new tests pass, full regression 276/276 pass
|
||||
- **tsc:** 0 errors, **vite build:** 252 modules, 3.31s
|
||||
- **Commit:** 0962f3a
|
||||
|
||||
## 2026-07-01 20:44 — T08c: Frontend Mail UI + Global Search UI Complete
|
||||
- **16 neue Dateien, 5 modified** — 4313 Zeilen
|
||||
- **Mail UI:** 3-pane layout (folder tree + mail list + reading pane), compose modal (bold/italic/link/template), reply/forward, shared mailbox selector, attachment download, create-event-from-mail
|
||||
- **Mail Settings:** 6 tabs (accounts, signatures, rules, labels, vacation, PGP)
|
||||
- **Global Search:** Tabs for companies/contacts/mails/files/events
|
||||
- **API client:** mail.ts (all endpoints)
|
||||
- **Routes:** /mail, /mail/settings
|
||||
- **i18n:** de.json + en.json translations
|
||||
- **Tests:** 44/44 new tests pass, full regression 318/318 pass
|
||||
- **tsc:** 0 errors, **vite build:** 267 modules, 5.19s
|
||||
- **Commit:** 0070fb3
|
||||
|
||||
## 2026-07-01 23:01 — T10: Monitoring, Performance, Documentation & Environment Config Complete
|
||||
- **Monitoring:** Prometheus metrics (http_requests_total, db_pool_connections, arq_jobs_total), structured JSON logging via structlog, extended health checks (DB, Redis, storage, worker)
|
||||
- **Metrics endpoint:** GET /api/v1/metrics (admin-only, text/plain Prometheus format, 403 for non-admin)
|
||||
- **Health endpoint:** Extended with database, redis, storage, worker checks — status healthy/degraded
|
||||
- **Performance:** Streaming CSV export for contacts and companies (StreamingResponse with own DB session), page_size max 100 enforced (422 for >100)
|
||||
- **Scripts:** seed_perf_data.py (--count N), check_indexes.py
|
||||
- **Documentation:** README.md updated (prod setup, API section, admin-guide link, env profiles), docs/admin-guide.md created, docs/api-overview.md created
|
||||
- **Config:** .env.example updated with SMTP, storage, secret_key vars; config.py extended with SMTP/storage/secret_key settings
|
||||
- **Dependencies:** prometheus-client, structlog added to requirements.txt
|
||||
- **Tests:** 38/38 pass (test_monitoring.py 17, test_performance.py 15, test_health.py 6) in 24.24s
|
||||
- **Ruff:** All checks passed
|
||||
- **Docs check:** README.md, docs/admin-guide.md, docs/api-overview.md all present
|
||||
|
||||
## 2026-07-01 23:15 — T10: Monitoring, Performance, Documentation Complete
|
||||
- **8 new files, 8 modified** — 2250 lines
|
||||
- **Monitoring:** Extended health (DB+Redis+Storage+Worker), Prometheus metrics (admin-only), structured JSON logging (structlog)
|
||||
- **Performance:** page_size max 100 enforced, streaming CSV export, seed_perf_data.py script
|
||||
- **Docs:** admin-guide.md, api-overview.md, README updated, .env.example updated
|
||||
- **Tests:** 38 new tests pass, full regression 564/564 pass
|
||||
- **Ruff:** all checks passed
|
||||
- **Commit:** 69e91fd
|
||||
|
||||
## 🎉 PHASE 3 COMPLETE — ALL 14 TASKS DONE
|
||||
|
||||
## 2026-07-25 19:07 — P2-1: Unified Contact Model normalisieren — COMPLETE
|
||||
- **6 files changed** (5 modified + 1 new migration)
|
||||
- **Migration 0039_contact_normalize.py**: surfix→suffix rename, Float→Numeric(5,2) for 6 discount columns with CHECK constraints (0-100), JSON→JSONB for contacts.custom and contactpersons.custom, partial unique indexes on (tenant_id, code) and (tenant_id, accounting_code)
|
||||
- **Model**: surfix→suffix, Float→Numeric(5,2), JSON→JSONB, UniqueConstraint added, Decimal import
|
||||
- **Schema**: surfix→suffix (3x), float→Decimal (18x), Decimal import
|
||||
- **Services**: contact_service.py (3x surfix→suffix), dedup_service.py (1x surfix→suffix)
|
||||
- **Frontend**: unifiedContacts.ts surfix→suffix in UnifiedContact interface
|
||||
- **Checks**: py_compile OK, alembic heads → 0039_contact_normalize (single head), comprehensive grep confirms zero surfix in source code
|
||||
- **Tests**: 1 passed, 5 failed (pre-existing 403/404 errors unrelated to P2-1)
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+23
-22
@@ -6,13 +6,15 @@
|
||||
# cp .env.docker.example .env.docker
|
||||
# $EDITOR .env.docker
|
||||
# docker compose --env-file .env.docker up --build
|
||||
#
|
||||
# Variable names MUST match docker-compose.yaml ${VARIABLE} references.
|
||||
# =============================================================================
|
||||
|
||||
# --- 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
|
||||
DB_PASSWORD=STRONG_PASSWORD_HERE
|
||||
POSTGRES_DB=crm_db
|
||||
|
||||
# --- Redis (REQUIRED) ---------------------------------------------------------
|
||||
@@ -20,43 +22,42 @@ POSTGRES_DB=crm_db
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||
REDIS_PASSWORD=STRONG_REDIS_PASSWORD_HERE
|
||||
|
||||
# --- CRM Application: Runtime DB user (NOSUPERUSER, NOBYPASSRLS) --------------
|
||||
# The app and worker use crm_runtime — RLS is enforced.
|
||||
# This user is created by migration 0044 with DML-only permissions.
|
||||
# Set RUNTIME_DB_PASSWORD to the password you want for crm_runtime.
|
||||
RUNTIME_DB_PASSWORD=STRONG_RUNTIME_PASSWORD_HERE
|
||||
DATABASE_URL=postgresql+asyncpg://crm_runtime:STRONG_RUNTIME_PASSWORD_HERE@postgres:5432/crm_db
|
||||
|
||||
# --- CRM Application: Migration DB user (owner, can run DDL) -----------------
|
||||
# Migrations and DDL operations use the owner user (crm_user).
|
||||
# This is NOT used by the app at runtime — only by prestart.sh / alembic.
|
||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
||||
|
||||
# --- SECRET_KEY (REQUIRED, min 32 chars) -------------------------------------
|
||||
# Session signing secret. MUST be at least 32 characters.
|
||||
# Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx
|
||||
|
||||
# --- Frontend URL (for email links) ------------------------------------------
|
||||
# --- Domain / Frontend URL ----------------------------------------------------
|
||||
# The public URL where users access the LeoCRM frontend.
|
||||
# Used for password reset links, invitations, etc.
|
||||
# Used for password reset links, invitations, CORS, etc.
|
||||
APP_DOMAIN=https://crm.example.com
|
||||
FRONTEND_URL=https://crm.example.com
|
||||
|
||||
# --- 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=https://crm.example.com
|
||||
|
||||
# --- Environment --------------------------------------------------------------
|
||||
ENVIRONMENT=production
|
||||
LOG_LEVEL=INFO
|
||||
SESSION_COOKIE_SECURE=true
|
||||
STORAGE_PATH=/data/storage
|
||||
|
||||
# --- SMTP (for password reset emails) -----------------------------------------
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=noreply@example.com
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASSWORD=YOUR_SMTP_PASSWORD
|
||||
SMTP_FROM_EMAIL=noreply@example.com
|
||||
SMTP_USE_TLS=true
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_TLS=true
|
||||
|
||||
# --- bcrypt tuning ----------------------------------------------------------
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# --- Admin user (seeded on first start) --------------------------------------
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=Admin123!
|
||||
|
||||
# --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
|
||||
# AES-256 encryption key for mail account passwords (Fernet).
|
||||
# Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
MAIL_ENCRYPTION_KEY=GENERATE_STRONG_KEY_HERE
|
||||
|
||||
+101
-44
@@ -1,48 +1,82 @@
|
||||
# LeoCRM v1.0 - Environment Variables Template
|
||||
# LeoCRM - Environment Variables Template
|
||||
# Copy to .env and fill in real values.
|
||||
|
||||
# === REQUIRED ===
|
||||
DATABASE_URL=postgresql+asyncpg://crm_api:your_password@localhost:5432/crm_db
|
||||
AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:your_password@localhost:5432/crm_db
|
||||
WORKER_DATABASE_URL=postgresql+asyncpg://crm_worker:your_password@localhost:5432/crm_db
|
||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:your_password@localhost:5432/crm_db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
# === COOLIFY DEPLOYMENT (required for scripts/deploy.py) ===
|
||||
# Coolify API token (required for deploy)
|
||||
COOLIFY_API_TOKEN=
|
||||
# Coolify base URL
|
||||
COOLIFY_BASE_URL=https://server.media-on.de
|
||||
# Application UUID (optional — resolved via API lookup by APP_NAME if absent)
|
||||
COOLIFY_APP_UUID=
|
||||
# Worker Service UUID (optional — resolved via API lookup by WORKER_NAME if absent)
|
||||
COOLIFY_WORKER_UUID=
|
||||
# Application name for API lookup
|
||||
APP_NAME=leocrm
|
||||
# Worker name for API lookup
|
||||
WORKER_NAME=leocrm-worker
|
||||
# App domain (required for deploy, used for health check and FQDN)
|
||||
APP_DOMAIN=https://crm.media-on.de
|
||||
|
||||
# === REQUIRED for Docker/Production ===
|
||||
# Redis password (required in Docker)
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
# === COOLIFY INITIAL DEPLOY (only needed for --initial) ===
|
||||
# Coolify project UUID
|
||||
COOLIFY_PROJECT_UUID=
|
||||
# Coolify server UUID
|
||||
COOLIFY_SERVER_UUID=
|
||||
# Coolify private key UUID (for Git deploy key)
|
||||
COOLIFY_PRIVATE_KEY_UUID=
|
||||
# Coolify environment name
|
||||
COOLIFY_ENVIRONMENT=production
|
||||
|
||||
# === OPTIONAL (with defaults) ===
|
||||
# === DATABASE (required) ===
|
||||
# Single password for all DB roles (crm_user, crm_api, crm_auth, crm_worker, crm_migration)
|
||||
DB_PASSWORD=
|
||||
# Database name
|
||||
POSTGRES_DB=crm_db
|
||||
# Database user (superuser/owner)
|
||||
POSTGRES_USER=crm_user
|
||||
# Database host (container name in Docker network)
|
||||
DB_HOST=postgres
|
||||
# Full database URLs (constructed from DB_PASSWORD/DB_HOST if not set explicitly)
|
||||
DATABASE_URL=postgresql+asyncpg://crm_api:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
WORKER_DATABASE_URL=postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_user:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
|
||||
# === REDIS (required) ===
|
||||
REDIS_PASSWORD=
|
||||
REDIS_HOST=redis
|
||||
REDIS_URL=redis://default:${REDIS_PASSWORD}@redis:6379/0
|
||||
|
||||
# === SECURITY (required) ===
|
||||
# Secret key for signing, sessions (use a secure random string >= 32 chars)
|
||||
SECRET_KEY=
|
||||
|
||||
# === SSH VERIFICATION (optional, deploy.py verification only) ===
|
||||
SSH_KEY=/a0/usr/workdir/.ssh/coolify-01-root
|
||||
SERVER_IP=46.225.91.159
|
||||
# Login test credentials (optional, for deploy verification)
|
||||
LOGIN_EMAIL=
|
||||
LOGIN_PASSWORD=
|
||||
|
||||
# === APPLICATION ===
|
||||
# Environment: development | production | testing
|
||||
ENVIRONMENT=development
|
||||
|
||||
ENVIRONMENT=production
|
||||
# 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
|
||||
CORS_ORIGINS=https://crm.media-on.de
|
||||
# Frontend URL
|
||||
FRONTEND_URL=https://crm.media-on.de
|
||||
# Session cookie secure (true in production)
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Secret Key (for signing, sessions — use a secure random string ≥32 chars in prod)
|
||||
SECRET_KEY=change-me-in-production-use-a-secure-random-string
|
||||
# === DOCKER COMPOSE (optional overrides) ===
|
||||
# Traefik host
|
||||
APP_HOST=crm.media-on.de
|
||||
APP_PORT=8000
|
||||
|
||||
# Storage (file uploads, DMS)
|
||||
STORAGE_PATH=/tmp
|
||||
# === STORAGE ===
|
||||
STORAGE_PATH=/data/storage
|
||||
# Storage backend: local (default) or s3
|
||||
STORAGE_BACKEND=local
|
||||
# S3-compatible storage (when STORAGE_BACKEND=s3)
|
||||
@@ -53,15 +87,15 @@ S3_SECRET_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_SECURE=true
|
||||
|
||||
# SMTP / Email
|
||||
# === SMTP / EMAIL ===
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=noreply@leocrm.local
|
||||
SMTP_USE_TLS=true
|
||||
SMTP_FROM=no-reply@localhost
|
||||
SMTP_TLS=true
|
||||
|
||||
# Rate limiting
|
||||
# === RATE LIMITING ===
|
||||
RATE_LIMIT_LOGIN_MAX=5
|
||||
RATE_LIMIT_LOGIN_WINDOW=900
|
||||
RATE_LIMIT_RESET_MAX=3
|
||||
@@ -71,10 +105,33 @@ RATE_LIMIT_RESET_CONFIRM_WINDOW=3600
|
||||
RATE_LIMIT_GENERAL_MAX=60
|
||||
RATE_LIMIT_GENERAL_WINDOW=60
|
||||
|
||||
# === AI / Search ===
|
||||
# Ollama Cloud API Key (für LiteLLM)
|
||||
# === DATABASE POOL ===
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_ECHO=false
|
||||
|
||||
# === SESSION ===
|
||||
SESSION_TTL_SECONDS=28800
|
||||
SESSION_COOKIE_NAME=leocrm_session
|
||||
SESSION_COOKIE_SAMESITE=strict
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
|
||||
# === PASSWORD HASHING ===
|
||||
BCRYPT_ROUNDS=12
|
||||
PASSWORD_RESET_EXPIRY_HOURS=1
|
||||
|
||||
# === AI / SEARCH ===
|
||||
# Ollama Cloud API Key (for LiteLLM)
|
||||
API_KEY_OLLAMA_CLOUD=
|
||||
# Embedding Modell (default: ollama/nomic-embed-text)
|
||||
# Embedding model (default: ollama/nomic-embed-text)
|
||||
SEARCH_EMBEDDING_MODEL=ollama/nomic-embed-text
|
||||
# LLM Modell für Query Understanding (default: ollama/deepseek-v4)
|
||||
# LLM model for query understanding (default: ollama/deepseek-v4)
|
||||
SEARCH_LLM_MODEL=ollama/deepseek-v4
|
||||
|
||||
# === GIT (for initial deployment) ===
|
||||
API_GIT_REPO=https://forgejo.media-on.de/Leopoldadmin/leocrm.git
|
||||
API_GIT_BRANCH=main
|
||||
|
||||
# === Admin User (auto-seeded on first start) ===
|
||||
ADMIN_EMAIL=admin@media-on.de
|
||||
ADMIN_PASSWORD=Admin123!
|
||||
|
||||
@@ -20,6 +20,6 @@ jobs:
|
||||
- name: Install Python deps
|
||||
run: pip install -r requirements.txt
|
||||
- name: Install Frontend deps
|
||||
run: cd frontend && npm ci
|
||||
run: cd frontend && npm ci --legacy-peer-deps
|
||||
- name: Run CI/CD Pipeline
|
||||
run: bash scripts/ci_pipeline.sh
|
||||
|
||||
+8
-34
@@ -11,21 +11,21 @@ __pycache__/
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
/build/
|
||||
/dist/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
/venv/
|
||||
/env/
|
||||
/ENV/
|
||||
|
||||
# Test and coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
/htmlcov/
|
||||
coverage.xml
|
||||
.mypy_cache/
|
||||
|
||||
@@ -49,44 +49,18 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
/logs/
|
||||
.ruff_cache/
|
||||
|
||||
# Redis dumps
|
||||
dump.rdb
|
||||
*.rdb
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
/data/
|
||||
|
||||
# Alembic (autogenerated migrations excluded, but keep 0001)
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Docker
|
||||
.docker-data/
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
@@ -1,571 +1,335 @@
|
||||
# LeoCRM — AGENTS.md
|
||||
|
||||
**Projekt:** leocrm
|
||||
**Erstellt:** 2026-06-28
|
||||
**Status:** Draft — ready for implementation
|
||||
**Projekt:** leocrm | **Stack:** FastAPI + SQLAlchemy + PostgreSQL 16 (pgvector) + React/TypeScript/Vite/Tailwind
|
||||
|
||||
---
|
||||
|
||||
## 0. BINDENDE REGEL: Auf bestehendem Code aufbauen (NICHT VERHANDELBAR)
|
||||
|
||||
### 0.0 Sub-Agents / Subordinates — Nuancierte Regel
|
||||
|
||||
**Sub-Agents (call_subordinate) nur für einfache Jobs verwenden.**
|
||||
|
||||
- Einfache Jobs: Research, Codebase-Exploration, Dokumentations-Zusammenfassung — Aufgaben ohne Code-Änderungen oder Schema-Migrationen.
|
||||
- Komplexe Jobs (Code-Änderungen, Tests, Migrationen, Deployments): vom Haupt-Agent selbst ausführen.
|
||||
- Wenn der User sagt "keine Sub-Agents verwenden": daran halten, keine Ausnahmen.
|
||||
- Sub-Agents haben in der Vergangenheit Code geschrieben der nicht gegen Produktion verifiziert wurde, Schema-Drifts verursacht und nicht getestet hat. Qualitätssicherung bleibt beim Haupt-Agent.
|
||||
|
||||
**Gültig für jegliche Arbeit an diesem Projekt.**
|
||||
|
||||
### 0.1 Pflicht zur Analyse vor Implementierung
|
||||
|
||||
Der Agent MUSS vor jeder Implementierung das bestehende System analysieren:
|
||||
|
||||
1. **Backend lesen:** Welche Models, Routes, Services, Plugins, Contracts, Hooks, ARQ-Jobs existieren bereits für den betroffenen Bereich? Der Agent greppt und liest die relevanten Dateien BEVOR er Code schreibt.
|
||||
2. **Frontend lesen:** Welche Pages, Components, Stores, Hooks, API-Clients, Block-Typen, Sidebar-Tabs existieren bereits für den betroffenen Bereich? Der Agent greppt und liest die relevanten Dateien BEVOR er Code schreibt.
|
||||
3. **Datenbank lesen:** Welche Tabellen, Foreign Keys, RLS-Policies, Migrationen existieren bereits? Der Agent prüft `alembic/versions/` und die Produktions-DB BEVOR er neue Migrationen schreibt.
|
||||
4. **Plugin-System lesen:** Welche Contracts, Manifests, Search Provider, Tools, Hooks existieren bereits in den betroffenen Plugins? Der Agent liest `plugin.py`, `contracts.py`, `manifest.py` BEVOR er neue Plugins oder Erweiterungen baut.
|
||||
|
||||
### 0.2 Pflicht zum Aufbau auf bestehendem Code
|
||||
|
||||
Der Agent MUSS auf bestehendem Code aufbauen. Es ist VERBOTEN:
|
||||
|
||||
- ❌ Parallele Systeme zu bauen die vorhandene Funktionalität duplizieren (z.B. ein separates Workstream-System wenn das `kommunikation` Plugin schon Conversations, Messages, Blocks, WebSocket hat)
|
||||
- ❌ Neue Frontend-Pages zu bauen wenn vorhandene Pages die Funktion aufnehmen können (z.B. Dashboard, Communication, AgentDashboard, Workflows, Wiki, Settings)
|
||||
- ❌ Neue Sidebars oder Panels zu bauen wenn die AISidebar (5 Tabs) oder MessageSidebar die Funktion aufnehmen können
|
||||
- ❌ Neue Stores zu bauen wenn vorhandene Stores (commStore, uiStore, authStore, etc.) die Funktion aufnehmen können
|
||||
- ❌ Neue API-Clients zu bauen wenn vorhandene API-Clients (api/comm.ts, api/ai.ts, api/automation.ts, etc.) die Funktion abdecken können
|
||||
- ❌ Neue Block-Typen zu bauen wenn vorhandene Block-Typen (action_card, contact_card, miniapp, etc.) die Funktion abdecken können
|
||||
- ❌ Dataclasses zu schreiben wenn echte SQLAlchemy Models + FastAPI Routes die richtige Lösung sind
|
||||
- ❌ Mock-Tests zu schreiben wenn echte Integration-Tests mit der Test-DB möglich sind
|
||||
- ❌ Module zu bauen die 0 Referenzen aus Routes/Plugins haben (unverbundener Code)
|
||||
- ❌ Tasks als "done" zu markieren ohne echte Verifizierung (curl gegen echte API, grep-Beweis für Import-Verbindungen, tsc clean, Backend import OK)
|
||||
|
||||
### 0.3 Pflicht zur Verbindung
|
||||
|
||||
Jeder neue Code MUSS mit dem bestehenden System verbunden werden:
|
||||
|
||||
- **Backend:** Neue Module müssen in `app/main.py` oder in Plugin `routes.py` registriert werden. Neue Models müssen in `alembic/versions/` migriert werden. Neue Tools müssen im `tool_registry` registriert werden. Neue Hooks müssen in `plugin.py on_activate` registriert werden. Neue ARQ-Jobs müssen in `worker.py` registriert werden.
|
||||
- **Frontend:** Neue Components müssen in vorhandene Pages integriert werden (nicht als neue Page). Neue API-Calls müssen vorhandene API-Clients nutzen oder erweitern. Neue Block-Typen müssen im `BlockRenderer.tsx` registriert werden. Neue Sidebar-Tabs müssen in der `AISidebar.tsx` registriert werden.
|
||||
- **Verifizierung:** Der Agent beweist mit grep dass neue Module importiert/referenziert werden. Der Agent beweist mit curl/pytest dass die API funktioniert. Der Agent markiert nichts als "done" ohne diese Beweise.
|
||||
|
||||
### 0.4 Referenz-Architektur (was existiert und genutzt werden MUSS)
|
||||
|
||||
**Frontend-Struktur:**
|
||||
- `AISidebar.tsx` — 5 Tabs: chat (KI Chat), proactive (Live KI/Suggestions), notifications, team, chatroom (Communication)
|
||||
- `MessageSidebar.tsx` (671 Zeilen) — voller Chat mit Conversations, Messages, WebSocket, BlockRenderer
|
||||
- `Communication.tsx` (859 Zeilen) — volle Chat-Seite mit Conversations (system/ai/colleague), Messages, Blocks, Pin/Unpin, Read
|
||||
- `comm/blocks/` — 10 Block-Typen: text, markdown, html, image, audio, video, file, action_card, contact_card, miniapp
|
||||
- `BlockRenderer.tsx` — rendert alle Block-Typen
|
||||
- `Dashboard.tsx` — StatCards, ActivityFeed, DashboardGrid mit Widgets
|
||||
- `AgentDashboard.tsx` — Agent CRUD, Execute, Test Run, Versions, Restore, Tools, Send Message
|
||||
- `Workflows.tsx` — Workflow CRUD, Instances, Editor, Step Config
|
||||
- `Wiki.tsx` — Categories, Articles, Markdown Editor, Version History, Restore
|
||||
- `components/knowledge/` — AskKnowledge.tsx, KnowledgeGraph.tsx
|
||||
- `components/onboarding/` — OnboardingTour.tsx, WelcomeDialog.tsx
|
||||
- `components/agents/` — AgentChat, AgentEditor, AgentMonitor, AgentRunLog
|
||||
- `components/workflows/` — StepConfigPanel, WorkflowEditor, WorkflowInstanceList, WorkflowInstanceDetail
|
||||
- `components/dashboard/` — DashboardGrid, RecentContactsWidget, TasksSummaryWidget, CalendarUpcomingWidget
|
||||
- `store/commStore.ts` — Conversation, Message, MessageBlock, MessageAttachment, Participant
|
||||
- `store/uiStore.ts` — aiSidebarCollapsed, aiSidebarTab, notifications
|
||||
- `api/comm.ts` — listConversations, getMessages, sendMessage, markRead, createConversation
|
||||
- `api/ai.ts` — createSession, fetchSessions, streamChat, fetchAgents
|
||||
- `api/automation.ts` — useAgents, useCreateAgent, useUpdateAgent, useDeleteAgent, useExecuteAgent, useTestRunAgent, useAgentRuns, useAgentVersions, useRestoreAgentVersion, useAgentTools, useSendAgentMessage
|
||||
- `api/workflows.ts` — useWorkflows, useDeleteWorkflow, useUpdateWorkflow
|
||||
- `api/knowledge.ts` — createWikiArticle, deleteWikiArticle, fetchWikiArticle, fetchWikiCategories, fetchWikiVersions, restoreWikiVersion, updateWikiArticle
|
||||
|
||||
**Backend-Struktur:**
|
||||
- `kommunikation` Plugin — CommConversation, CommParticipant, CommMessage, CommMessageBlock, WebSocket, Contracts, MiniAppRegistry
|
||||
- `automation` Plugin — AgentDefinition, AgentRun, AgentRunStep, Triggers, Schedules, Pre-built Agents
|
||||
- `unified_search` Plugin — 14 Search Provider, Hybrid Search, Embeddings
|
||||
- `graph_rag` Plugin — Knowledge Graph, Relationships, Entities
|
||||
- `wiki` Plugin — WikiArticle, WikiCategory, WikiArticleVersion, Entity Links
|
||||
- `ai_assistant` Plugin — Tool Registry, CRM API Tool, AI Chat
|
||||
- `ai_proactive` Plugin — Proactive Suggestions, Context Tools
|
||||
- `agent_memory` Plugin — Agent Memory with Embeddings
|
||||
- `permissions` Plugin — ABAC/RBAC, Entity Permissions, Share Links
|
||||
- `app/ai/` — agent_loop.py, agent_runner.py, llm_client.py, context_builder.py, agent_permissions.py, agent_tools.py, data_policy.py, transparency.py, oversight.py, agent_stream.py, skill_registry.py, ai_use_case.py
|
||||
- `app/workflows/` — engine.py, step_handlers.py, decision_guard.py
|
||||
- `app/core/` — approval.py, hooks.py, outbox.py, worker.py, storage.py, monitoring.py, notifications.py
|
||||
- `app/routes/` — 468 API Routes über alle Plugins und Core-Module
|
||||
|
||||
**Datenbank:**
|
||||
- 130 Tabellen, 159 Foreign Keys, 590 Indexes
|
||||
- 114 Tabellen mit RLS (Row Level Security)
|
||||
- 130 Alembic Migrationen (Head: 0130)
|
||||
- `set_tenant_context()` setzt `app.current_tenant_id` für RLS
|
||||
|
||||
### 0.5 Konsequenzen bei Verstoss
|
||||
|
||||
Wenn der Agent gegen diese Regel verstösst:
|
||||
1. Der Code wird nicht akzeptiert
|
||||
2. Der Agent muss den Code löschen und auf bestehendem Code neu aufbauen
|
||||
3. Der Agent muss den Verstoß dokumentieren und erklären warum er die Regel ignoriert hat
|
||||
4. Der Agent muss PROVE dass der neue Code mit grep-imports verbunden ist BEVOR er als done markiert wird
|
||||
|
||||
---
|
||||
|
||||
## 1. Build & Test Commands
|
||||
|
||||
### Backend (Python / FastAPI)
|
||||
|
||||
#### Setup
|
||||
```bash
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
#### Run Dev Server
|
||||
```bash
|
||||
|
||||
# Backend
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
#### Database Migrations (Alembic)
|
||||
```bash
|
||||
|
||||
# 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
|
||||
|
||||
python -m pytest -v --tb=short
|
||||
```
|
||||
|
||||
#### Run Specific Test File
|
||||
```bash
|
||||
|
||||
python -m pytest tests/test_auth.py -v --tb=short
|
||||
```
|
||||
alembic upgrade head
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
#### Run Tests with Coverage
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm run dev
|
||||
cd frontend && npm run build
|
||||
cd frontend && npx vitest run --reporter=verbose
|
||||
cd frontend && npx tsc --noEmit
|
||||
|
||||
python -m pytest --cov=app --cov-report=term-missing --cov-report=html
|
||||
```
|
||||
|
||||
#### Run Tests with Grep Filter
|
||||
```bash
|
||||
|
||||
python -m pytest -k 'tenant or auth' -v
|
||||
```
|
||||
|
||||
#### Type Checking
|
||||
```bash
|
||||
|
||||
mypy app/ --ignore-missing-imports
|
||||
```
|
||||
|
||||
#### Linting
|
||||
```bash
|
||||
|
||||
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
|
||||
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.**
|
||||
- TDD: failing test first → implement → refactor
|
||||
- NEVER modify tests to make them pass — fix the code
|
||||
- Test DB: ephemeral PostgreSQL, NEVER production DB
|
||||
- Mock external services (SMTP, IMAP, OnlyOffice) with AsyncMock
|
||||
- Tests must be deterministic and isolated
|
||||
|
||||
---
|
||||
|
||||
## 3. Conventions
|
||||
## 3. Code Conventions
|
||||
|
||||
### Backend Structure
|
||||
### Backend
|
||||
- Async first: all routes/services `async def`
|
||||
- UUID primary keys only, never integer auto-increment
|
||||
- TIMESTAMPTZ only, never naive datetime
|
||||
- Soft-delete via `deleted_at IS NULL`; hard-delete only with `?gdpr=true`
|
||||
- Pydantic schemas validate input, never validate in routes
|
||||
- All mutations create audit log entries
|
||||
- snake_case files/functions, PascalCase classes
|
||||
- Schemas: `<Entity>Create`, `<Entity>Update`, `<Entity>Read`
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
### Frontend
|
||||
- TypeScript strict, no `any`
|
||||
- Functional components only, no class components
|
||||
- TanStack Query for server state, Zustand for client state only
|
||||
- React Hook Form + Zod for all forms
|
||||
- Tailwind utility classes, no inline styles
|
||||
- i18n via `t()` from react-i18next, no hardcoded strings
|
||||
- ARIA attributes on all interactive elements, 44px touch targets
|
||||
- PascalCase.tsx for components, camelCase.ts for utilities
|
||||
|
||||
### 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
|
||||
### Git
|
||||
- Conventional Commits: `feat(core): ...`, `fix(dms): ...`
|
||||
- Squash merge to main after review
|
||||
|
||||
---
|
||||
|
||||
## 4. Task-Zuweisung (Subagenten pro Task)
|
||||
## 4. Forbidden Patterns
|
||||
|
||||
### Phasen-Plan
|
||||
### Backend
|
||||
- ❌ SQLite — PostgreSQL 16 only
|
||||
- ❌ Jinja2/server-side HTML rendering — API-only backend
|
||||
- ❌ Cross-tenant data access — ORM auto-filter must not be bypassed
|
||||
- ❌ Plaintext passwords — bcrypt cost=12
|
||||
- ❌ JWT auth — session-based with HttpOnly cookies only
|
||||
- ❌ Naive datetime — TIMESTAMPTZ only
|
||||
- ❌ Integer IDs — UUID only
|
||||
- ❌ Hard-delete without `?gdpr=true`
|
||||
- ❌ Manual tenant filter — ORM auto-filter handles it
|
||||
- ❌ Sync I/O in routes — use asyncpg, aiofiles
|
||||
- ❌ Raw SQL without tenant_id check
|
||||
- ❌ Secrets in code — env vars only
|
||||
- ❌ Unvalidated input — Pydantic schemas required
|
||||
- ❌ Missing audit log on mutations
|
||||
- ❌ Plugin tables without tenant_id
|
||||
|
||||
#### v1 Core Phases (Phase 3 — Implementation)
|
||||
### Frontend
|
||||
- ❌ Class components
|
||||
- ❌ Inline styles — Tailwind only
|
||||
- ❌ Hardcoded strings — use `t()`
|
||||
- ❌ Manual fetch/axios in components — use TanStack Query
|
||||
- ❌ Server data in Zustand
|
||||
- ❌ `any` types
|
||||
- ❌ Missing ARIA attributes
|
||||
- ❌ Touch targets < 44px
|
||||
- ❌ Direct DOM manipulation — use React refs
|
||||
- ❌ `dangerouslySetInnerHTML` without sanitization
|
||||
|
||||
| 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.
|
||||
### Deployment
|
||||
- ❌ Running as root in container — use app:app
|
||||
- ❌ Exposed DB port in production
|
||||
- ❌ Missing Docker health checks
|
||||
- ❌ Ephemeral storage — use named volumes
|
||||
- ❌ Secrets in docker-compose.yml
|
||||
|
||||
---
|
||||
|
||||
## 5. Forbidden Patterns
|
||||
## 5. Quality Gates
|
||||
|
||||
### 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.
|
||||
- Per-Task: tests pass, coverage met, tsc/ruff clean, build succeeds, no forbidden patterns
|
||||
- Phase: all tasks pass → quality_reviewer review → user checkpoint
|
||||
- Release: all tasks complete → release_auditor audit → Docker builds → health 200 → E2E pass
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality Gates
|
||||
## 6. ADRs
|
||||
|
||||
### 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-03: Built-in plugins with manifest (not pip-install)
|
||||
- ADR-04: TanStack Query (not Redux)
|
||||
- ADR-05: Session-based auth (not JWT)
|
||||
- ADR-06: Soft-delete with `deleted_at` column
|
||||
- ADR-06: Soft-delete with `deleted_at`
|
||||
|
||||
Full architecture: `architecture.md` | Full task graph: `task_graph.json`
|
||||
|
||||
---
|
||||
|
||||
## Handoff
|
||||
## 7. Deploy
|
||||
|
||||
- **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
|
||||
**Vor Deploy:** `docs/deploy-guide.md` lesen (Befehle, Credentials, Server-Info).
|
||||
|
||||
- Frontend-only: `bash /a0/usr/projects/leocrm/scripts/fast-deploy.sh frontend`
|
||||
- Full (Backend): `bash /a0/usr/projects/leocrm/scripts/fast-deploy.sh full`
|
||||
- Git Workflow: commit → push → deploy
|
||||
|
||||
---
|
||||
|
||||
## 8. Dokumentations-Pflichten
|
||||
|
||||
### Wichtige MD-Dateien im Projekt
|
||||
|
||||
| Datei | Zweck |
|
||||
|-------|------|
|
||||
| `README.md` | Projekt-Overview, Setup |
|
||||
| `PLATFORM_ROADMAP.md` | EINZIGE Planungs-Datei für zukünftige Entwicklung, Umbauten, Roadmap. Alle Phasen, Tasks und Architekturentscheidungen |
|
||||
| `PROGRESS.md` | Fortschritts-Tracking — pro Task: Status, Forgejo Issue, Verifiziert. Wird vom Agent bei jedem Status-Wechsel aktualisiert |
|
||||
| `AGENTS.md` | Agent-Definitionen (diese Datei) |
|
||||
| `docs/test-strategy.md` | Test-Strategie, Konventionen, Einschränkungen |
|
||||
| `docs/security_kernel.md` | Security-Konzept (ABAC, RLS, Session) |
|
||||
| `docs/permissions.md` | Permission-System-Dokumentation |
|
||||
| `docs/permissions_plugin_dev.md` | Permission-Plugin-Entwicklung |
|
||||
| `docs/monitoring.md` | Monitoring, Health-Checks |
|
||||
| `docs/infrastructure.md` | Infrastruktur (Docker, PostgreSQL, Redis) |
|
||||
| `docs/admin-guide.md` | Admin-Handbuch |
|
||||
| `docs/api-documentation.md` | API-Dokumentation |
|
||||
| `docs/INSTALL.md` | Installationsanleitung |
|
||||
| `docs/plugin-development-guide.md` | Plugin-Entwicklungs-Guide |
|
||||
| `docs/ui-design-guidelines.md` | UI-Design-Richtlinien |
|
||||
| `docs/deploy-guide.md` | Deploy-Anleitung, Credentials, Server-Info |
|
||||
|
||||
### Pflicht: Aktualisierung nach größeren Änderungen
|
||||
|
||||
**Nach jeder größeren Änderung MÜSSEN die betroffenen MD-Dateien überarbeitet werden:**
|
||||
|
||||
1. Neue Plugins/Module → `docs/plugin-development-guide.md`, `docs/api-documentation.md`, `docs/test-strategy.md`
|
||||
2. Security-Änderungen → `docs/security_kernel.md`, `docs/permissions.md`, `docs/test-strategy.md`
|
||||
3. Neue Test-Infrastruktur → `docs/test-strategy.md`
|
||||
4. CI-Pipeline-Änderungen → `docs/test-strategy.md`, `docs/infrastructure.md`
|
||||
5. Größere Refactoring → `README.md`, betroffene `docs/`-Dateien, `docs/test-strategy.md`
|
||||
6. Nach Bugfix-Session → `docs/test-strategy.md`, `docs/security_kernel.md`
|
||||
7. Roadmap-Änderungen → `PLATFORM_ROADMAP.md`
|
||||
8. Infrastruktur-Änderungen → `docs/infrastructure.md`, `docs/INSTALL.md`
|
||||
9. UI/UX-Änderungen → `docs/ui-design-guidelines.md`
|
||||
10. API-Änderungen → `docs/api-documentation.md`
|
||||
|
||||
**Verantwortlich:** Agent/Entwickler der die Änderung durchführt.
|
||||
|
||||
### Test-Konventionen (MUST FOLLOW)
|
||||
|
||||
**Vor Tests:** `docs/test-strategy.md` lesen für vollständige Konventionen und Einschränkungen.
|
||||
|
||||
1. Plugin-Aktivierung: `init_permission_registry(active_plugin_names={...})` in jeder Plugin-Test-Datei
|
||||
2. Entity-Typen: Korrekte ENTITY_MODELS-Keys (`file` nicht `dms_file`, `mail_account` nicht `mailbox`)
|
||||
3. URLs: Korrekte API-Pfade (`/api/v1/entity-links/` nicht `/api/v1/dms/`)
|
||||
4. Dedup-Tests: Unterschiedlichen Dateiinhalt pro Upload verwenden
|
||||
5. Keine zufälligen UUIDs: Echte Entity-IDs aus der DB verwenden
|
||||
6. Test-Dateien: `tests/test_<modul>.py` | Fixtures: `tests/conftest.py`
|
||||
|
||||
---
|
||||
|
||||
## 9. Progress-Tracking & Forgejo-Issue-Verwaltung
|
||||
|
||||
### Planungs- und Fortschrittsdateien
|
||||
|
||||
| Datei | Zweck | Wann aktualisieren |
|
||||
|-------|------|-------------------|
|
||||
| `PLATFORM_ROADMAP.md` | EINZIGE Planungs-Datei. Alle Phasen, Tasks, Architekturentscheidungen | Bei Planungsänderungen |
|
||||
| `PROGRESS.md` | Fortschritts-Tracking. Pro Task: Status, Forgejo Issue, Verifiziert | Bei jedem Task-Status-Wechsel |
|
||||
|
||||
### Task-Status-Verwaltung
|
||||
|
||||
Jeder Task in der Roadmap hat einen Status der in `PROGRESS.md` verfolgt wird:
|
||||
|
||||
- `not_started` — Task noch nicht begonnen
|
||||
- `in_progress` — Task wird bearbeitet
|
||||
- `blocked` — Task blockiert (Abhängigkeit fehlt, Entscheidung ausstehend)
|
||||
- `review` — Task implementiert, wartet auf Review/Tests
|
||||
- `done` — Task hat Definition of Done (DoD) erfüllt
|
||||
|
||||
**Der Agent MUSS `PROGRESS.md` bei jedem Status-Wechsel aktualisieren.** Kein Task-Wechsel ohne PROGRESS.md-Update.
|
||||
|
||||
### Forgejo Issues & Milestones
|
||||
|
||||
- **Pro Phase (A-J):** Ein Forgejo Milestone (z.B. "Phase A — Stabilität", "Phase B — System-Konsolidierung")
|
||||
- **Pro Task:** Ein Forgejo Issue mit Label `task` + Milestone der jeweiligen Phase
|
||||
- **Pro Bug:** Ein Forgejo Issue mit Label `bug` + Priorität (`critical`, `high`, `medium`, `low`)
|
||||
- **Pro Feature-Request:** Ein Forgejo Issue mit Label `enhancement`
|
||||
|
||||
**Der Agent MUSS für jeden Task ein Forgejo Issue erstellen** und die Issue-Nummer in `PROGRESS.md` eintragen.
|
||||
|
||||
### Commit-Messages
|
||||
|
||||
- Commit-Messages enthalten die Task-ID: `feat(B-LLM): zentraler LLM Client implementiert`
|
||||
- Bug-Fixes referenzieren das Issue: `fix(#123): Redis-Connection-Leak behoben`
|
||||
- `fixes #123` oder `closes #123` im Commit schließt das Issue automatisch
|
||||
|
||||
### Definition of Done (DoD)
|
||||
|
||||
Ein Task gilt erst als **DONE** wenn alle 8 DoD-Kriterien erfüllt sind (siehe `PLATFORM_ROADMAP.md`). Ein Task ohne Test ist NICHT done. Der Agent darf keinen Task als `done` markieren ohne DoD erfüllt zu haben.
|
||||
|
||||
### Phase-Gate-Review
|
||||
|
||||
Eine Phase gilt erst als **ABGESCHLOSSEN** wenn alle 7 Phase-Gate-Kriterien erfüllt sind (siehe `PLATFORM_ROADMAP.md`). Der Agent darf nicht zur nächsten Phase übergehen ohne Phase-Gate-Review bestanden zu haben.
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
# 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 **three 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).
|
||||
3. The **crm-worker** Application (same Dockerfile build, different entrypoint).
|
||||
|
||||
The 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).
|
||||
The worker is not exposed publicly — it only needs Redis and PostgreSQL access.
|
||||
|
||||
---
|
||||
|
||||
## 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`. |
|
||||
|
||||
|
||||
### 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"}
|
||||
> ]
|
||||
> }'
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 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**: `/api/v1/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`
|
||||
|
||||
---
|
||||
|
||||
## 11. Resource C — crm-worker (Background Worker)
|
||||
|
||||
The crm-worker runs the ARQ background worker and scheduler in a separate
|
||||
container, using the same Docker image as crm-app but with a different
|
||||
entrypoint (`/app/worker.sh` instead of `/app/prestart.sh`).
|
||||
|
||||
### Setup in Coolify UI
|
||||
|
||||
1. In the same project/environment as crm-app, **+ Add → Application →
|
||||
Public/Private Repository**.
|
||||
2. Fill in:
|
||||
- **Git repository**: same as crm-app (`https://forgejo.media-on.de/Leopoldadmin/leocrm.git`)
|
||||
- **Branch**: `main`
|
||||
- **Build pack**: `Dockerfile`
|
||||
- **Dockerfile location**: `Dockerfile` (same image)
|
||||
- **Port**: `8000` (not used, but Coolify requires a port)
|
||||
- **Custom Entrypoint**: `/app/worker.sh`
|
||||
3. Click **Deploy** once to create the resource.
|
||||
4. Note the **Application UUID**.
|
||||
|
||||
### Environment variables (on the crm-worker resource)
|
||||
|
||||
Set the same variables as crm-app, except:
|
||||
|
||||
| Key | Value | Notes |
|
||||
|-----|-------|-------|
|
||||
| `DATABASE_URL` | same as crm-app | |
|
||||
| `REDIS_URL` | same as crm-app | |
|
||||
| `SECRET_KEY` | same as crm-app | |
|
||||
| `ENVIRONMENT` | `production` | |
|
||||
| `LOG_LEVEL` | `INFO` | |
|
||||
| `STORAGE_PATH` | `/data/storage` | |
|
||||
|
||||
No domain is needed — the worker is not publicly accessible.
|
||||
|
||||
### Healthcheck (Coolify side)
|
||||
|
||||
- **Healthcheck path**: `/api/v1/health` (not used by worker, but Coolify requires one)
|
||||
- Alternatively, use a custom healthcheck command:
|
||||
`pgrep -f "arq app.core.worker.WorkerSettings" || exit 1`
|
||||
|
||||
### Scaling
|
||||
|
||||
To scale the worker horizontally, deploy multiple crm-worker instances.
|
||||
Cron jobs use a Redis-based distributed lock (`SET NX` with TTL) so only
|
||||
one replica executes each scheduled job.
|
||||
@@ -1,172 +0,0 @@
|
||||
# LeoCRM Deployment
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A: Coolify (empfohlen für Produktion)
|
||||
|
||||
```bash
|
||||
# Einmalig: Umgebungsvariablen setzen
|
||||
export COOLIFY_API_TOKEN="dein-token"
|
||||
export COOLIFY_APP_UUID="deine-app-uuid"
|
||||
|
||||
# Deploy
|
||||
python scripts/deploy.py
|
||||
|
||||
# Redeploy (ohne Neubuild)
|
||||
python scripts/deploy.py --skip-build
|
||||
```
|
||||
|
||||
Das Script macht automatisch:
|
||||
1. Coolify Build & Deploy triggern
|
||||
2. Persistent Volume in Coolify DB konfigurieren (automatisch, portabel)
|
||||
3. Auf healthy Container warten
|
||||
4. RLS auf allen Tenant-Tabellen sicherstellen
|
||||
5. DB-Migrationen verifizieren
|
||||
6. Worker-Container starten
|
||||
7. App-Health verifizieren
|
||||
8. Domain-Erreichbarkeit prüfen
|
||||
|
||||
**Funktioniert auf jeder Coolify-Instanz. Bei mehreren Apps. Bei Erst-Deploy und Redeploy.**
|
||||
|
||||
### Option B: Docker Compose (lokal / ohne Coolify)
|
||||
|
||||
```bash
|
||||
# .env.docker erstellen
|
||||
cp .env.docker.example .env.docker
|
||||
$EDITOR .env.docker # SECRET_KEY, POSTGRES_PASSWORD, etc. ausfüllen
|
||||
|
||||
# Starten (alle 4 Container: Postgres, Redis, App, Worker)
|
||||
docker compose --env-file .env.docker up --build -d
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8000/api/v1/health
|
||||
|
||||
# Stoppen
|
||||
docker compose down
|
||||
```
|
||||
|
||||
**Container:**
|
||||
- `crm-postgres` — PostgreSQL 16 mit pgvector
|
||||
- `crm-redis` — Redis 7
|
||||
- `crm-app` — FastAPI API Server
|
||||
- `crm-worker` — ARQ Background Worker
|
||||
|
||||
Alle mit persistenten Volumes. Kein Datenverlust bei Redeploy.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3.12+
|
||||
- Docker & Docker Compose (für Option B)
|
||||
- Coolify v4+ (für Option A)
|
||||
- SSH-Zugang zum Server (für Option A)
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Siehe `.env.example` für alle Variablen. Wichtigste:
|
||||
|
||||
| Variable | Pflicht | Default | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URL` | Ja | — | PostgreSQL Connection String |
|
||||
| `REDIS_URL` | Ja | — | Redis Connection String |
|
||||
| `SECRET_KEY` | Ja | — | Mindestens 32 Zeichen |
|
||||
| `ENVIRONMENT` | Nein | `development` | `production` oder `development` |
|
||||
| `SESSION_COOKIE_SECURE` | Nein | `true` | In Production muss `true` |
|
||||
| `STORAGE_PATH` | Nein | `/data/storage` | Datei-Upload-Pfad |
|
||||
| `STORAGE_BACKEND` | Nein | `local` | `local` oder `s3` |
|
||||
|
||||
## S3 Storage (optional)
|
||||
|
||||
Die App unterstützt S3-kompatiblen Storage. Setze:
|
||||
```bash
|
||||
STORAGE_BACKEND=s3
|
||||
S3_ENDPOINT=https://s3.example.com
|
||||
S3_BUCKET=leocrm
|
||||
S3_ACCESS_KEY=...
|
||||
S3_SECRET_KEY=...
|
||||
```
|
||||
|
||||
## Test- vs. Produktionsumgebung
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
python scripts/deploy.py --environment test
|
||||
```
|
||||
Eigene Coolify-App, eigene DB, eigene Domain (`crm-test.media-on.de`).
|
||||
|
||||
**Produktion:**
|
||||
```bash
|
||||
python scripts/deploy.py --environment production
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container nicht healthy:**
|
||||
```bash
|
||||
docker logs <container-name> --tail 50
|
||||
```
|
||||
|
||||
**Migration fehlgeschlagen:**
|
||||
```bash
|
||||
docker exec <container> alembic upgrade head
|
||||
```
|
||||
|
||||
**RLS nicht aktiv:**
|
||||
```bash
|
||||
python scripts/deploy.py --migrate-only
|
||||
```
|
||||
|
||||
**Worker nicht gestartet:**
|
||||
```bash
|
||||
python scripts/deploy.py --skip-build # startet Worker automatisch
|
||||
```
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
### Backup (PostgreSQL)
|
||||
|
||||
```bash
|
||||
# Full DB backup (run on the host or via docker exec)
|
||||
docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > backup_$(date +%Y%m%d_%H%M%S).dump
|
||||
|
||||
# Backup mit Custom-Format (komprimiert, parallel restore-fähig)
|
||||
docker exec crm-postgres pg_dump -U crm_user -Fc -Z 9 crm_db > backup_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### Backup (Redis — Sessions/Queues)
|
||||
|
||||
```bash
|
||||
# Redis RDB Snapshot
|
||||
docker exec crm-redis redis-cli -a "$REDIS_PASSWORD" SAVE
|
||||
docker cp crm-redis:/data/dump.rdb redis_backup_$(date +%Y%m%d).rdb
|
||||
```
|
||||
|
||||
### Backup (File Storage)
|
||||
|
||||
```bash
|
||||
# Local storage volume
|
||||
docker run --rm -v leocrm-fix_storage:/data -v $(pwd):/backup alpine \
|
||||
tar czf /backup/storage_$(date +%Y%m%d).tar.gz /data
|
||||
```
|
||||
|
||||
### Restore (PostgreSQL)
|
||||
|
||||
```bash
|
||||
# Stop app containers
|
||||
docker compose stop crm-app crm-worker
|
||||
|
||||
# Restore DB
|
||||
docker exec -i crm-postgres pg_restore -U crm_user -d crm_db --clean < backup_20260726.dump
|
||||
|
||||
# Restart app
|
||||
docker compose start crm-app crm-worker
|
||||
```
|
||||
|
||||
### Automatisierte Backups (Cron)
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/leocrm-backup
|
||||
0 2 * * * root docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > /backups/leocrm_$(date +\%Y\%m\%d).dump
|
||||
0 3 * * * root find /backups -name 'leocrm_*.dump' -mtime +30 -delete
|
||||
```
|
||||
|
||||
**Empfehlung:** Tägliche DB-Backups, 30 Tage Aufbewahrung. Storage-Backup wöchentlich.
|
||||
+1
-1
@@ -12,7 +12,7 @@ WORKDIR /frontend
|
||||
|
||||
# Copy package files first for layer caching
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copy frontend source and build
|
||||
COPY frontend/ ./
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
# Enterprise RBAC Plan — LeoCRM
|
||||
|
||||
## Gesamt: 23 Sprints, 74 Features, 230h
|
||||
|
||||
### Sprint 1 — Fundament (14h)
|
||||
- [ ] entity_permissions Tabelle + expires_at + Migration 0049
|
||||
- [ ] OwnedMixin + owner_id auf allen Models + Migration 0050
|
||||
- [ ] Universeller Permission Service (CRUD + get_effective_access + get_visible_ids)
|
||||
- [ ] Universelle Permission API (5 Endpoints)
|
||||
- [ ] Redis-Cache für Entity-Permissions (Bitmap)
|
||||
- [ ] PostgreSQL RLS Policies + set_user_context()
|
||||
- [ ] Rate Limiting auf Permission-Änderungen
|
||||
- [ ] Folder ACLs in entity_permissions migrieren (Migration 0051)
|
||||
|
||||
### Sprint 2 — Row-Level Security (16h)
|
||||
- [ ] apply_visibility_filter() Helper
|
||||
- [ ] Query-Filter in alle 28 Routes
|
||||
- [ ] Child-Entity-Vererbung
|
||||
- [ ] Batch-Resolution
|
||||
- [ ] BaseSearchProvider mit Visibility-Filter
|
||||
- [ ] ContactDetail/ContactsList Permission-Checks
|
||||
- [ ] Copy/Duplicate Permission
|
||||
- [ ] EXISTS-Optimization für RLS
|
||||
|
||||
### Sprint 3 — Search/Dashboard/Export (13h)
|
||||
- [ ] GlobalSearch Visibility-Filter
|
||||
- [ ] Two-Phase Search
|
||||
- [ ] Search-Index Pre-Filter
|
||||
- [ ] Dashboard-Counts pro User
|
||||
- [ ] Export-Filter
|
||||
- [ ] Reports-Filter
|
||||
- [ ] Frontend-Filter für alle 4
|
||||
|
||||
### Sprint 4 — Field-Level komplett (10h)
|
||||
- [ ] Custom Field Sensitivity
|
||||
- [ ] Field Definitions für alle Entities + Plugin-Registration
|
||||
- [ ] filter_fields_by_permission() in alle Responses
|
||||
- [ ] Field-Level Permission Editor UI
|
||||
- [ ] Frontend: readonly/hidden in ContactDetail + ContactsList + DMS + Mail + AI
|
||||
|
||||
### Sprint 5 — Sharing UI (8h)
|
||||
- [ ] Universeller ShareDialog Komponente
|
||||
- [ ] Share-Button in 8 Detail-Ansichten
|
||||
- [ ] Owner-Spalte in 8 Listen
|
||||
- [ ] Permission-UI (Buttons ausblenden)
|
||||
- [ ] Permission-Expiration UI
|
||||
|
||||
### Sprint 6 — Notifications + Audit + Real-time (10h)
|
||||
- [ ] Permission-Change-Notifications
|
||||
- [ ] Audit-Trail für Permission-Änderungen
|
||||
- [ ] Notification-Entity-Filter
|
||||
- [ ] Real-time WebSocket Sync
|
||||
- [ ] Redis Pub/Sub für WebSocket Fan-Out
|
||||
|
||||
### Sprint 7 — E-Mail Postfächer (8h)
|
||||
- [ ] Mailbox owner_id + Migration
|
||||
- [ ] Mailbox Permissions (entity_permissions)
|
||||
- [ ] Mail Permission Migration
|
||||
- [ ] Mail-Query-Filter
|
||||
- [ ] Mail-Field-Level
|
||||
- [ ] Frontend: Mailbox-Liste + Mail-Liste + Mail-Detail
|
||||
|
||||
### Sprint 8 — Plugin Entities (14h)
|
||||
- [ ] DMS owner_id + Permissions + Migration
|
||||
- [ ] Calendar owner_id + Permissions + Migration
|
||||
- [ ] Tasks owner_id + Permissions + Migration
|
||||
- [ ] Kommunikation RBAC Migration
|
||||
- [ ] Entity Links Permission
|
||||
- [ ] Tags Permission
|
||||
- [ ] 15 Plugin Entity Registration
|
||||
- [ ] DMS Permission Migration
|
||||
- [ ] Folder-Path-Materialization
|
||||
- [ ] Frontend Permission-Checks für DMS + Calendar + Tasks
|
||||
|
||||
### Sprint 9 — App-Sichtbarkeit (7h)
|
||||
- [ ] Plugin Manifest permission Feld
|
||||
- [ ] tenant_plugin_activation Tabelle + API
|
||||
- [ ] Sidebar Permission-Filter
|
||||
- [ ] TopBar Permission-Filter
|
||||
- [ ] Settings-Navigation Permission-Filter
|
||||
- [ ] Route-Guards (ProtectedRoute)
|
||||
|
||||
### Sprint 10 — Advanced Security + AI + WebSocket (18h)
|
||||
- [ ] API-Token Scopes
|
||||
- [ ] Webhook Scope Filter
|
||||
- [ ] Workflow Scope Filter
|
||||
- [ ] Contact Merge Permission-Check
|
||||
- [ ] AI Copilot Permission-Aware (process_query + execute_action)
|
||||
- [ ] AI Tool Registry
|
||||
- [ ] AI System Prompt mit Permission-Context
|
||||
- [ ] AI Proactive Permission-Aware
|
||||
- [ ] AI UI Control Permission-Checks
|
||||
- [ ] MCP Permission-Scopes
|
||||
- [ ] Automation Permission-Checks
|
||||
- [ ] WebSocket Permission-Checks
|
||||
- [ ] Event Bus Permission-Filter
|
||||
- [ ] Frontend: AI + Notifications + Workflows + DedupMerge
|
||||
|
||||
### Sprint 11 — Owner Management (5h)
|
||||
- [ ] Owner-Transfer (Bulk) API
|
||||
- [ ] Auto-Transfer bei User-Deaktivierung
|
||||
- [ ] Backup/Restore Permissions
|
||||
- [ ] Frontend Owner-Transfer-UI
|
||||
|
||||
### Sprint 12 — Zentrale Einstellungsseite (9h)
|
||||
- [ ] Rechte-Settings-Page mit Tabs
|
||||
- [ ] Freigaben-Übersicht (Admin-Dashboard)
|
||||
- [ ] Audit-View für Permission-Changes
|
||||
- [ ] CustomFields Sensitivity UI
|
||||
- [ ] App-Sichtbarkeit-Tab
|
||||
|
||||
### Sprint 13 — ABAC Engine (18h)
|
||||
- [ ] entity_policies Tabelle + Migration
|
||||
- [ ] Policy-Engine: JSONB → SQLAlchemy Übersetzer
|
||||
- [ ] apply_policy_filter() + Integration mit RBAC-Filter
|
||||
- [ ] Policy-Cache (Redis) + Invalidation
|
||||
- [ ] Policy Service (CRUD)
|
||||
- [ ] Policy API (5 Endpoints)
|
||||
- [ ] GIN-Indexes für ABAC
|
||||
- [ ] Pre-compiled SQL Fragments
|
||||
- [ ] Policy-Intersection-Optimization
|
||||
- [ ] Materialized Policy Result
|
||||
|
||||
### Sprint 14 — ABAC UI (10h)
|
||||
- [ ] ABAC Rule-Editor mit AND/OR Gruppen
|
||||
- [ ] Feld-Auswahl (Core + Custom Fields)
|
||||
- [ ] Vorschau + Test-Tool
|
||||
- [ ] Custom Field ABAC Support (JSONB-Path)
|
||||
|
||||
### Sprint 15 — Templates & Automation (5h)
|
||||
- [ ] permission_templates Tabelle + Migration
|
||||
- [ ] Default-Policies für neue Entities
|
||||
- [ ] Auto-Share bei Erstellung
|
||||
- [ ] Frontend Template-Editor UI
|
||||
|
||||
### Sprint 16 — Mass & Bulk (4h)
|
||||
- [ ] Bulk-Share API
|
||||
- [ ] Mass-Operations
|
||||
- [ ] Frontend Bulk-Share-UI
|
||||
|
||||
### Sprint 17 — Analytics & Konflikte (5h)
|
||||
- [ ] Permission-Analytics API
|
||||
- [ ] Konflikt-Erkennung
|
||||
- [ ] Orphaned-Permissions-Cleanup
|
||||
- [ ] Frontend Analytics-Dashboard
|
||||
|
||||
### Sprint 18 — Delegation (4h)
|
||||
- [ ] permission_delegations Tabelle + Migration
|
||||
- [ ] Delegation Service + API
|
||||
- [ ] Abwesenheits-UI
|
||||
- [ ] Auto-Expiry
|
||||
|
||||
### Sprint 19 — Resolution-Strategien (3h)
|
||||
- [ ] Konfigurierbare Override-Regeln
|
||||
- [ ] Tenant-Einstellung
|
||||
- [ ] Frontend UI
|
||||
|
||||
### Sprint 20 — Tests (12h)
|
||||
- [ ] Backend: Entity Permissions Tests
|
||||
- [ ] Backend: ABAC Tests
|
||||
- [ ] Backend: Performance Tests (100K Datensätze)
|
||||
- [ ] Backend: Search Permission Tests
|
||||
- [ ] Backend: WebSocket Permission Tests
|
||||
- [ ] Frontend: ProtectedRoute Tests
|
||||
- [ ] Frontend: Permission-UI Tests
|
||||
- [ ] Frontend: ShareDialog Tests
|
||||
|
||||
### Sprint 21 — Dokumentation (3h)
|
||||
- [ ] docs/permissions.md
|
||||
- [ ] docs/permissions_plugin_dev.md
|
||||
- [ ] Plugin Template mit Permission-Beispielen
|
||||
- [ ] API-Docs
|
||||
|
||||
### Sprint 22 — Guest Access (28h)
|
||||
- [ ] guest_users Tabelle + Migration
|
||||
- [ ] Guest Auth (Login, Session, Logout)
|
||||
- [ ] Guest Permission Resolution (Service + RLS)
|
||||
- [ ] Guest Invitation Flow (Backend + E-Mail)
|
||||
- [ ] Guest API (limited endpoints)
|
||||
- [ ] Guest Frontend (vereinfachtes Layout + Views)
|
||||
- [ ] Guest Permission Management UI (Settings)
|
||||
- [ ] Guest Expiration & Auto-Cleanup
|
||||
- [ ] Guest Audit Trail
|
||||
- [ ] Guest Security (IP-Whitelist, Rate Limit, Watermarking)
|
||||
- [ ] Guest Tests
|
||||
|
||||
### Sprint 23 — Infrastructure (4h)
|
||||
- [ ] PgBouncer Setup
|
||||
- [ ] Audit Log Partitioning
|
||||
- [ ] Connection Pool Config
|
||||
|
||||
## Permission Levels
|
||||
| Level | Sichtbar? | Bearbeiten? | Löschen? | Teilen? |
|
||||
|-------|:---:|:---:|:---:|:---:|
|
||||
| Owner | ✅ | ✅ | ✅ | ✅ |
|
||||
| Admin | ✅ | ✅ | ✅ | ✅ |
|
||||
| Write | ✅ | ✅ | ❌ | ❌ |
|
||||
| Read | ✅ | ❌ | ❌ | ❌ |
|
||||
| None | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
## Architecture
|
||||
- PostgreSQL RLS (Safety Net)
|
||||
- Materialized View (user_entity_visibility)
|
||||
- Redis Bitmap Cache
|
||||
- Batch-Resolution
|
||||
- GIN-Indexes (ABAC + JSONB)
|
||||
- Folder-Path-Materialization (GiST)
|
||||
- PgBouncer Connection Pool
|
||||
- Redis Pub/Sub WebSocket Fan-Out
|
||||
- Audit Log Partitioning
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
# LeoCRM Fix-Plan V2 — Gründliche Analyse & Maßnahmen
|
||||
|
||||
*Erstellt: 2026-07-26 — basierend auf externem Audit + eigener Code-Verifikation*
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Von 16 zentralen Punkten des externen Audits wurden **alle 16 durch Code-Inspektion verifiziert**. Zusätzlich wurden **5 neue Probleme** gefunden (UploadFile-Bug, Redis-Default-Passwort, exponierte Ports, unauthentifizierter Error-Endpoint, fehlende Security-Headers).
|
||||
|
||||
**Gesamtstatus:** Alle Phasen implementiert (Stand 2026-07-27). M5 (Frontend-Integration) als letzte Phase abgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Status (Stand 2026-07-27)
|
||||
|
||||
Die folgenden Phasen wurden gemäß Git-Historie implementiert:
|
||||
|
||||
| Phase | Commit | Maßnahmen | Status |
|
||||
|-------|--------|-----------|--------|
|
||||
| **Phase 1** (B1-B10) | `5ec1fc9` | Kritische Release-Blocker: Redis-Singleton (B1), Plugin-Routen (B2), UploadFile response_model (B3), DMS-Streaming (B4), Outbox-Worker (B5), Passwort-Reset-Mail (B6), Webhook-SSRF (B7), RLS-DB-Role (B8), .env-Korrektur (B9), Redis-Ports (B10) | ✅ Implementiert |
|
||||
| **Phase 2** (H1-H7) | `604a2b7` | Error-Endpoint (H1), Rate-Limiter (H2), CSRF-Redis (H3), WebSocket-Auth (H4), File-Upload (H5), Security-Headers (H6), Migration-Repair (H7) | ✅ Implementiert |
|
||||
| **Phase 3** (M1-M4, M6) | `825d638` | Passwort-Komplexität (M1), Login-Response (M2), Permission-Cache (M3), ENVIRONMENT (M4), weitere (M6) | ✅ Implementiert |
|
||||
| **Phase 4** | `b6e3afd` | Webhooks, Backup/Restore UI, Onboarding/Tutorial | ✅ Implementiert |
|
||||
| **Plugin-System-Umbau** | `98eb1d0` | Plugin-Routen nur in create_app(), require_active_plugin() Dependency, WebSocket-Skip | ✅ Implementiert |
|
||||
|
||||
### Verifizierte P0-Behebungen
|
||||
|
||||
| P0 | Problem | Status | Beweis |
|
||||
|----|---------|--------|--------|
|
||||
| P0-1 | Auth-Bypass via X-Internal-Call | ✅ Behoben | `app/deps.py` hat keinen X-Internal-Call Code mehr. Auth nur via Session-Cookie. |
|
||||
| P0-2 | Destruktive Migrationen | ✅ Behoben | Migration 0021 benennt Tabellen um (`*_old`). Migration 0044 repariert RLS. |
|
||||
| P0-3 | Plugin-Upload RCE | ✅ Neutralisiert | Alle Upload-Endpoints deaktiviert (403). `_extract_plugin_from_zip()` ist Dead Code. |
|
||||
| P0-4 | RLS nicht erzwungen | ✅ Behoben | Migration 0028 setzt FORCE RLS. Migration 0044 erstellt `crm_runtime` (NOSUPERUSER, NOBYPASSRLS). |
|
||||
| P0-5 | Plugin-Doppelregistrierung | ✅ Behoben | Routen nur in create_app(). require_active_plugin() prüft Aktivierungsstatus. |
|
||||
| P0-6 | Kein persistentes Volume | ✅ Behoben | docker-compose.yml hat volumes für PostgreSQL, Redis, App-Uploads, Worker. |
|
||||
| P0-7 | Öffentliche Domain | ✅ Behoben | Keine crm.media-on.de Referenz mehr in docker-compose.yml. |
|
||||
|
||||
### Weitere verifizierte Behebungen
|
||||
- **B1** (doppelte get_redis()): ✅ Nur eine Definition in `app/core/auth.py` Zeile 53
|
||||
- **B3** (UploadFile response_model): ✅ `response_model=None` in dms, calendar, mail routes
|
||||
- **B7** (Webhook SSRF): ✅ Private IP-Check, `follow_redirects=False`, Protokoll-Check
|
||||
- **B9** (AUTH_SECRET vs SECRET_KEY): ✅ `.env.docker.example` verwendet `SECRET_KEY`
|
||||
- **B10** (Redis-Default-Passwort + Ports): ✅ Ports auskommentiert, Redis-Passwort required
|
||||
- **WebSocket Auth**: ✅ Beide WS-Endpunkte haben `verify_ws_origin()`, Session-Cookie-Validierung, `user_id` aus Session
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Kritische Release-Blocker (vor Produktivbetrieb)
|
||||
|
||||
### B1. Doppelte `get_redis()` entfernen
|
||||
- **Datei:** `app/core/auth.py` Zeilen 53 + 94
|
||||
- **Problem:** Zweite Definition überschreibt Singleton, erzeugt pro Aufruf neue Verbindung → Connection Leak
|
||||
- **Fix:** Zweite `def get_redis()` (Zeile 94) löschen. Erste Definition (Zeile 53) beibehalten.
|
||||
- **Aufwand:** 5 Min
|
||||
- **Risiko:** Keines — erste Definition ist korrekt
|
||||
|
||||
### B2. Plugin-Routen-Registrierung reparieren
|
||||
- **Datei:** `app/main.py` Zeilen 375-416
|
||||
- **Problem:** Alle Plugin-Routen werden statisch in `create_app()` registriert, unabhängig vom Aktivierungsstatus. Deaktivierte Plugins bleiben erreichbar. Kommentar in Zeile 416 sagt das Gegenteil.
|
||||
- **Fix:**
|
||||
1. Statische Registrierung aus `create_app()` entfernen
|
||||
2. In `lifespan()` nur Routen für `active=True` Plugins registrieren
|
||||
3. `Depends(require_active_plugin("name"))` als zentrale Prüfung ergänzen
|
||||
4. Bei Deaktivierung: Router entfernen oder 403-Dependency ergänzen
|
||||
- **Aufwand:** 2-3 Std
|
||||
- **Risiko:** Mittel — muss sicherstellen dass keine Route doppelt registriert wird
|
||||
|
||||
### B3. UploadFile Route-Registration Bug
|
||||
- **Dateien:** `app/plugins/builtins/dms/routes.py`, `calendar/routes.py`, `mail/routes.py`, `kommunikation/routes.py`, `ai_assistant/routes.py`
|
||||
- **Problem:** FastAPI kann `UploadFile` nicht als Response-Model auflösen → 5 Plugins failen beim Registrieren mit `Invalid args for response field`
|
||||
- **Fix:** `response_model=None` zu allen Endpoints mit `UploadFile`-Rückgabe hinzufügen, oder Return-Type auf `Response`/`dict` ändern
|
||||
- **Aufwand:** 30 Min
|
||||
- **Risiko:** Keines — Routen sind aktuell gar nicht registriert
|
||||
|
||||
### B4. DMS-Upload auf echtes Streaming umstellen
|
||||
- **Datei:** `app/plugins/builtins/dms/routes.py` Zeilen 444-472
|
||||
- **Problem:** Chunks werden in `list[bytes]` gesammelt, dann `b"".join()` → 100MB Datei = 200MB+ RAM. `save_stream()` existiert aber wird nicht benutzt.
|
||||
- **Fix:**
|
||||
```python
|
||||
async def chunk_generator():
|
||||
while chunk := await file.read(CHUNK_SIZE):
|
||||
yield chunk
|
||||
await storage.save_stream(storage_path, chunk_generator())
|
||||
```
|
||||
Hash und Größe während des Streams berechnen.
|
||||
- **Aufwand:** 1 Std
|
||||
- **Risiko:** Gering — save_stream() ist bereits implementiert
|
||||
|
||||
### B5. Outbox-Worker: Event-Handler registrieren
|
||||
- **Datei:** `app/core/worker.py` `on_startup()`
|
||||
- **Problem:** Worker liest Events aus Outbox, published an lokalen EventBus, aber es sind keine Handler registriert → Events werden als `published` markiert ohne Verarbeitung
|
||||
- **Fix:**
|
||||
1. In `on_startup()`: Plugin-Event-Handler registrieren (wie in `lifespan()` der API)
|
||||
2. `webhook_dispatcher._dispatch_event` an EventBus subscriben
|
||||
3. Plugin-Participant-Handler registrieren
|
||||
- **Aufwand:** 2 Std
|
||||
- **Risiko:** Mittel — muss gleiche Handler wie API-Container registrieren
|
||||
|
||||
### B6. Passwort-Reset-Mailjob implementieren
|
||||
- **Dateien:** `app/services/auth_service.py`, `app/core/jobs.py`, `app/core/job_registry.py`
|
||||
- **Problem:** `send_password_reset_email` Job wird gequeued aber nie registriert → Mail wird nicht versendet. Token wird in Logs geschrieben (Zeile 240-241).
|
||||
- **Fix:**
|
||||
1. `send_password_reset_email` Worker-Funktion implementieren (SMTP/IMAP)
|
||||
2. Mit `register_job()` registrieren
|
||||
3. `logger.warning("raw_token for development: %s", raw_token)` entfernen
|
||||
4. Token nur im Development-Mode loggen, nie in Production
|
||||
- **Aufwand:** 2 Std
|
||||
- **Risiko:** Gering
|
||||
|
||||
### B7. Webhook SSRF-Schutz + Secret-Behandlung
|
||||
- **Dateien:** `app/services/webhook_service.py`, `app/schemas/webhook.py`
|
||||
- **Problem:** Kein SSRF-Schutz — User können interne Dienste ansprechen (redis:6379, postgres:5432, 169.254.169.254). Webhook-Secret wird im Response zurückgegeben.
|
||||
- **Fix:**
|
||||
1. SSRF-Prüfung: DNS auflösen, private IPs blocken (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1)
|
||||
2. Redirects deaktivieren oder prüfen
|
||||
3. Protokoll-Allowlist (nur https)
|
||||
4. `secret` aus `WebhookResponse` entfernen
|
||||
5. Secret gehasht in DB speichern
|
||||
- **Aufwand:** 3 Std
|
||||
- **Risiko:** Gering
|
||||
|
||||
### B8. RLS: Separater DB-Runtime-User
|
||||
- **Dateien:** `docker-compose.yml`, `alembic/versions/0044_db_roles.py` (neu)
|
||||
- **Problem:** `POSTGRES_USER` (crm_user) ist Superuser → umgeht RLS auch mit FORCE. Spätere Tabellen (user_preferences, saved_filters, etc.) haben keine RLS-Policy.
|
||||
- **Fix:**
|
||||
1. Neue Migration `0044_db_roles.py`: erstellt `crm_runtime` (NOSUPERUSER, NOBYPASSRLS)
|
||||
2. `crm_runtime` bekommt nur SELECT/INSERT/UPDATE/DELETE Rechte
|
||||
3. `docker-compose.yml`: API und Worker nutzen `crm_runtime`, Migrationen nutzen `crm_owner`
|
||||
4. Neue Migration `0045_rls_new_tables.py`: RLS für alle Tabellen mit `tenant_id` die nach 0028 hinzukamen
|
||||
- **Aufwand:** 4 Std
|
||||
- **Risiko:** Hoch — muss bestehende Datenbanken migrieren ohne Datenverlust
|
||||
|
||||
### B9. .env.docker.example korrigieren
|
||||
- **Datei:** `.env.docker.example`
|
||||
- **Problem:** Verwendet `AUTH_SECRET` statt `SECRET_KEY` (config.py erwartet `SECRET_KEY`)
|
||||
- **Fix:** `AUTH_SECRET` → `SECRET_KEY` umbenennen
|
||||
- **Aufwand:** 5 Min
|
||||
- **Risiko:** Keines
|
||||
|
||||
### B10. Redis-Default-Passwort + exponierte Ports
|
||||
- **Datei:** `docker-compose.yml`
|
||||
- **Problem:** Redis-Passwort default `changeme`, PostgreSQL (5432) und Redis (6379) Ports exponiert
|
||||
- **Fix:**
|
||||
1. Redis-Passwort als Required-Env ohne Default
|
||||
2. `ports:` Sektion für DB und Redis entfernen (nur internes Docker-Netzwerk)
|
||||
3. Falls Debug-Zugriff nötig: nur an 127.0.0.1 binden
|
||||
- **Aufwand:** 15 Min
|
||||
- **Risiko:** Gering — bestehende Setups müssen .env anpassen
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hohe Priorität (kurz nach Release)
|
||||
|
||||
### H1. Unauthentifizierter Error-Endpoint absichern
|
||||
- **Datei:** `app/routes/errors.py`
|
||||
- **Problem:** `POST /api/v1/errors` ohne Auth, sendet Daten an Forgejo als öffentliches Issue. Context-Dict kann sensible Daten enthalten.
|
||||
- **Fix:**
|
||||
1. Context-Felder filtern (keine Tokens, Passwörter, Headers)
|
||||
2. Forgejo-Issues nur in non-production erstellen
|
||||
3. Rate-Limit auf IP-Basis (bereits vorhanden, aber in-memory → bei Multi-Worker unzuverlässig)
|
||||
4. Optional: Auth erforderlich, aber dann funktioniert Frontend-Error-Logging nicht mehr → besser: nur sanitisierte Daten akzeptieren
|
||||
- **Aufwand:** 1 Std
|
||||
|
||||
### H2. Rate-Limiter IP-Spoofing
|
||||
- **Datei:** `app/core/rate_limit.py` Zeile 43
|
||||
- **Problem:** Vertraut `X-Forwarded-For` blind → IP-Spoofing umgeht Rate-Limits
|
||||
- **Fix:** Nur erste IP in X-Forwarded-For verwenden, oder `X-Real-IP` mit Proxy-Validation
|
||||
- **Aufwand:** 30 Min
|
||||
|
||||
### H3. CSRF-Middleware Redis-Verbindung
|
||||
- **Datei:** `app/core/middleware.py` Zeile 69
|
||||
- **Problem:** Erstellt pro unsafe Request neue Redis-Verbindung → Connection Leak
|
||||
- **Fix:** `get_redis()` Singleton verwenden (funktioniert nach B1)
|
||||
- **Aufwand:** 10 Min
|
||||
|
||||
### H4. WebSocket Auth + Origin-Verifikation
|
||||
- **Dateien:** `app/plugins/builtins/kommunikation/websocket_manager.py`, `ai_ui_control/websocket_manager.py`
|
||||
- **Problem:** `user_id` wird ohne Auth-Verifikation akzeptiert. Keine Origin-Prüfung bei WS-Upgrade.
|
||||
- **Fix:**
|
||||
1. Session-Token aus Query-Param oder Header validieren
|
||||
2. Origin-Header gegen erlaubte Domains prüfen
|
||||
3. User-ID aus Session ableiten, nicht aus Client-Param
|
||||
- **Aufwand:** 2 Std
|
||||
|
||||
### H5. File-Upload-Sicherheit
|
||||
- **Datei:** `app/core/storage.py`
|
||||
- **Problem:** Keine Path-Traversal-Prüfung, keine Type/Size-Limits, `get_url()` leakt Filesystem-Pfade
|
||||
- **Fix:**
|
||||
1. Filename sanitizen (keine `../`, keine absoluten Pfade)
|
||||
2. MIME-Type-Allowlist
|
||||
3. Max-File-Size konfigurierbar
|
||||
4. `get_url()` gibt relative URL zurück, nicht Filesystem-Pfad
|
||||
- **Aufwand:** 1 Std
|
||||
|
||||
### H6. Security-Headers
|
||||
- **Datei:** `app/core/middleware.py` (neu)
|
||||
- **Problem:** Keine Security-Headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP)
|
||||
- **Fix:** Middleware ergänzen die diese Headers setzt
|
||||
- **Aufwand:** 30 Min
|
||||
|
||||
### H7. Migration-Repair für bestehende Installationen
|
||||
- **Datei:** `alembic/versions/0044_repair_contact_migration.py` (neu)
|
||||
- **Problem:** Migrationen 0021 und 0027 wurden nachträglich geändert. Alembic führt sie nicht erneut aus.
|
||||
- **Fix:**
|
||||
1. Neue Migration die `*_old` Tabellen erkennt und Daten nachmigriert
|
||||
2. Integritätsprüfung (Anzahl vergleichen)
|
||||
3. Bei Abweichungen hart abbrechen mit Fehlermeldung
|
||||
- **Aufwand:** 3 Std
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Mittlere Priorität
|
||||
|
||||
### M1. Passwort-Komplexität
|
||||
- **Datei:** `app/schemas/auth.py`, `app/schemas/user.py`
|
||||
- **Problem:** Min-Length 8 bei Erstellung, Min-Length 1 bei Login. Keine Komplexitäts-Requirements.
|
||||
- **Fix:** Passwort-Validator ergänzen (min 8 Zeichen, 1 Groß, 1 Klein, 1 Zahl)
|
||||
- **Aufwand:** 30 Min
|
||||
|
||||
### M2. Login-Response: is_system_admin
|
||||
- **Datei:** `app/routes/auth.py` Zeile 78
|
||||
- **Problem:** `is_system_admin` Flag in Login-Response leakt interne Rolle
|
||||
- **Fix:** Flag aus Response entfernen oder nur für Admin-User anzeigen
|
||||
- **Aufwand:** 15 Min
|
||||
|
||||
### M3. Permission-Cache: Stale Data bei DB-Error
|
||||
- **Datei:** `app/core/permissions.py` Zeile 337
|
||||
- **Problem:** Bei DB-Error fällt Cache auf stale Daten zurück → widerrufene Rechte bleiben aktiv
|
||||
- **Fix:** Bei DB-Error: Cache invalidieren und 503 zurückgeben statt stale Daten zu nutzen
|
||||
- **Aufwand:** 30 Min
|
||||
|
||||
### M4. ENVIRONMENT=development vs SESSION_COOKIE_SECURE=true
|
||||
- **Datei:** `.env` Zeilen 3-4
|
||||
- **Problem:** Inkonsistent — development deaktiviert Prod-Safety-Checks, aber Cookie ist secure
|
||||
- **Fix:** In .env.docker.example klar dokumentieren: production → `ENVIRONMENT=production` + `SESSION_COOKIE_SECURE=true`
|
||||
- **Aufwand:** 10 Min
|
||||
|
||||
### M5. Frontend: Unresolved Items — ✅ Implementiert (2026-07-27)
|
||||
- **Dateien:** `WelcomeDialog.tsx`, `SavedFilterBar.tsx`, `EntityHistoryPanel.tsx`, `TagBadge.tsx`, `TagSelector.tsx`
|
||||
- **Status:** ✅ Implementiert — SavedFilterBar und TagSelector in ContactsList, Mail, Calendar integriert
|
||||
- **Implementiert:**
|
||||
1. SavedFilterBar in ContactsList (entityType="contacts"), Mail (entityType="mail"), Calendar (entityType="calendar") integriert
|
||||
2. TagSelector in ContactsList (entityType="contact"), Mail (entityType="file"), Calendar (entityType="calendar_entry") integriert
|
||||
3. Frontend TypeScript: 0 Errors (`npx tsc --noEmit`)
|
||||
- **Hinweis:** WelcomeDialog und EntityHistoryPanel bleiben für spätere Iteration offen
|
||||
|
||||
### M6. Frontend-Tests: QueryClientProvider
|
||||
- **Datei:** `frontend/src/test/setup.ts` oder einzelne Tests
|
||||
- **Problem:** ~29 Tests failen mit missing QueryClientProvider
|
||||
- **Fix:** Globalen Test-Wrapper mit QueryClientProvider in setup.ts ergänzen
|
||||
- **Aufwand:** 1 Std
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Niedrige Priorität
|
||||
|
||||
### L1. document.write() in print.ts
|
||||
- **Datei:** `frontend/src/utils/print.ts` Zeilen 54, 127
|
||||
- **Problem:** `document.write()` mit DOM-Clone — XSS-Risiko wenn Content nicht sanitized
|
||||
- **Fix:** Statt `document.write()`: `iframe.srcdoc` oder `Blob URL` verwenden
|
||||
- **Aufwand:** 1 Std
|
||||
|
||||
### L2. AI UI Control: Unbounded Feedback-Storage
|
||||
- **Datei:** `app/plugins/builtins/ai_ui_control/websocket_manager.py` Zeile 94
|
||||
- **Problem:** Feedback/Commands unbegrenzt im Memory gespeichert → Memory Exhaustion
|
||||
- **Fix:** Max-Length Queue (z.B. 100 Einträge) mit FIFO
|
||||
- **Aufwand:** 15 Min
|
||||
|
||||
### L3. Backup-Strategie dokumentieren
|
||||
- **Problem:** Named Volumes in docker-compose aber keine Backup/Restore-Doku
|
||||
- **Fix:** Backup-Script und Doku ergänzen
|
||||
- **Aufwand:** 2 Std
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Reihenfolge
|
||||
|
||||
```
|
||||
Phase 1 (Release-Blocker):
|
||||
B1 → B3 → B9 → B10 → B2 → B4 → B5 → B6 → B7 → B8
|
||||
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||
5m 30m 5m 15m 3h 1h 2h 2h 3h 4h
|
||||
Gesamt: ~16 Std
|
||||
|
||||
Phase 2 (Hohe Priorität):
|
||||
H3 → H2 → H6 → H1 → H5 → H4 → H7
|
||||
Gesamt: ~8 Std
|
||||
|
||||
Phase 3 (Mittlere Priorität):
|
||||
M4 → M1 → M2 → M3 → M6 → M5
|
||||
Gesamt: ~6 Std
|
||||
|
||||
Phase 4 (Niedrige Priorität):
|
||||
L2 → L1 → L3
|
||||
Gesamt: ~3 Std
|
||||
```
|
||||
|
||||
**Gesamtaufwand: ~33 Std**
|
||||
|
||||
---
|
||||
|
||||
## Was bereits sauber funktioniert
|
||||
|
||||
- ✅ Auth-Bypass entfernt (keine X-Internal-Call/X-Tenant-Id/X-User-Id Headers mehr)
|
||||
- ✅ Plugin-Upload/URL-Installation deaktiviert (403)
|
||||
- ✅ Worker in separatem Container
|
||||
- ✅ Metrics adminbeschränkt
|
||||
- ✅ DOMPurify für HTML-Komponenten
|
||||
- ✅ ARQ-Verbindungspool zentralisiert
|
||||
- ✅ Session-Widerruf nach Passwortänderung
|
||||
- ✅ Permission-Cache-Versionierung
|
||||
- ✅ Redis SCAN statt KEYS
|
||||
- ✅ Rabatte von Float auf Numeric
|
||||
- ✅ Event-Outbox als Grundlage vorhanden
|
||||
- ✅ RLS FORCE + WITH CHECK in Migration 0028
|
||||
- ✅ Migration 0021: Tabellen umbenennen statt löschen
|
||||
- ✅ Frontend: TypeScript typecheck clean (0 errors)
|
||||
- ✅ Frontend: ErrorBoundary, OfflineBanner, ErrorLogger implementiert
|
||||
- ✅ Frontend: Print/PDF mit WeasyPrint funktioniert
|
||||
- ✅ Dockerfile: Multi-stage, non-root User, Healthcheck
|
||||
- ✅ Bcrypt Password-Hashing
|
||||
- ✅ Session-Tokens: secrets.token_urlsafe(32)
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
# LeoCRM — Umfassender Fix-Plan
|
||||
|
||||
> Erstellt: 2026-07-25
|
||||
> Letzte Überprüfung: 2026-07-26 — Alle Items gegen Codebasis verifiziert
|
||||
> Quellen: Externes Audit (geprüft), eigene Code-Inspektion, Coolify-Deployment-Prüfung
|
||||
|
||||
---
|
||||
|
||||
## ✅ Erledigte Fixes (22 von 24 Items komplett)
|
||||
|
||||
Die folgenden Items wurden bei der Überprüfung am 2026-07-26 als erledigt bestätigt:
|
||||
|
||||
| Item | Beschreibung | Verifiziert durch |
|
||||
|---|---|---|
|
||||
| P0-1 | Auth-Bypass entfernt | `app/deps.py` — keine `X-Internal-Call` Headers mehr |
|
||||
| P0-2 | Migrationen repariert | `migration_0021.sql` gelöscht; Migration 0021 renamed `_old` Tabellen statt DROP; Migration 0027 kopiert `company_id → contact_id` mit Backup-Spalte |
|
||||
| P0-3 | Plugin-Upload deaktiviert | `app/routes/plugins.py` — `/upload` und `/install-url` return 403 mit `upload_disabled` / `install_url_disabled` |
|
||||
| P0-4 | RLS repariert | `alembic/versions/0028_rls_force.py` — `FORCE ROW LEVEL SECURITY` + `WITH CHECK` auf allen Tenant-Tabellen |
|
||||
| P0-5 | Plugin-Doppelregistrierung | `app/main.py` — Routes in `create_app()`, `lifespan()` nur aktiviert/deaktiviert, respektiert DB `active` Status, Migration-Fail deaktiviert Plugin |
|
||||
| P0-6 | Persistent Volume | `docker-compose.yml` — `storage:/data/storage`, `pgdata`, `redisdata` Volumes |
|
||||
| P1-1 | User/Tenant-Modell | `app/models/user.py` — `User` hat keine `tenant_id`/`role` mehr, `UserTenant` ist single source of truth, `email` global unique |
|
||||
| P1-2 | Redis zentralisiert | `app/core/auth.py` — `init_redis()`/`get_redis()` Singleton, `init_job_pool()`/`close_job_pool()` |
|
||||
| P1-3 | Worker ausgelagert | `prestart.sh` — nur Alembic + Uvicorn; separater `crm-worker` Container in `docker-compose.yml` |
|
||||
| P1-4 | Transactional Outbox | `app/core/outbox.py`, `app/models/outbox.py`, `alembic/versions/0040_outbox.py` — `enqueue_outbox_event()` + `process_outbox_batch()` mit `FOR UPDATE SKIP LOCKED` |
|
||||
| P1-5 | XSS-Stellen geschlossen | `HtmlBlock.tsx` + `SignatureManager.tsx` — `DOMPurify.sanitize()`; `ActionCardBlock.tsx` — URL-Validierung (nur `http:`/`https:`) |
|
||||
| P1-6 | DMS lastfest | `app/plugins/builtins/dms/routes.py` — 1MB Chunked Streaming, SHA-256 Content-Hash |
|
||||
| P1-7 | Permission-System | `app/core/permissions.py` — `permission_version` wird beim Cache-Lesen geprüft, `redis.scan()` statt `redis.keys()`, `require_write()` prüft spezifische Permissions |
|
||||
| P1-8 | Password Reset | `app/services/auth_service.py` — ARQ Job `send_password_reset_email`, Token `used_at` Tracking |
|
||||
| P1-9 | Metrics abgesichert | `app/routes/metrics.py` — `Depends(require_admin)` |
|
||||
| P1-10 | Coolify-Doku & Config | `COOLIFY_SETUP.md` — Healthcheck `/api/v1/health`, JWT-Vars entfernt, CORS `:443`; `app/config.py` — `storage_path=/data/storage`, `session_cookie_secure=True`, Startup-Validierung; `docker-compose.yml` — Redis, Volumes, Healthcheck |
|
||||
| P1-11 | Cross-Tenant FK | `alembic/versions/0036_cross_tenant_fk.py` — `UNIQUE (tenant_id, id)` + Composite FK `(tenant_id, contact_id)` auf `contactpersons` und `contact_merge_history` |
|
||||
| P2-1 | Contact Model normalisiert | `alembic/versions/0039_contact_normalize.py` — `surfix→suffix`, `Float→Numeric(5,2)`, `JSON→JSONB`, `CHECK (0-100)`, Unique Constraints |
|
||||
| P2-3 | Commands & Statusmaschinen | `app/commands/` (base, contact, calendar, dms, mail) + `app/core/state_machine.py` |
|
||||
| P2-4 | SPA Path-Traversal | `app/main.py` — `os.path.abspath` Check + `".." in full_path` Blocking |
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Offene Items
|
||||
|
||||
### P0-7: App von öffentlicher Domain nehmen
|
||||
|
||||
**Status:** Operational — nicht aus Code verifizierbar
|
||||
|
||||
**Problem:** Die App läuft unter `https://crm.media-on.de` und ist öffentlich erreichbar.
|
||||
|
||||
**Maßnahme:**
|
||||
1. **Sofort:** App von öffentlicher Domain nehmen oder IP-Whitelist/Basic Auth vorschalten
|
||||
2. Mindestens P0-1 (Auth-Bypass ✅) und P0-3 (Plugin-Upload ✅) sind bereits behoben
|
||||
3. Alternativ: VPN/Tunnel-Zugang statt öffentliche Domain
|
||||
|
||||
**Aufwand:** 30 Minuten
|
||||
|
||||
---
|
||||
|
||||
### P2-2: Plugin-Cross-Imports reduzieren
|
||||
|
||||
**Status:** Offen — 228 direkte Cross-Imports zwischen Plugins
|
||||
|
||||
**Problem:** 228 direkte `from app.plugins.builtins` Imports zwischen Plugins. Automatisierung importiert Modelle/Services von Kommunikation, Mail, Kalender. Verteilter Monolith ohne Modulgrenzen.
|
||||
|
||||
**Maßnahme:**
|
||||
1. Öffentliche Schnittstellen (Contracts) für jedes Modul definieren
|
||||
2. Direkte Imports fremder Plugin-Modelle verbieten
|
||||
3. Kommunikation nur über Events oder öffentliche Service-API
|
||||
4. CI-Check: keine direkten Cross-Plugin-Imports
|
||||
|
||||
**Aufwand:** 1-2 Wochen
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Priorität | Erledigt | Offen | Geschätzter Aufwand (offen) |
|
||||
|---|---|---|---|
|
||||
| P0 | 6/7 | 1 (operational) | 30 Minuten |
|
||||
| P1 | 11/11 | 0 | — |
|
||||
| P2 | 3/4 | 1 | 1-2 Wochen |
|
||||
| **Total** | **20/22** | **2** | **~1-2 Wochen** |
|
||||
|
||||
## Validierung nach jedem Fix
|
||||
|
||||
- [ ] Python-Syntax-Check: `python -m py_compile app/**/*.py`
|
||||
- [ ] pytest: `pytest tests/ -x`
|
||||
- [ ] Frontend-Typecheck: `cd frontend && npx tsc --noEmit`
|
||||
- [ ] Frontend-Build: `cd frontend && npx vite build`
|
||||
- [ ] Manueller Smoke-Test: Login, Kontakt erstellen, DMS-Upload
|
||||
- [ ] Cross-Tenant-Test: Datensatz aus Mandant A kann nicht aus Mandant B gelesen werden
|
||||
- [ ] Deployment: Coolify Deploy + Healthcheck prüfen
|
||||
@@ -1,534 +0,0 @@
|
||||
# LeoCRM — Implementationsplan: Fehlende Frontend-Features
|
||||
|
||||
> **Stand:** 26.07.2026 (Audit-korrigiert) | **Backend:** 352 Endpunkte | **Frontend:** 47 Pages, 38 API-Clients
|
||||
> **Repo:** `/a0/usr/workdir/leocrm-fix` | **Branch:** `main` | **Deploy:** Coolify App `stvabl4vaqru7jclx4ittzr3`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Audit-Korrekturen (26.07.2026 02:17)
|
||||
|
||||
### Korrektur 1: Permissions Management UI — ENTHALTEN IN SettingsRoles.tsx
|
||||
**Vorher:** Plan sagte "keine Verwaltungs-Seite um Permissions pro Rolle zu konfigurieren"
|
||||
**Tatsächlich:** `SettingsRoles.tsx` (531 Zeilen) hat VOLLSTÄNDIGE Permission-Verwaltung:
|
||||
- ✅ Grant permissions (Checkboxen gruppiert nach system/plugin)
|
||||
- ✅ Denied permissions (explizite Verweigern-Liste)
|
||||
- ✅ Field-level permissions (pro Modul/Feld Sensitivität)
|
||||
- ✅ Rollen erstellen/bearbeiten mit Permission-Zuweisung
|
||||
- ✅ DMS `ShareDialog.tsx` nutzt bereits File-Permissions API (grant/revoke/share-link)
|
||||
**Folge:** Feature 8 entfällt. Keine neue Permission-UI nötig.
|
||||
|
||||
### Korrektur 2: Import/Export — Export-Route fehlt DEFINITIV
|
||||
**Vorher:** Plan sagte "falls Export fehlt"
|
||||
**Tatsächlich:** `export_contacts_csv()` Service-Funktion existiert, aber KEINE Route in `import_export.py`. Nur `/import` und `/import/preview` sind registriert. Export muss als Route hinzugefügt werden.
|
||||
|
||||
### Korrektur 3: Activity Timeline — ActivityFeed existiert bereits
|
||||
**Vorher:** Plan sagte "Dashboard hat ActivityFeed aber nur statisch"
|
||||
**Tatsächlich:** Dashboard nutzt `ActivityFeed` mit Daten aus Audit-API. Komponente ist wiederverwendbar. Es fehlt nur eine eigenständige Seite mit Filterung/Pagination.
|
||||
|
||||
### Korrektur 4: DMS ShareDialog — File Permissions bereits integriert
|
||||
**Vorher:** Plan sah `FilePermissionDialog` als neue Komponente vor
|
||||
**Tatsächlich:** `ShareDialog.tsx` (11KB) existiert bereits und nutzt `fetchFilePermissions`, `grantPermission`, `revokePermission`, `createShareLink`, `revokeShareLink` aus `permissions.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht: 14 verbleibende Features in 4 Phasen
|
||||
|
||||
| Phase | Features | Priorität | Geschätzter Aufwand |
|
||||
|-------|----------|-----------|---------------------|
|
||||
| **1 — Kritisch** | Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF | CRM-Kern | ~4-5 Tage |
|
||||
| **2 — Wichtig** | Tags UI, Custom Fields UI, Notifications Dropdown | Tagesgeschäft | ~2.5-3 Tage |
|
||||
| **3 — Nice-to-have** | Saved Filters UI, Entity History UI, Activity Timeline, API Docs Link | Produktivität | ~1.5-2 Tage |
|
||||
| **4 — Backend+Frontend** | Webhooks, Backup/Restore UI, Onboarding/Tutorial | Erweiterungen | ~3-4 Tage |
|
||||
|
||||
**Gesamtaufwand:** ~11-14 Entwicklungstage (1 Feature entfallen)
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Grundsätze (für alle Features)
|
||||
|
||||
### Frontend-Konventionen
|
||||
- **Routing:** Lazy-loaded in `frontend/src/routes/index.tsx`, explizite Routes (nicht PluginRouteRenderer)
|
||||
- **API-Clients:** In `frontend/src/api/<name>.ts`, verwenden `apiGet/apiPost/apiPatch/apiDelete` aus `client.ts`
|
||||
- **Hooks:** React Query (`useQuery`/`useMutation`) mit Query-Key-Invalidierung
|
||||
- **UI:** Tailwind CSS, `clsx` für Klassen, `lucide-react` für Icons
|
||||
- **i18n:** `useTranslation()` mit `t('key')`, Keys in `frontend/src/i18n/`
|
||||
- **Sidebar:** Plugin-Manifeste liefern Menu-Items via `usePluginStore` — neue Pages brauchen Plugin-Manifest-Einträge
|
||||
- **Settings:** Hardcoded nav items in `Settings.tsx` + plugin settings_pages
|
||||
- **Error Handling:** `ErrorBoundary` wrappt alle Routes
|
||||
|
||||
### Backend-Konventionen
|
||||
- **Routes:** `app/routes/<name>.py`, registriert in `app/main.py`
|
||||
- **Services:** `app/services/<name>_service.py`
|
||||
- **Models:** `app/models/<name>.py`, Migrationen in `alembic/versions/`
|
||||
- **Schemas:** `app/schemas/<name>.py` (Pydantic)
|
||||
- **Permissions:** `require_permission('plugin:action')` Dependency
|
||||
- **Events:** `event_bus.publish()` für System-Events
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Kritisch für CRM-Betrieb
|
||||
|
||||
### 1.1 Workflows UI
|
||||
|
||||
**Audit-Status:** ✅ Backend vollständig (routes, model, service, execution engine). ✅ API-Client vollständig (`workflows.ts`). ❌ Keine Frontend-Seite. ❌ Kein menu_items-Eintrag im Automation-Plugin.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/pages/Workflows.tsx` — Hauptseite mit Tabs: Definitionen | Instanzen
|
||||
- `frontend/src/components/workflows/WorkflowEditor.tsx` — Visueller Step-Editor
|
||||
- `frontend/src/components/workflows/WorkflowInstanceList.tsx` — Liste laufender/abgeschlossener Instanzen
|
||||
- `frontend/src/components/workflows/WorkflowInstanceDetail.tsx` — Detail mit Step-History, Approve/Reject
|
||||
- `frontend/src/components/workflows/StepConfigPanel.tsx` — Konfiguration pro Step-Typ
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/workflows` + `/workflows/instances/:id`
|
||||
- `app/plugins/builtins/automation/plugin.py` — menu_items Eintrag für Workflows (aktuell `menu_items=[]`)
|
||||
- `frontend/src/i18n/de.json` — Workflow-Übersetzungen
|
||||
|
||||
**Step-Editor:**
|
||||
- Step-Typen: `action`, `approval`, `notification`, `condition`
|
||||
- Drag-and-Drop Reihenfolge (oder Button-basiert nach oben/unten)
|
||||
- Pro Step: Name, Typ, Config-Form
|
||||
- Trigger-Event Dropdown (aus Event-Bus-Events)
|
||||
- Aktiv/Inaktiv Toggle
|
||||
|
||||
**Instanzen-View:**
|
||||
- Status-Filter: pending, in_progress, completed, rejected, cancelled
|
||||
- Pro Instanz: Workflow-Name, Status, Current Step, Timeout
|
||||
- Detail: Step-History Timeline, Approve/Reject Buttons
|
||||
|
||||
**Aufwand:** ~1.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Dedup/Merge UI
|
||||
|
||||
**Audit-Status:** ✅ Backend vollständig (`dedup_service.py`, routes in `contacts.py`: `/duplicates`, `/merge`, `/merge-history`). ✅ API-Client vollständig (`dedup.ts`). ❌ Keine Frontend-Seite.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/pages/DedupMerge.tsx` — Hauptseite mit drei Bereichen
|
||||
- `frontend/src/components/dedup/DuplicatePairCard.tsx` — Side-by-side Vergleich
|
||||
- `frontend/src/components/dedup/MergeDialog.tsx` — Merge-Dialog mit Feld-Auswahl
|
||||
- `frontend/src/components/dedup/MergeHistory.tsx` — Verlauf der durchgeführten Merges
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/contacts/dedup`
|
||||
- `frontend/src/pages/ContactsList.tsx` — Button "Duplikate prüfen" im Header
|
||||
- `frontend/src/i18n/de.json` — Dedup-Übersetzungen
|
||||
|
||||
**Merge-Dialog:**
|
||||
- Side-by-side Feld-Vergleich
|
||||
- Pro Feld Radio: Quelle | Ziel | Manuell eingeben
|
||||
- Vorschau des merged Kontakts
|
||||
- Optionale Notiz
|
||||
- Bestätigungs-Button mit Warnung
|
||||
|
||||
**Aufwand:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Import/Export UI
|
||||
|
||||
**Audit-Status:** ✅ Backend hat `/api/v1/import` + `/api/v1/import/preview` (Routes). ✅ Service hat `import_csv()`, `export_contacts_csv()`. ❌ **Export-Route fehlt** — Service-Funktion existiert aber ist nicht als Endpoint registriert. ❌ Kein Frontend, kein API-Client.
|
||||
|
||||
**Backend-Ergänzung (bestätigt nötig):**
|
||||
- `app/routes/import_export.py` — `GET /api/v1/export?entity_type=contacts&format=csv` hinzufügen
|
||||
- Ruft `export_contacts_csv()` auf, gibt `StreamingResponse` mit CSV zurück
|
||||
- Erweiterung: `entity_type=companies` (Filter auf `Contact.type == 'company'`)
|
||||
- Optional: XLSX-Format via `openpyxl`
|
||||
|
||||
**Neue Frontend-Dateien:**
|
||||
- `frontend/src/pages/ImportExport.tsx` — Hauptseite mit Tabs: Import | Export
|
||||
- `frontend/src/components/import-export/ImportWizard.tsx` — Mehrstufiger Import-Wizard
|
||||
- `frontend/src/components/import-export/ExportPanel.tsx` — Export-Auswahl
|
||||
- `frontend/src/api/importExport.ts` — API-Client (neu)
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/import-export`
|
||||
- Plugin-Manifest — menu_items Eintrag
|
||||
- `frontend/src/i18n/de.json` — Übersetzungen
|
||||
|
||||
**Import-Wizard:**
|
||||
```
|
||||
Step 1: Datei hochladen + Entity-Typ (Companies/Contacts)
|
||||
Step 2: Dry-Run Preview — zeigt erkannte Spalten, Mapping, Fehler
|
||||
Step 3: Bestätigung — Anzahl neu/aktualisiert/fehlerhaft
|
||||
Step 4: Import ausführen — Progress + Ergebnis
|
||||
```
|
||||
|
||||
**Export-Panel:**
|
||||
- Entity: Kontakte / Firmen
|
||||
- Format: CSV (XLSX optional)
|
||||
- Download-Button → File-Download
|
||||
|
||||
**Aufwand:** ~1.5 Tage (inkl. Backend Export-Route)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Print/PDF
|
||||
|
||||
**Audit-Status:** ❌ Komplett fehlend. Keine Print-Utils, keine Print-Buttons, kein `@media print` CSS.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/utils/print.ts` — Print-Utility
|
||||
- `frontend/src/components/common/PrintButton.tsx` — Wiederverwendbarer Print/Export-Button
|
||||
- `frontend/src/styles/print.css` — Print-spezifische CSS
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/pages/ContactsList.tsx` — Print-Button in Toolbar
|
||||
- `frontend/src/pages/ContactDetailPage.tsx` — Print-Button
|
||||
- `frontend/src/pages/Calendar.tsx` — Print-Button
|
||||
- `frontend/src/pages/Reports.tsx` — Print-Button
|
||||
- `frontend/index.html` — Print-CSS einbinden
|
||||
|
||||
**Implementierung:**
|
||||
- Option A: `window.print()` mit `@media print` CSS (empfohlen für Listen/Details)
|
||||
- Option B: `jspdf` + `html2canvas` für echte PDF-Generierung (für Reports)
|
||||
- Print-Button Dropdown: "Drucken" | "Als PDF"
|
||||
|
||||
**Aufwand:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Wichtig für Tagesgeschäft
|
||||
|
||||
### 2.1 Tags UI
|
||||
|
||||
**Audit-Status:** ✅ Backend-Plugin vollständig (`app/plugins/builtins/tags/`: models, routes, schemas). ✅ API-Client vollständig (`tags.ts`). ❌ Keine Frontend-Seite.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/pages/Tags.tsx` — Tag-Verwaltung (CRUD, Farb-Auswahl, Usage-Count)
|
||||
- `frontend/src/components/tags/TagBadge.tsx` — Wiederverwendbares Tag-Badge
|
||||
- `frontend/src/components/tags/TagSelector.tsx` — Multi-Select Tag-Picker
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/tags`
|
||||
- `frontend/src/pages/ContactsList.tsx` — Tag-Spalte + Tag-Filter
|
||||
- `frontend/src/pages/ContactDetailPage.tsx` — Tag-Badges + Tag-Selector
|
||||
- `frontend/src/pages/Calendar.tsx` — Tag-Badges für Termine
|
||||
- `frontend/src/pages/Dms.tsx` — Tag-Badges für Dateien
|
||||
- Plugin-Manifest — menu_items
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Aufwand:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Custom Fields UI
|
||||
|
||||
**Audit-Status:** ✅ Backend hat Custom-Fields-Route (plugin-manifest-gesteuert, Werte in `contacts.custom` JSONB). ❌ Keine User-definierten Feld-Definitionen (nur Plugin-Definitionen). ❌ Keine Frontend-Seite.
|
||||
|
||||
**Backend-Ergänzung nötig:**
|
||||
- `app/models/custom_field_definition.py` — Model für User-definierte Felder
|
||||
- `app/schemas/custom_field_definition.py` — Pydantic Schemas
|
||||
- `app/services/custom_field_service.py` — CRUD-Service
|
||||
- `app/routes/custom_fields.py` — `GET/POST/PATCH/DELETE /api/v1/custom-fields/definitions`
|
||||
- Migration für `custom_field_definitions` Tabelle
|
||||
- Bestehende `_collect_custom_field_definitions()` erweitern um DB-Definitionen
|
||||
|
||||
**Neue Frontend-Dateien:**
|
||||
- `frontend/src/pages/CustomFields.tsx` — Definitionen verwalten
|
||||
- `frontend/src/components/custom-fields/FieldDefinitionForm.tsx` — Form für neue Felder
|
||||
- `frontend/src/components/custom-fields/CustomFieldRenderer.tsx` — Dynamisches Feld-Rendering
|
||||
- `frontend/src/api/customFieldDefinitions.ts` — API-Client für Definitionen
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/settings/custom-fields`
|
||||
- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Custom Fields"
|
||||
- `frontend/src/pages/ContactDetailPage.tsx` — Custom Fields Section
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Feld-Typen:** text, number, date, select, multiselect, boolean
|
||||
|
||||
**Aufwand:** ~1.5 Tage (inkl. Backend CRUD + Migration)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Notifications Dropdown (Bell Icon in TopBar)
|
||||
|
||||
**Audit-Status:** ✅ API-Client vollständig (`notifications.ts`). ✅ Backend vollständig. ❌ TopBar hat kein Bell-Icon (confirmed: `grep` findet nichts). Notifications nur in AISidebar.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/components/layout/NotificationBell.tsx` — Bell-Icon mit Badge + Dropdown
|
||||
- `frontend/src/components/notifications/NotificationDropdown.tsx` — Dropdown-Liste
|
||||
- `frontend/src/components/notifications/NotificationItem.tsx` — Einzelne Notification
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/components/layout/TopBar.tsx` — `<NotificationBell />` vor User-Menu einfügen
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Features:**
|
||||
- Unread-Count Badge (rot)
|
||||
- Polling alle 30s (refetchInterval in useQuery)
|
||||
- Click: Notification als gelesen markieren
|
||||
- "Alle als gelesen" Button
|
||||
- Type-Icon pro Notification
|
||||
- Zeitstempel (relativ: "vor 5 Min")
|
||||
|
||||
**Aufwand:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Nice-to-have / Produktivität
|
||||
|
||||
### 3.1 Saved Filters UI
|
||||
|
||||
**Audit-Status:** ✅ Backend vollständig (`saved_filters.py`: CRUD, entity_types: contacts/mail/calendar/dms). ✅ API-Client vorhanden (`savedFilters.ts`). ❌ Keine UI.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/components/common/SavedFilterBar.tsx` — Filter-Leiste mit Save/Load
|
||||
- `frontend/src/components/common/SaveFilterDialog.tsx` — Dialog zum Speichern
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/pages/ContactsList.tsx` — SavedFilterBar
|
||||
- `frontend/src/pages/Calendar.tsx` — SavedFilterBar
|
||||
- `frontend/src/pages/Dms.tsx` — SavedFilterBar
|
||||
- `frontend/src/pages/Tasks.tsx` — SavedFilterBar
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Aufwand:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Entity History UI
|
||||
|
||||
**Audit-Status:** ✅ Backend vollständig (`entity_history.py`: get/restore/undo). ✅ API-Client vorhanden (`entityHistory.ts`). ❌ Keine UI-Komponente.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/components/common/EntityHistoryPanel.tsx` — Timeline-Komponente
|
||||
- `frontend/src/components/common/HistoryDiff.tsx` — Visualisierung von Feld-Änderungen
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/pages/ContactDetailPage.tsx` — History-Tab/Panel
|
||||
- `frontend/src/pages/Dms.tsx` — History für Dateien
|
||||
- `frontend/src/pages/Calendar.tsx` — History für Termine
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Features:**
|
||||
- Timeline mit create/update/delete Events
|
||||
- Diff-Anzeige: alt → neu pro Feld
|
||||
- Restore-Button pro Eintrag
|
||||
- Undo-Button (letzte Aktion rückgängig)
|
||||
|
||||
**Aufwand:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Activity Timeline
|
||||
|
||||
**Audit-Status:** ✅ `ActivityFeed` Komponente existiert (wiederverwendbar). ✅ Dashboard nutzt sie mit Audit-Daten. ❌ Keine eigenständige Seite mit Filterung/Pagination.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/pages/ActivityTimeline.tsx` — Globale Activity-Feed Seite
|
||||
- `frontend/src/components/activity/ActivityFilter.tsx` — Filter (User, Entity, Action, Zeitraum)
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/routes/index.tsx` — Route `/activity`
|
||||
- `frontend/src/api/audit.ts` — Erweitern um Timeline-Query (alle Entities, Pagination)
|
||||
- `frontend/src/pages/Dashboard.tsx` — Link "Alle Aktivitäten anzeigen"
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Features:**
|
||||
- Wiederverwendung von `ActivityFeed` Komponente
|
||||
- Gruppierung nach Tag
|
||||
- Filter: User, Entity-Typ, Aktion, Zeitraum
|
||||
- Pagination / Infinite-Scroll
|
||||
- Link zu Entity-Detail bei Click
|
||||
|
||||
**Aufwand:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### 3.4 API Documentation Link
|
||||
|
||||
**Audit-Status:** ✅ FastAPI generiert automatisch `/docs` (Swagger) und `/redoc`. ❌ Kein Link im UI.
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/components/layout/TopBar.tsx` — "API Docs" Link im User-Menu
|
||||
- `frontend/src/pages/SettingsSystem.tsx` — "API Dokumentation" Sektion
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Implementierung:**
|
||||
- Link zu Swagger UI: `/docs` (FastAPI auto-docs)
|
||||
- Link zu ReDoc: `/redoc`
|
||||
- In Settings/System: Sektion "Entwickler"
|
||||
|
||||
**Aufwand:** ~0.25 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Backend + Frontend (komplett neu)
|
||||
|
||||
### 4.1 Webhooks
|
||||
|
||||
**Audit-Status:** ❌ Komplett fehlend. Kein Backend, kein Frontend, keine Modelle.
|
||||
|
||||
**Neue Backend-Dateien:**
|
||||
- `app/models/webhook.py` — Webhook-Modell (url, events, secret, is_active, retry_count)
|
||||
- `app/schemas/webhook.py` — Pydantic Schemas
|
||||
- `app/services/webhook_service.py` — Webhook-Service (send, retry, verify HMAC)
|
||||
- `app/routes/webhooks.py` — CRUD-Routes `/api/v1/webhooks`
|
||||
- `app/core/webhook_dispatcher.py` — Event-Bus-Subscriber
|
||||
- `alembic/versions/0036_webhooks.py` — Migration
|
||||
|
||||
**Neue Frontend-Dateien:**
|
||||
- `frontend/src/pages/SettingsWebhooks.tsx` — Webhook-Verwaltung
|
||||
- `frontend/src/components/webhooks/WebhookForm.tsx` — Create/Edit Form
|
||||
- `frontend/src/components/webhooks/WebhookDeliveryLog.tsx` — Delivery-Log
|
||||
- `frontend/src/api/webhooks.ts` — API-Client
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `app/main.py` — Router registrieren
|
||||
- `app/core/event_bus.py` — Webhook-Dispatcher subscriben
|
||||
- `frontend/src/routes/index.tsx` — Route `/settings/webhooks`
|
||||
- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Webhooks"
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Features:**
|
||||
- URL + Secret (HMAC-Signatur)
|
||||
- Event-Auswahl (Multi-Select aus Event-Bus-Events)
|
||||
- Aktiv/Pause Toggle
|
||||
- Delivery-Log mit Status, Response-Code, Latenz
|
||||
- Retry-Konfiguration
|
||||
- Test-Button
|
||||
|
||||
**Aufwand:** ~1.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Backup/Restore UI
|
||||
|
||||
**Audit-Status:** ✅ `backup_check` Cron-Job existiert (prüft last_backup_at, published Events). ❌ Keine Backup-Routes, keine Restore-Funktionalität, keine UI.
|
||||
|
||||
**Backend-Ergänzung:**
|
||||
- `app/routes/backups.py` — `/api/v1/backups` (list, create, restore, delete)
|
||||
- `app/services/backup_service.py` — Backup erstellen (pg_dump), Restore (pg_restore)
|
||||
- `app/models/backup.py` — Backup-Modell
|
||||
- `alembic/versions/0037_backups.py` — Migration
|
||||
|
||||
**Neue Frontend-Dateien:**
|
||||
- `frontend/src/pages/SettingsBackup.tsx` — Backup-Verwaltung
|
||||
- `frontend/src/components/backup/BackupList.tsx` — Liste der Backups
|
||||
- `frontend/src/components/backup/RestoreDialog.tsx` — Restore-Bestätigung
|
||||
- `frontend/src/api/backups.ts` — API-Client
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `app/main.py` — Router registrieren
|
||||
- `frontend/src/routes/index.tsx` — Route `/settings/backup`
|
||||
- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Backup & Restore"
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Aufwand:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Onboarding/Tutorial
|
||||
|
||||
**Audit-Status:** ❌ Komplett fehlend.
|
||||
|
||||
**Neue Dateien:**
|
||||
- `frontend/src/components/onboarding/OnboardingTour.tsx` — Guided Tour
|
||||
- `frontend/src/components/onboarding/WelcomeDialog.tsx` — Willkommens-Dialog
|
||||
- `frontend/src/store/onboardingStore.ts` — Zustand-Store
|
||||
|
||||
**Modifizierte Dateien:**
|
||||
- `frontend/src/components/layout/AppShell.tsx` — OnboardingTour einbinden
|
||||
- `frontend/src/api/userPreferences.ts` — onboarding_completed flag
|
||||
- `frontend/src/i18n/de.json`
|
||||
|
||||
**Tour-Schritte (8):**
|
||||
1. Willkommen
|
||||
2. Sidebar-Navigation
|
||||
3. Globale Suche
|
||||
4. Kontakte erstellen
|
||||
5. Kalender/Termine
|
||||
6. KI Assistent
|
||||
7. Einstellungen
|
||||
8. Fertig
|
||||
|
||||
**Bibliothek:** `react-joyride` oder Custom Implementation
|
||||
|
||||
**Aufwand:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
## Implementations-Reihenfolge
|
||||
|
||||
```
|
||||
Phase 1 (Kritisch)
|
||||
1.1 Workflows UI ████████████████░░░ 1.5 Tage
|
||||
1.2 Dedup/Merge UI ███████████░░░░░░░░ 1.0 Tag
|
||||
1.3 Import/Export UI ████████████████░░░ 1.5 Tage (inkl. Backend Export-Route)
|
||||
1.4 Print/PDF █████░░░░░░░░░░░░░░ 0.5 Tage
|
||||
|
||||
Phase 2 (Wichtig)
|
||||
2.1 Tags UI ███████████░░░░░░░░ 1.0 Tag
|
||||
2.2 Custom Fields UI ████████████████░░░ 1.5 Tage (inkl. Backend CRUD)
|
||||
2.3 Notifications Bell █████░░░░░░░░░░░░░░ 0.5 Tage
|
||||
|
||||
Phase 3 (Nice-to-have)
|
||||
3.1 Saved Filters UI █████░░░░░░░░░░░░░░ 0.5 Tage
|
||||
3.2 Entity History UI █████░░░░░░░░░░░░░░ 0.5 Tage
|
||||
3.3 Activity Timeline █████░░░░░░░░░░░░░░ 0.5 Tage
|
||||
3.4 API Docs Link ██░░░░░░░░░░░░░░░░░ 0.25 Tage
|
||||
|
||||
Phase 4 (Backend + Frontend)
|
||||
4.1 Webhooks ████████████████░░░ 1.5 Tage
|
||||
4.2 Backup/Restore UI ███████████░░░░░░░░ 1.0 Tag
|
||||
4.3 Onboarding/Tutorial ███████████░░░░░░░░ 1.0 Tag
|
||||
```
|
||||
|
||||
## Deployment-Strategie
|
||||
|
||||
### Nach jeder Phase:
|
||||
1. Frontend Build: `cd frontend && npm run build`
|
||||
2. Git commit + push
|
||||
3. Coolify Auto-Deploy
|
||||
4. Verifikation im Browser
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
```
|
||||
1.1 Workflows UI ← keine (API ready)
|
||||
1.2 Dedup/Merge UI ← keine (API ready)
|
||||
1.3 Import/Export UI ← Backend Export-Route hinzufügen (Service existiert)
|
||||
1.4 Print/PDF ← keine
|
||||
|
||||
2.1 Tags UI ← keine (API ready)
|
||||
2.2 Custom Fields UI ← Backend CRUD + Migration (neu)
|
||||
2.3 Notifications Bell ← keine (API ready)
|
||||
|
||||
3.1 Saved Filters ← keine (API ready)
|
||||
3.2 Entity History ← keine (API ready)
|
||||
3.3 Activity Timeline ← Audit-API ggf. erweitern (Pagination)
|
||||
3.4 API Docs Link ← keine
|
||||
|
||||
4.1 Webhooks ← Backend komplett neu + Migration
|
||||
4.2 Backup/Restore ← Backend komplett neu + Migration
|
||||
4.3 Onboarding ← User-Preferences API ggf. erweitern
|
||||
```
|
||||
|
||||
## Risiko-Bewertung
|
||||
|
||||
| Feature | Risiko | Grund |
|
||||
|---------|--------|-------|
|
||||
| Workflows UI | Mittel | Komplexe Step-Editor UI |
|
||||
| Custom Fields UI | Hoch | Backend-Ergänzung + dynamisches Rendering |
|
||||
| Webhooks | Hoch | Backend komplett neu, Security (HMAC, Retry) |
|
||||
| Backup/Restore | Hoch | Datenverlust-Risiko bei Fehlern |
|
||||
| Import/Export | Mittel | Backend Export-Route fehlt, Datei-Handling |
|
||||
| Alle anderen | Niedrig | API existiert, nur UI |
|
||||
|
||||
---
|
||||
|
||||
## Entfallenes Feature
|
||||
|
||||
### ~~Permissions Management UI~~ — BEREITS VORHANDEN
|
||||
- `SettingsRoles.tsx` (531 Zeilen) hat vollständige Permission-Verwaltung
|
||||
- `ShareDialog.tsx` (11KB) nutzt File-Permissions API
|
||||
- `roles.ts` API-Client hat `usePermissions()`, `useRoles()`, `useCreateRole()`, `useUpdateRole()`, `useDeleteRole()`
|
||||
- Backend `/roles/permissions` liefert alle System+Plugin-Permissions
|
||||
- Backend Roles-CRUD erlaubt Permission-Zuweisung (grant/deny/field-level)
|
||||
|
||||
---
|
||||
|
||||
*Plan erstellt am 26.07.2026, audit-korrigiert um 02:17 — bereit zur Umsetzung.*
|
||||
-754
@@ -1,754 +0,0 @@
|
||||
# LeoCRM — Master Plan: Umbau & Vollendung
|
||||
|
||||
**Erstellt:** 2026-07-22
|
||||
**Status:** Draft — zur Freigabe
|
||||
**Letzte Revision:** 2026-07-22 (gründliche Überprüfung nach Code-Tiefenanalyse)
|
||||
|
||||
---
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
### Was bereits gut ist
|
||||
- Backend: ~35.800 Zeilen, 12 Plugins, Multi-Tenant mit RLS, Rate Limiting, Audit Log
|
||||
- Unified Contact Model: **BEREITS implementiert** (Migration 0021) — Contact mit type='company'|'person', ContactPerson als 1:N child (wie Rentman)
|
||||
- Frontend: ~30.000 Zeilen, 27 Pages, 70 Components, i18n DE/EN, TanStack Query, TipTap
|
||||
- Tests: ~17.300 Zeilen Backend-Tests, 38 Vitest-Dateien
|
||||
- Docker: Multi-Stage-Build (Frontend+Backend in einem Container)
|
||||
- Datenbank: PostgreSQL 16 als separater docker-compose Service
|
||||
- WebSocket-Infrastruktur: Bereits im `kommunikation` Plugin vorhanden (`/api/v1/comm/ws`) — kann als Referenz für KI-UI-Steuerung dienen
|
||||
|
||||
### Was fehlt oder nicht stimmt
|
||||
- Frontend nutzt unified Contact Model nicht vollständig (keine Contact-Detail-Route, ContactPerson-Verwaltung fehlt in UI)
|
||||
- **'company' als entity_type ist in 6 Plugins verankert** — muss zu 'contact' vereinheitlicht werden
|
||||
- Plugin-UI-System fehlt (hartkodierte Routes statt dynamische Registry)
|
||||
- Code-Splitting fehlt (alle 27 Pages im Main Bundle)
|
||||
- E2E Tests fehlen komplett
|
||||
- KI-UI-Steuerung fehlt
|
||||
- Virtual Scrolling fehlt
|
||||
- React Hook Form + Zod nicht überall
|
||||
- hooks.ts ist Monolith (1.298 Zeilen)
|
||||
- Fehlende Dependencies (lucide-react, date-fns)
|
||||
- Plugin-Richtlinien fehlen
|
||||
|
||||
### Wichtige Unterscheidung: 'company' hat zwei Bedeutungen
|
||||
1. **entity_type='company'** in Plugins (entity_links, calendar, tags, mail) → referenziert eine Firma als Entität → **MUSS zu 'contact' werden**
|
||||
2. **system_settings.company_*** Felder (company_name, company_street etc.) → CRM-Besitzer-Firmeninfo für Rechnungen → **BLEIBT wie es ist**
|
||||
3. **CalendarType='company'** → Kalender-Typ (Firmenkalender) → kann bleiben oder zu 'organization' umbenannt werden (kosmetisch)
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entscheidungen (freigegeben 2026-07-22)
|
||||
|
||||
1. **KI-UI-Steuerung:** Keine Mausbewegung nötig. KI muss zu Kontakten springen und einen Kontakt öffnen können. Die UI muss das Ergebnis zeigen — Kontaktliste und spezieller Kontakt ausgewählt. Implementierungsweg (WebSocket, postMessage, etc.) ist offen, Hauptsache das Ergebnis wird in der UI sichtbar.
|
||||
2. **Company-Routes:** Komplett entfernen. Keine deprecated-Routes, keine Redirects. Kontakte wie in Rentman — ein unified Contact-Modell, kein separates Company-Modell mehr. **Alle Plugin-Referenzen auf entity_type='company' müssen zu 'contact' migriert werden.**
|
||||
3. **PostgreSQL:** Aktuell egal (Coolify-managed oder docker-compose). Reine Docker-Lösung soll später möglich sein. Keine Code-Änderung nötig — nur Konfiguration.
|
||||
4. **S3-Storage:** Provider egal. Wichtig ist nur dass die Architektur es später ermöglicht. Bereits vorbereitet in config.py (STORAGE_BACKEND=s3).
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Plan
|
||||
|
||||
### PHASE 0: Vorbereitung & Cleanup
|
||||
**Ziel:** Codebasis bereinigen, Dependencies installieren, veraltete Dokumente aktualisieren
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 0.1 | Veraltete Planungsdokumente aktualisieren | 2h | `codebase-vs-requirements.md` neu schreiben (beschreibt alten Stand), `architecture.md` um Implementation-Status erweitern, `security-review-phase2.md` um 'Resolved' Markierungen ergänzen |
|
||||
| 0.2 | `lucide-react` installieren + Icons migrieren | 4h | Inline SVGs durch lucide-react Icons ersetzen. Konsistente Icon-Bibliothek. |
|
||||
| 0.3 | `date-fns` installieren + Datum-Formatierung | 3h | Alle `toLocaleDateString()` etc. durch date-fns ersetzen. Konsistente Datum-Formatierung. |
|
||||
| 0.4 | `hooks.ts` aufteilen | 3h | 1.298 Zeilen aufteilen in `api/auth.ts`, `api/contacts.ts`, `api/settings.ts` etc. Generische Hooks bleiben in `hooks.ts`. Company-Hooks werden in Phase 1 entfernt, nicht aufgeteilt. |
|
||||
| 0.5 | Store-Verzeichnis konsolidieren | 1h | `store/` und `stores/` zusammenführen. |
|
||||
| 0.6 | Frontend-Bestandsanalyse als Dokument speichern | 1h | `frontend-gap-analysis.md` mit vollständiger Analyse. |
|
||||
| 0.7 | UI-Design-Richtlinien erstellen | 6h | `docs/ui-design-guidelines.md` basierend auf bestehenden Plugin-Patterns (siehe unten). |
|
||||
| 0.8 | Theme-Customization Backend | 4h | `system_settings` um Theme-Felder erweitern (primary_color, accent_color, font_family, border_radius). Neue Alembic-Migration. API-Endpoints zum Lesen/Schreiben der Theme-Settings. |
|
||||
| 0.9 | Theme-Customization Frontend | 6h | `SettingsTheme.tsx` Seite mit Color-Picker, Font-Auswahl, Live-Preview. Tailwind-CSS-Variablen dynamisch aus API-Settings überschreiben. Dark-Mode-Toggle. Theme wird beim App-Start geladen und angewendet. |
|
||||
| 0.10 | RBAC-Audit & Plugin-Permissions nachrüsten | 6h | 4 Plugins haben `permissions=[]` (calendar, dms, entity_links, tags) → keine Rechte-Prüfung! Pro Plugin passende Permissions definieren und in Manifest eintragen. Routes mit `require_permission()` absichern. Siehe Details unten. |
|
||||
| 0.11 | LiteLLM-Cleanup & alte llm_client.py migrieren | 3h | LiteLLM ist **BEREITS** in ai_assistant und ai_proactive integriert (`litellm.acompletion()`). Nur die alte `llm_client.py` (Copilot) nutzt noch httpx direkt. Diese auf LiteLLM umstellen oder entfernen. System-Prompt in llm_client.py referenziert noch `/api/v1/companies` → auf Contacts umstellen. |
|
||||
| 0.12 | KI-Agent-Framework in Plugin-Richtlinien dokumentieren | 2h | PydanticAI + tool_registry existieren bereits. In `docs/plugin-development-guide.md` dokumentieren: Wie Plugins KI-Agenten, Tools und LLM-Funktionen nutzen. Plugin-Manifest um `agent_capabilities` Feld erweitern. |
|
||||
| 0.13 | Heartbeat konfigurierbar machen | 3h | Heartbeat-Intervall, Aktivierung, Ziel-Room in ProactiveSettings (DB) speichern. Settings-UI für Heartbeat-Konfiguration. |
|
||||
| 0.14 | Unified Search: Field-Level RBAC nachrüsten | 4h | Search-Provider prüfen aktuell KEINE Feld-Level-Permissions. Nutzer mit `search:read` sieht alle Felder. Provider müssen `resolved_perms` prüfen und `hidden` Felder ausblenden. `to_search_result()` um Permission-Filter ergänzen. |
|
||||
| 0.15 | Undo/History-System für CRUD-Operationen | 8h | Globale Undo-History: Jede CRUD-Aktion (Create/Update/Delete) wird mit Snapshot in `entity_history` Tabelle gespeichert. User kann Änderungen rückgängig machen oder zu früherer Version zurückkehren. Nutzt bestehenden Audit-Log als Basis. Frontend: Undo-Button + History-Viewer pro Entity. |
|
||||
| 0.16 | Storage Backend implementieren (S3-Support) | 8h | Architecture.md beschreibt abstract StorageBackend (local/S3), aber **existiert NICHT im Code**. Attachments nutzen hardcoded `/data/uploads`. Storage-Klasse erstellen: `LocalStorage` + `S3Storage`. Config um `STORAGE_BACKEND`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` erweitern. DMS und Attachments auf Storage-Backend umstellen. .env.example um S3-Variablen ergänzen. |
|
||||
| 0.17 | Import/Export an unified Contact Model anpassen | 4h | Import/Export nutzt alte Feldnamen (`first_name`, `last_name`, `mobile`, `position`, `department`). Auf unified Contact-Felder umstellen (`firstname`, `surname`, `phone_1`, `email_1`, etc.). Company-Import auf Contact mit type='company' umstellen. |
|
||||
| 0.18 | .gitignore & Config-Cleanup | 2h | `.gitignore` hat `webui/` statt `frontend/` — frontend/node_modules und frontend/dist werden nicht ignoriert! Korrigieren. `python-jose` (JWT) aus requirements.txt entfernen — Code nutzt Session-Auth. `pyproject.toml` Python-Version auf 3.12 aktualisieren. `.env.docker.example` JWT-Variablen entfernen. **.env aus Git entfernen** (ist committet aber sollte nicht sein). `dump.rdb` und `test.txt` aus Repo löschen. `frontend/dist/` aus Git entfernen (sollte nicht committet sein). |
|
||||
| 0.19 | Mail-Salt Security-Fix | 2h | `mail/services.py` hat hardcoded salt `b"leocrm-mail-salt"` für Passwort-Verschlüsselung. Salt sollte random pro Account sein. Fix: Random salt generieren und mit encrypted_password zusammen speichern. DB-Migration für bestehende Accounts. |
|
||||
| 0.20 | AGPL-Lizenzen durch kommerziell nutzbare Alternativen ersetzen | 6h | **PyMuPDF** (AGPL-3.0) → ersetzen durch `pypdf` (BSD). Text-Extraktion in unified_search anpassen. **OnlyOffice** (AGPL-3.0) → ersetzen durch **Collabora Online** (LGPL/MPL). DMS Edit-Sessions auf Collabora umstellen. `requirements.txt`, `Dockerfile`, `docker-compose.yml`, `architecture.md` aktualisieren. DMS Plugin `OnlyOfficeConfig` → `CollaboraConfig`. Frontend DMS-Komponenten anpassen. Lizenz-Datei (`LICENSE`) und `THIRD_PARTY_LICENSES.md` erstellen. |
|
||||
|
||||
**Phase 0 Gesamt: ~77h**
|
||||
|
||||
### UI-Design-Richtlinien (Task 0.7)
|
||||
|
||||
Basierend auf Analyse der bestehenden Plugins (Calendar, Mail, DMS, Contacts):
|
||||
|
||||
**Layout-Patterns:**
|
||||
- **3-Spalten-Explorer-Layout** (Tree | Liste/Explorer | Detail) — verwendet von Calendar, Mail, DMS
|
||||
- **ResizablePanel** für drag-to-resize Spalten — bereits implementiert
|
||||
- **PluginToolbar** für Plugin-Aktionen (oben) — bereits implementiert
|
||||
- **Modal** für Formulare (Create/Edit/Delete-Bestätigung) — bereits implementiert
|
||||
- **EmptyState** für leere Listen — bereits implementiert
|
||||
- **LoadingState/Skeleton** für Lade-Zustände — bereits implementiert
|
||||
|
||||
**Farbsystem (Tailwind Design Tokens):**
|
||||
- `primary` (Blau #2563eb) — Hauptaktionen, aktive Zustände
|
||||
- `secondary` (Slate #64748b) — Text, Borders, Hintergründe
|
||||
- `accent` (Fuchsia #d946ef) — Hervorhebungen, Info-Badges
|
||||
- `danger` (Rot #dc2626) — Löschen, Fehler
|
||||
- `warning` (Amber #f59e0b) — Warnungen
|
||||
- `success` (Grün #16a34a) — Erfolg, Bestätigungen
|
||||
- Jede Farbe mit 50-900 Schattierungen
|
||||
- **Dark Mode** via `darkMode: 'class'` — CSS-Variablen in `:root` und `.dark`
|
||||
|
||||
**Typografie:**
|
||||
- Font: `Inter` (system-ui fallback)
|
||||
- Mono: `JetBrains Mono` für Code/Daten
|
||||
- Größen: xs (0.75rem) bis 4xl (2.25rem)
|
||||
- Zeilenhöhen definiert pro Größe
|
||||
|
||||
**Komponenten-Konventionen:**
|
||||
- **Button**: 4 Varianten (primary/secondary/danger/ghost), 3 Größen (sm/md/lg), `min-h-touch` (44px), `focus-visible:ring-2`
|
||||
- **Card**: Titel + Beschreibung + Actions (header), Body, optional Footer (bg-secondary-50)
|
||||
- **Badge**: 7 Varianten (default/primary/success/warning/danger/info/secondary), optional dot
|
||||
- **Input/Select**: `focus-ring` Klasse, `border-secondary-200`, `rounded-md`
|
||||
- **Modal**: `size` prop (sm/md/lg/xl), `ConfirmDialog` für Bestätigungen
|
||||
- **Table/DataGrid**: TanStack Table, ARIA-labels auf sortierbare Headers
|
||||
- **Toast**: `useToast()` Hook für Benachrichtigungen
|
||||
|
||||
**Spacing & Layout:**
|
||||
- Standard-Padding: `px-6 py-4` (Card body), `p-4` (Panel)
|
||||
- Gap: `gap-2` (Buttons), `gap-4` (Sections), `gap-6` (Columns)
|
||||
- Border-Radius: `rounded-md` (0.5rem) Standard, `rounded-lg` (0.75rem) für Cards
|
||||
- Shadow: `shadow-sm` (Cards), `shadow-md` (Dropdowns), `shadow-lg` (Modals)
|
||||
|
||||
**Accessibility (bereits implementiert):**
|
||||
- `focus-ring` Klasse: `focus-visible:ring-2 focus-visible:ring-primary-500`
|
||||
- `btn-touch` Klasse: `min-h-touch min-w-touch` (44px)
|
||||
- `sr-only` und `sr-only-focusable` Klassen
|
||||
- `prefers-reduced-motion` Media Query
|
||||
- `aria-hidden="true"` auf dekorativen SVGs
|
||||
- `aria-label` auf interaktiven Elementen ohne sichtbaren Text
|
||||
|
||||
**Plugin-UI-Patterns (für neue Plugins):**
|
||||
- Jede Plugin-Seite folgt dem 3-Spalten-Layout (wenn anwendbar)
|
||||
- PluginToolbar für Aktionen (Create, Import, Export, etc.)
|
||||
- Plugin-Settings als eigene Settings-Sub-Seite
|
||||
- Plugin-Detail-Tabs (z.B. "Dateien" bei Contact-Detail)
|
||||
- Konsistente EmptyState-Komponente wenn keine Daten
|
||||
- Konsistente LoadingState/Skeleton-Komponente beim Laden
|
||||
- Toast für Erfolg/Fehler-Meldungen nach Aktionen
|
||||
- ConfirmDialog vor destruktiven Aktionen
|
||||
|
||||
**Was im Design-Guide dokumentiert wird:**
|
||||
1. Farbsystem mit Verwendungsregeln (wann welche Farbe)
|
||||
2. Typografie-Hierarchie (Überschriften, Body-Text, Labels)
|
||||
3. Layout-Patterns (3-Spalten, Modal, Settings-Tree)
|
||||
4. Komponenten-Verwendung (welche Komponente für was)
|
||||
5. Spacing & Sizing Konventionen
|
||||
6. Accessibility-Regeln
|
||||
7. Dark-Mode-Regeln
|
||||
8. Plugin-UI-Patterns für neue Plugins
|
||||
9. Do's & Don'ts
|
||||
10. Code-Beispiele aus bestehenden Plugins
|
||||
|
||||
### RBAC-Audit & Plugin-Permissions (Task 0.10)
|
||||
|
||||
**Problem:** 4 Plugins haben `permissions=[]` im Manifest → keine Rechte-Prüfung auf ihren Routes:
|
||||
|
||||
| Plugin | Aktuell | Muss definiert werden |
|
||||
|---|---|---|
|
||||
| **calendar** | `permissions=[]` | `calendar:read`, `calendar:write`, `calendar:delete`, `calendar:share`, `calendar:admin` |
|
||||
| **dms** | `permissions=[]` | `dms:read`, `dms:write`, `dms:delete`, `dms:share`, `dms:admin` |
|
||||
| **entity_links** | `permissions=[]` | `entity_links:read`, `entity_links:write`, `entity_links:delete` |
|
||||
| **tags** | `permissions=[]` | `tags:read`, `tags:write`, `tags:delete`, `tags:admin` |
|
||||
|
||||
**Was zu tun ist:**
|
||||
1. Pro Plugin passende Permissions im Manifest definieren
|
||||
2. Alle Plugin-Routes mit `require_permission()` absichern
|
||||
3. Permission-Registry registriert Plugin-Permissions automatisch beim Aktivieren
|
||||
4. Admin kann Permissions in Rollen-Editor zuweisen
|
||||
5. Tests: User ohne Permission → 403, User mit Permission → 200
|
||||
|
||||
**Zusätzlich in Phase 1 (Permission-Registry-Cleanup):**
|
||||
- `companies:read/write/delete` aus `CORE_PERMISSIONS` entfernen (wird zu `contacts:read/write/delete`)
|
||||
- `CORE_FIELD_DEFINITIONS` aktualisieren: alte Felder (`first_name`, `last_name`, `mobile`, `position`, `department`, `linkedin_url`) durch unified Contact-Felder ersetzen (`firstname`, `surname`, `phone_1`, `email_1`, etc.)
|
||||
- `companies` Field-Definitions entfernen
|
||||
|
||||
### LiteLLM-Integration (Task 0.11)
|
||||
|
||||
**Problem:** Aktuelle `llm_client.py` spricht nur OpenAI-compatible API direkt via httpx. Keine Unterstützung für Anthropic, Google, lokale Modelle etc.
|
||||
|
||||
**Lösung:** LiteLLM als unified LLM-Interface integrieren.
|
||||
|
||||
**Was LiteLLM bietet:**
|
||||
- 100+ LLM-Provider über eine einheitliche API (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Ollama, etc.)
|
||||
- Konsistente Request/Response-Formate
|
||||
- Streaming-Support
|
||||
- Fallback/Routing-Regeln
|
||||
- Cost-Tracking
|
||||
- Rate-Limiting
|
||||
|
||||
**Was zu tun ist:**
|
||||
1. `litellm` als Python-Dependency hinzufügen
|
||||
2. `llm_client.py` auf LiteLLM umstellen: `litellm.acompletion()` statt direktem httpx-Call
|
||||
3. Konfiguration via Env-Vars: `AI_MODEL`, `AI_API_KEY`, `AI_API_BASE` (bleiben gleich), plus `AI_PROVIDER` (neu: openai/anthropic/google/ollama/etc.)
|
||||
4. AI Assistant Plugin nutzt LiteLLM für Multi-Provider-Support
|
||||
5. AI Proactive Plugin nutzt LiteLLM für Suggestions
|
||||
6. Zukünftige Plugins können LiteLLM einfach nutzen — einheitliches Interface
|
||||
7. Mock-Mode für Tests beibehalten (wenn kein API-Key gesetzt)
|
||||
8. Plugin-Entwickler-Richtlinien: Wie man LiteLLM in neuen Plugins nutzt
|
||||
|
||||
**Architektur:**
|
||||
```
|
||||
Plugin (ai_assistant, ai_proactive, zukünftige)
|
||||
↓
|
||||
LiteLLM (unified LLM interface)
|
||||
↓
|
||||
Provider (OpenAI, Anthropic, Google, Ollama, ...)
|
||||
```
|
||||
|
||||
**Vorteil für zukünftige Plugins:**
|
||||
- Ein Plugin kann LLM-Funktionen nutzen ohne sich um den Provider zu kümmern
|
||||
- Admin kann Provider in Settings konfigurieren
|
||||
- KI-Modelle können ausgetauscht werden ohne Code-Änderung
|
||||
|
||||
---
|
||||
|
||||
### PHASE 1: Unified Contact Model — Vollendung (Backend + Frontend)
|
||||
**Ziel:** 'company' als separates Konzept komplett entfernen. Alles ist 'contact' mit type='company'|'person'. Wie Rentman.
|
||||
|
||||
#### 1A: Backend — Company-Routes & Services entfernen
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `app/routes/companies.py` entfernen | 1h | 303 Zeilen. Router aus `main.py`/`routes/__init__.py` austragen. |
|
||||
| 1.2 | `app/services/company_service.py` entfernen | 1h | 273 Zeilen. Importe aus `services/__init__.py` entfernen. |
|
||||
| 1.3 | `app/models/company.py` entfernen | 1h | Backward-compat shim. Importe überall auf `Contact` umstellen. |
|
||||
| 1.4 | `app/schemas/company.py` entfernen | 1h | CompanyCreate, CompanyUpdate, CompanyResponse etc. |
|
||||
| 1.5 | `app/ai/action_mapper.py` aktualisieren | 3h | Company-Intents (create_company, delete_company, update_company, list_company) auf Contact-API umstellen. Regex-Patterns anpassen. |
|
||||
| 1.6 | `app/workflows/engine.py` aktualisieren | 1h | Event `company.created` → `contact.created`. Workflow-Trigger anpassen. |
|
||||
| 1.7 | `app/core/worker.py` aktualisieren | 1h | `index_company` Referenzen → `index_contact`. |
|
||||
| 1.8 | `app/core/seeds.py` prüfen/aktualisieren | 1h | Falls Company-Seed-Daten existieren, auf Contact mit type='company' umstellen. |
|
||||
|
||||
**1A Gesamt: ~10h**
|
||||
|
||||
#### 1B: Backend — Plugins von entity_type='company' befreien
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 1.9 | **entity_links Plugin** aktualisieren | 4h | `entity_type` Pattern von `^(company|contact)$` → `^contact$`. `company_router` entfernen. `on_company_deleted` → `on_contact_deleted`. Event `company.deleted` → `contact.deleted`. DB-Migration: bestehende EntityLinks mit entity_type='company' auf 'contact' migrieren. |
|
||||
| 1.10 | **unified_search Plugin** aktualisieren | 6h | `CompanySearchProvider` → wird zu `ContactSearchProvider` oder bleibt als Provider für type='company' Kontakte. `index_company` → `index_contact`. Events `company.created/updated` → `contact.created/updated`. `search_engine.py` Mapping `"company" → "contacts"` anpassen. `jobs.py` aktualisieren. |
|
||||
| 1.11 | **calendar Plugin** aktualisieren | 3h | `entity_type` Pattern von `^(company|contact)$` → `^contact$`. CalendarEntryLink entity_type anpassen. DB-Migration: bestehende Links migrieren. CalendarType='company' kann bleiben (Kalender-Typ, nicht Entity-Referenz). |
|
||||
| 1.12 | **tags Plugin** aktualisieren | 3h | `entity_type` Pattern von `^(company|contact|file|folder)$` → `^(contact|file|folder)$`. DB-Migration: bestehende Tag-Assignments mit entity_type='company' auf 'contact' migrieren. |
|
||||
| 1.13 | **mail Plugin** aktualisieren | 4h | `mail.company_id` Spalte → `mail.contact_id` (DB-Migration). Routes, Schemas, Services aktualisieren. `company_id` Referenzen in Frontend-API-Modul. |
|
||||
| 1.14 | **test_sample Plugin** aktualisieren | 1h | `company.created` Event → `contact.created`. Test-Plugin ist Referenz für Plugin-Entwicklung. |
|
||||
| 1.15 | **Event-Namen vereinheitlichen** | 2h | Alle `company.created/updated/deleted` Events → `contact.created/updated/deleted`. Event-Publisher in contact_service.py prüfen. |
|
||||
| 1.16 | **DB-Migration: entity_type 'company' → 'contact'** | 3h | Alembic-Migration: UPDATE entity_links SET entity_type='contact' WHERE entity_type='company'. UPDATE tag_assignments SET entity_type='contact' WHERE entity_type='company'. UPDATE calendar_entry_links SET entity_type='contact' WHERE entity_type='company'. ALTER TABLE mails RENAME COLUMN company_id TO contact_id. |
|
||||
| 1.17 | **Backend-Tests aktualisieren** | 4h | Alle Tests die Company-Routes oder entity_type='company' referenzieren umstellen. `test_companies.py` entfernen oder zu Contact-Tests umschreiben. |
|
||||
| 1.18 | **Permission-Registry-Cleanup** | 3h | `companies:read/write/delete` aus `CORE_PERMISSIONS` entfernen. `CORE_FIELD_DEFINITIONS` aktualisieren: alte Felder durch unified Contact-Felder ersetzen. `companies` Field-Definitions entfernen. |
|
||||
| 1.19 | **Addresses entity_type='company' → 'contact'** | 2h | `address_service.py` `VALID_ENTITY_TYPES` von `{"company", "contact"}` → `{"contact"}`. `address.py` Model anpassen. DB-Migration: bestehende Adressen mit entity_type='company' auf 'contact' migrieren. |
|
||||
| 1.20 | **conftest.py aktualisieren** | 2h | `conftest.py` importiert `Company` und `CompanyContact` aus alten Modellen. Auf unified Contact Model umstellen. Test-Fixtures anpassen. |
|
||||
|
||||
**1B Gesamt: ~33h**
|
||||
|
||||
#### 1C: Frontend — Unified Contact UI
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 1.18 | Contact-Detail-Route hinzufügen | 2h | Route `/contacts/:id` in `routes/index.tsx`. `ContactDetail.tsx` (372 Zeilen) existiert bereits als Komponente. |
|
||||
| 1.19 | ContactList mit Type-Filter (company/person) | 4h | `ContactsList.tsx` (445 Zeilen) um Type-Filter erweitern. Tabs oder Toggle: "Alle | Firmen | Personen". |
|
||||
| 1.20 | ContactDetail um ContactPerson-Verwaltung erweitern | 8h | Bei type='company': Ansprechpartner-Liste, Ansprechpartner hinzufügen/bearbeiten/löschen. ContactPerson API-Hooks in Frontend. |
|
||||
| 1.21 | ContactEditModal für beide Types | 6h | Formular je nach type unterschiedlich: company → name, person → firstname/surname. Adressen (mailing/visit/invoice). |
|
||||
| 1.22 | Company-Hooks aus `hooks.ts` entfernen | 2h | `useCompanies`, `useCompany`, `useCreateCompany`, `useUpdateCompany`, `useDeleteCompany`, `useCompanyExport`, `useCompanyImport` entfernen. Company-Interface entfernen. |
|
||||
| 1.23 | Frontend Type-Definitions aktualisieren | 2h | `calendar.ts`: entity_type 'company' → 'contact'. `tags.ts`: EntityType 'company' entfernen. `search.ts`: type 'company' → 'contact'. `mail.ts`: company_id → contact_id. |
|
||||
| 1.24 | Dashboard.tsx aktualisieren | 1h | `useUnifiedContacts(1, 1, undefined, 'company')` → `useUnifiedContacts(1, 1, undefined, 'company')` (type-Filter bleibt, ist jetzt Contact type nicht Company entity). |
|
||||
| 1.25 | GlobalSearchResults.tsx aktualisieren | 2h | Search result type 'company' → 'contact'. Grouping, Icons, Labels anpassen. |
|
||||
| 1.26 | ContactFolderTree in ContactList integrieren | 4h | Ordner-Baum links, Kontaktliste rechts. Drag & Drop Kontakte in Ordner. |
|
||||
| 1.27 | React Hook Form + Zod in ContactEditModal | 3h | Strukturierte Validierung für alle Contact-Felder. |
|
||||
| 1.28 | Frontend-Tests aktualisieren | 4h | Tests für Contact-Detail, ContactEditModal, ContactPerson-Verwaltung. Company-Test-Referenzen entfernen. |
|
||||
|
||||
**1C Gesamt: ~38h**
|
||||
|
||||
**Phase 1 Gesamt: ~81h** (vorher 33h — unterschätzt um 48h!)
|
||||
|
||||
---
|
||||
|
||||
### PHASE 2: Code-Splitting & Performance
|
||||
**Ziel:** Frontend lädt nur was nötig ist. Virtual Scrolling überall.
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 2.1 | React.lazy + Suspense für alle Routes | 4h | Alle Page-Imports in `routes/index.tsx` auf `React.lazy()` umstellen. `<Suspense>` mit Loading-Fallback. |
|
||||
| 2.2 | `@tanstack/react-virtual` installieren | 1h | Dependency hinzufügen. |
|
||||
| 2.3 | Virtual Scrolling in DataGrid | 6h | `DataGrid.tsx` um Virtual Scrolling erweitern. Nur sichtbare Zeilen rendern. |
|
||||
| 2.4 | Virtual Scrolling in MailList | 4h | `MailList.tsx` um Virtual Scrolling erweitern. |
|
||||
| 2.5 | Virtual Scrolling in ContactList | 4h | `ContactList.tsx` um Virtual Scrolling erweitern. |
|
||||
| 2.6 | Virtual Scrolling in allen anderen Listen | 4h | AuditLog, Calendar Entries, DMS FileGrid, etc. |
|
||||
| 2.7 | Bundle-Analyse & Optimierung | 2h | `vite-bundle-visualizer` prüfen, manuelle Chunks für große Dependencies. |
|
||||
|
||||
**Phase 2 Gesamt: ~25h**
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3: Plugin-UI-System (WordPress-Style)
|
||||
**Ziel:** Dynamisches Plugin-UI-Loading. Plugins registrieren sich selbst.
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Plugin-Manifest-Frontend-Endpoint | 4h | Backend-Endpoint `GET /api/v1/plugins/active-manifests` liefert alle aktiven Plugin-Manifeste mit UI-Definitionen (routes, menu_items, detail_tabs, settings_pages, dashboard_widgets). |
|
||||
| 3.2 | `PluginRegistry.tsx` erstellen | 8h | Fetcht aktive Plugin-Manifeste beim App-Start. Registriert Routes, Menu-Items, Detail-Tabs, Settings-Pages dynamisch. |
|
||||
| 3.3 | `PluginLoader.tsx` erstellen | 6h | Lazy-loaded Plugin-Komponenten via `React.lazy()`. Suspense-Boundaries pro Plugin. Error-Boundary falls Plugin nicht lädt. |
|
||||
| 3.4 | Sidebar dynamisch aus Plugin-Manifesten | 4h | Sidebar rendert Menu-Items aus Plugin-Registry statt hartkodierte Items. |
|
||||
| 3.5 | Settings-Baum dynamisch aus Plugin-Manifesten | 4h | Settings-Pages werden dynamisch aus Plugin-Manifesten generiert. |
|
||||
| 3.6 | Detail-Tabs dynamisch (Contact-Detail) | 4h | Plugin-Detail-Tabs (z.B. "Dateien", "E-Mails", "Kalender") werden dynamisch gerendert. |
|
||||
| 3.7 | Plugin-Routen aus hartkodiertem Router entfernen | 4h | Statische Plugin-Imports aus `routes/index.tsx` entfernen. Alles über PluginRegistry. |
|
||||
| 3.8 | Plugin-Entwickler-Richtlinien erstellen | 8h | `docs/plugin-development-guide.md`: Manifest-Format, Lifecycle, UI-Registrierung, Event-Bus, Migration-Runner, Service-Container, Beispiele, Do's & Don'ts, Testing-Guide. |
|
||||
| 3.9 | Plugin-Templates / Boilerplate | 4h | `templates/plugin-template/`: Minimal-Plugin als Startpunkt für neue Plugins. Mit Manifest, Routes, Models, Schemas, Migration, Tests. |
|
||||
| 3.10 | Tests für Plugin-UI-System | 4h | Vitest-Tests für PluginRegistry, PluginLoader, dynamische Sidebar/Settings. |
|
||||
| 3.10b | Plugin-Install-System | 8h | Plugins einfach installierbar machen: ZIP-Upload, URL-Install, Plugin-Marketplace-Integration. Plugin-Upload-Endpoint, Validierung (Manifest prüfen, tenant_id-Check, Security-Scan), automatische Migration bei Install. Install-UI in SettingsPlugins.tsx. |
|
||||
|
||||
**Phase 3 Gesamt: ~58h**
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3.5: Automation & Agents Plugin
|
||||
**Ziel:** Zentrale Oberfläche für Automatisierungen und selbst-arbeitende KI-Agenten. Plugins können Agenten und Automation-Templates mitbringen.
|
||||
|
||||
**Architektur:**
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Automation & Agents UI │
|
||||
│ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Automation │ │ Agent Builder │ │
|
||||
│ │ Builder │ │ - Agent definieren │ │
|
||||
│ │ - Trigger │ │ - Tools auswählen │ │
|
||||
│ │ - Schedule │ │ - LLM-Modell wählen │ │
|
||||
│ │ - Conditions │ │ - Heartbeat setzen │ │
|
||||
│ │ - Actions │ │ - Proaktiv/Reaktiv │ │
|
||||
│ └─────────────┘ └─────────────────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Cron-Scheduler │ Workflow-Timeouts │ HB │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Plugins bringen mit: │
|
||||
│ - agent_definitions (Agent-Templates) │
|
||||
│ - automation_templates (Automation-Tpl) │
|
||||
│ - cron_jobs (periodische Tasks) │
|
||||
│ - heartbeat_configs │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 3.11 | Plugin-Manifest um Agent/Automation-Felder erweitern | 4h | Manifest um `agent_definitions`, `automation_templates`, `cron_jobs`, `heartbeat_configs` erweitern. Plugins deklarieren was sie mitbringen. |
|
||||
| 3.12 | Cron-Scheduler Backend | 6h | ARQ-basierter Scheduler für periodische Tasks. Cron-Expressions (z.B. `0 8 * * *` = täglich 8 Uhr). Scheduler liest aktive Cron-Jobs aus DB und enqueued sie. Ersetzt hartkodierten Heartbeat. |
|
||||
| 3.13 | Workflow-Timeout-Worker | 4h | ARQ-Job der regelmäßig Workflow-Instanzen mit abgelaufenem `timeout_at` prüft. Bei Timeout: Status auf `cancelled`, Notification an Initiator. |
|
||||
| 3.14 | Agent Builder Backend | 8h | API für Agent-Definitionen: Name, Beschreibung, LLM-Modell, Tools (aus tool_registry), System-Prompt, Heartbeat-Intervall, Proaktiv/Reaktiv-Modus. Agent-Definitionen in DB gespeichert. |
|
||||
| 3.15 | Automation Builder Backend | 6h | API für Automation-Definitionen: Trigger (Event/Schedule/Manual), Conditions, Actions (API-Call/Notification/Workflow-Start). Automation-Definitionen in DB gespeichert. |
|
||||
| 3.16 | Automation Execution Engine | 6h | Engine die Automations ausführt: Event-Trigger → Conditions prüfen → Actions ausführen. Nutzt Event-Bus für Event-Trigger, Cron-Scheduler für Schedule-Trigger. |
|
||||
| 3.17 | Agent Runner | 8h | Führt Agenten aus: Proaktiv (Heartbeat-getriggert, sammelt Kontext, generiert Vorschläge) oder Reaktiv (auf Event/Message, reagiert). Nutzt LiteLLM + tool_registry + PydanticAI. |
|
||||
| 3.18 | Automation & Agents UI — Automation Builder | 8h | Visueller Builder für Automations: Trigger auswählen, Conditions definieren, Actions zusammenstellen. Drag & Drop oder Form-basiert. Live-Preview. |
|
||||
| 3.19 | Automation & Agents UI — Agent Builder | 8h | Visueller Builder für Agenten: Name, Modell, Tools, System-Prompt, Heartbeat. Test-Run Button. Agent-Liste mit Status (aktiv/inaktiv). |
|
||||
| 3.20 | Automation & Agents UI — Dashboard | 4h | Übersicht: Aktive Automations, Aktive Agenten, Letzte Ausführungen, Logs, Fehler. Heartbeat-Status pro Agent. |
|
||||
| 3.21 | Plugin-Beiträge registrieren | 4h | Wenn Plugin aktiviert wird: Agent-Definitionen, Automation-Templates, Cron-Jobs aus Manifest registrieren. Bei Deaktivierung: entfernen. |
|
||||
| 3.22 | Heartbeat-Verwaltung migrieren | 3h | Hartkodierten Heartbeat aus ai_proactive in Automation & Agents Plugin migrieren. Heartbeat wird zu einem konfigurierbaren Cron-Job. |
|
||||
| 3.23 | Settings für Automation & Agents | 3h | Einstellungen: Default-LLM-Modell für Agenten, Heartbeat-Default-Intervall, Max-Concurrent-Agents, Log-Level. |
|
||||
| 3.24 | Tests für Automation & Agents | 6h | Tests für Cron-Scheduler, Workflow-Timeouts, Agent Runner, Automation Engine, Plugin-Beiträge. |
|
||||
| 3.25 | Agent- & Automation-Logs | 4h | Jede Agent-Ausführung und Automation-Ausführung wird geloggt: Start, Ende, Status, Dauer, Ergebnis, Fehler. Log-Viewer in Dashboard UI. Historie pro Agent/Automation. |
|
||||
| 3.26 | RBAC für Automation & Agents | 3h | Permissions definieren: `automation:read`, `automation:write`, `automation:delete`, `automation:execute`, `agents:read`, `agents:write`, `agents:delete`, `agents:execute`. Nur Admin/Editor dürfen Agenten/Automations erstellen. |
|
||||
| 3.27 | Dry-Run / Test-Modus | 3h | Automations und Agenten können im Dry-Run getestet werden: Führt Conditions aus, zeigt was passieren würde, aber führt keine destruktiven Actions aus. Test-Button in Builder UI. |
|
||||
| 3.28 | Agent Rate-Limiting & Safety | 3h | Max-Ausführungen pro Agent pro Stunde. Max-Dauer pro Ausführung. Auto-Stop bei Endlosschleife (wenn Agent dieselbe Action 5x hintereinander ausführt). Budget-Limit pro Agent (LiteLLM Cost-Tracking). |
|
||||
| 3.29 | Plugin-Beitrags-Konfliktlösung | 2h | Wenn zwei Plugins denselben Agent-Namen/Templat-Namen mitbringen: Plugin-Name als Prefix (`mail.mail_sorter` statt `mail_sorter`). Dedup-Logik bei Registrierung. |
|
||||
| 3.30 | Agent-zu-Agent-Kommunikation | 8h | Agenten können Nachrichten an andere Agenten senden. Nutzt kommunikation Plugin-Infrastruktur (WebSocket, Rooms). Agent-Message-Router: Agent A sendet `{to: 'mail_sorter', message: 'Neuer Termin gefunden'}`. Empfänger-Agent reagiert. Agent-Chatrooms in Dashboard sichtbar. |
|
||||
| 3.31 | Versionshistorie für Agenten & Automations | 4h | Jede Änderung an Agent/Automation erstellt neue Version. Alte Versionen können wiederhergestellt werden. Versions-Diff in UI. `agent_versions` und `automation_versions` Tabellen. |
|
||||
| 3.32 | MiniApps: Plugin-MiniApps im Chat | 6h | **Bereits implementiert:** `MiniAppRegistry`, `MiniAppDef`, Routes (`GET /miniapps`, `POST /conversations/{id}/miniapps`), `MiniAppBlock.tsx` Frontend. **Was fehlt:** Plugin-Manifest um `miniapps` Feld erweitern (Plugins deklarieren welche MiniApps sie mitbringen). MiniApp-Builder UI (visuell MiniApps erstellen). MiniApp-Store in Settings. Dokumentation in Plugin-Entwickler-Richtlinien. |
|
||||
|
||||
**Phase 3.5 Gesamt: ~105h**
|
||||
|
||||
**Was Plugins mitbringen können:**
|
||||
- **Agent-Definitionen:** Ein Plugin kann vordefinierte Agenten mitbringen (z.B. Mail-Plugin bringt "E-Mail-Sortier-Agent" mit)
|
||||
- **Automation-Templates:** Ein Plugin kann Automation-Vorlagen mitbringen (z.B. Calendar-Plugin bringt "Terminerinnerung 24h vorher" mit)
|
||||
- **Cron-Jobs:** Ein Plugin kann periodische Tasks deklarieren (z.B. Mail-Plugin: "IMAP-Sync alle 15 Minuten")
|
||||
- **Heartbeat-Configs:** Ein Plugin kann Heartbeat-Konfigurationen mitbringen
|
||||
|
||||
**Beispiel: Mail-Plugin bringt Agent mit**
|
||||
```json
|
||||
{
|
||||
"agent_definitions": [{
|
||||
"name": "mail_sorter",
|
||||
"display_name": "E-Mail-Sortier-Assistent",
|
||||
"description": "Sortiert eingehende E-Mails automatisch nach Regeln",
|
||||
"model": "ollama/deepseek-v4-flash",
|
||||
"tools": ["mail.read", "mail.move", "mail.label"],
|
||||
"system_prompt": "Du sortierst E-Mails...",
|
||||
"mode": "reactive",
|
||||
"trigger_event": "mail.received"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Beispiel: Calendar-Plugin bringt Automation mit**
|
||||
```json
|
||||
{
|
||||
"automation_templates": [{
|
||||
"name": "appointment_reminder",
|
||||
"display_name": "Terminerinnerung 24h vorher",
|
||||
"trigger": {"type": "schedule", "cron": "0 8 * * *"},
|
||||
"conditions": [{"field": "entry.start_at", "operator": "lt", "value": "now + 24h"}],
|
||||
"actions": [{"type": "notification", "title": "Terminerinnerung", "body": "Morgen: ${entry.title}"}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PHASE 4: KI-UI-Steuerung
|
||||
**Ziel:** KI-Agent kann UI steuern — Kontakte öffnen, Filter setzen, navigieren. User sieht das Ergebnis in der UI.
|
||||
|
||||
**Wichtig:** Bestehende WebSocket-Infrastruktur im `kommunikation` Plugin (`/api/v1/comm/ws`, `websocket_manager.py`) kann als Referenz dienen.
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 4.1 | UI-Command-Protokoll definieren | 4h | JSON-Protokoll für UI-Befehle: `{action: 'navigate', path: '/contacts/123'}`, `{action: 'filter', entity: 'contacts', filter: {type: 'company'}}`, `{action: 'open_contact', id: '...'}`. |
|
||||
| 4.2 | WebSocket-Endpoint für KI-UI-Steuerung | 6h | Backend-WebSocket `/ws/ai-ui-control`. Authentifiziert via Session. KI-Agent sendet Commands, Frontend empfängt. Basiert auf bewährter WebSocket-Infrastruktur aus kommunikation Plugin. |
|
||||
| 4.3 | Frontend `useAIUIControl` Hook | 6h | WebSocket-Client im Frontend. Empfängt Commands und führt sie aus. Nutzt React Router, TanStack Query, Zustand Stores. |
|
||||
| 4.4 | Command: Navigate | 2h | `useNavigate()` für Route-Wechsel. KI kann zu jeder Seite navigieren. |
|
||||
| 4.5 | Command: Filter setzen | 4h | URL-Search-Params setzen für Listen-Filter. KI kann Filter setzen (z.B. "Zeige nur Firmen in Berlin"). |
|
||||
| 4.6 | Command: Contact öffnen | 3h | Navigate zu `/contacts/:id` + Detail-Daten laden. KI kann Kontakt öffnen und User sieht ihn. |
|
||||
| 4.7 | Command: Modal öffnen/schließen | 3h | EditModal, CreateModal etc. per Command steuerbar. |
|
||||
| 4.8 | Command: Tab wechseln | 2h | Detail-Tabs (Dateien, E-Mails, Kalender) per Command wechseln. |
|
||||
| 4.9 | Command: Settings ändern | 3h | System-Settings, User-Preferences per UI-Command ändern. Wird in UI sichtbar. |
|
||||
| 4.10 | UI-Action-Feedback an KI | 4h | Frontend sendet Bestätigung zurück: `{action: 'navigate', status: 'success', current_path: '/contacts/123'}`. KI weiß, dass Command ausgeführt wurde. |
|
||||
| 4.11 | Visuelle KI-Indikation | 3h | Wenn KI eine Aktion ausführt: kurzer Highlight-Effekt oder Toast "KI führt Aktion aus...". User sieht dass KI agiert. |
|
||||
| 4.12 | Tests für KI-UI-Steuerung | 4h | Vitest-Tests für Command-Protokoll, useAIUIControl Hook, Command-Ausführung. |
|
||||
|
||||
**Phase 4 Gesamt: ~44h**
|
||||
|
||||
---
|
||||
|
||||
### PHASE 5: API-Vollständigkeit & KI-Testbarkeit
|
||||
**Ziel:** App komplett per API steuerbar. KI kann selbstständig testen und Updates einspielen.
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 5.1 | API-Audit: Alle UI-Funktionen per API erreichbar | 8h | Systematische Prüfung: Jede UI-Aktion hat einen API-Endpoint. Fehlende Endpoints identifizieren und implementieren. Sidebar-Zustand, Tab-Auswahl, Filter-Zustand per API speichern/laden. |
|
||||
| 5.2 | User-Preferences-API erweitern | 4h | UI-Einstellungen (Sidebar collapsed, theme, language, active tab, sort preferences) per API speichern/laden. |
|
||||
| 5.3 | Workflow-API-Frontend-Modul | 4h | `api/workflows.ts` erstellen. Workflow-Definitions CRUD, Instances, Step-History. |
|
||||
| 5.4 | Playwright E2E-Tests: Setup | 4h | `@playwright/test` installieren. `playwright.config.ts`. Test-Helper für Login, API-Calls. |
|
||||
| 5.5 | Playwright: auth.spec.ts | 3h | Login → Logout E2E-Test. |
|
||||
| 5.6 | Playwright: contact-crud.spec.ts | 4h | Contact erstellen → bearbeiten → Ansprechpartner hinzufügen → löschen. |
|
||||
| 5.7 | Playwright: search.spec.ts | 3h | Globale Suche, Filter, Ergebnisse prüfen. |
|
||||
| 5.8 | Playwright: plugin-toggle.spec.ts | 3h | Plugin aktivieren/deaktivieren, UI-Änderung prüfen. |
|
||||
| 5.9 | Playwright: mail.spec.ts | 4h | Mail-Konto anlegen, Ordner anzeigen, Mail öffnen. |
|
||||
| 5.10 | Playwright: dms.spec.ts | 4h | Ordner erstellen, Datei hochladen, Vorschau, teilen. |
|
||||
| 5.11 | Playwright: calendar.spec.ts | 4h | Termin erstellen, Kalender wechseln, Kanban-View. |
|
||||
| 5.12 | API-Health-Check-Script für KI | 4h | `scripts/ai_health_check.py`: Prüft alle API-Endpunkte, gibt strukturierten Report. KI kann das vor/nach Updates laufen lassen. |
|
||||
| 5.13 | CI/CD-Pipeline für KI-Updates | 6h | `scripts/ai_deploy.py`: KI kann Build erstellen, Tests laufen, bei Erfolg deployen. Rollback bei Fehler. |
|
||||
| 5.14 | API-Dokumentation vervollständigen | 4h | OpenAPI/Swagger prüfen. Alle Endpoints dokumentiert. Beispiele für KI. |
|
||||
| 5.15 | Automatisiertes Backup-System | 8h | `pg_dump` + Storage-Backup als Cron-Job (nutzt Cron-Scheduler aus Phase 3.5). Backup-Konfiguration in Settings (Intervall, Aufbewahrung, Ziel: lokal/S3/Nextcloud). Restore-Script. Backup-Status in Dashboard. Notification bei Backup-Fehler. |
|
||||
| 5.16 | MCP-Server Integration | 10h | LeoCRM als MCP-Server: Externe Tools (Claude Desktop, andere KI-Clients) können auf LeoCRM-Daten zugreifen. MCP-Tools für Contacts, Calendar, Mail, DMS. Authentifiziert via API-Token. MCP-Config-Endpoint `GET /api/v1/mcp/tools`. |
|
||||
| 5.17 | MCP-Client Integration | 6h | LeoCRM-Agenten können externe MCP-Server nutzen (z.B. Web-Search, Code-Execution, externe Datenquellen). MCP-Client in tool_registry integriert. Admin kann MCP-Server in Settings konfigurieren. Agenten nutzen MCP-Tools wie native Tools. |
|
||||
| 5.18 | Report Generator: PDF-Support & Druck-Funktionen | 8h | Backend: WeasyPrint für PDF-Generierung aus Jinja2-Templates. Vorgefertigte Berichte: Kontaktliste, Kalender (Woche/Monat), Firmenliste, Audit-Log. Druck-Optimierte Templates (A4, Landscape). `output_format` um `pdf` und `print` erweitern. |
|
||||
| 5.19 | Report Generator: Frontend-Oberfläche | 10h | `Reports.tsx` Seite: Template-Liste, Template-Editor (Code-Editor für Jinja2), Report-Generierung mit Live-Preview, Download-History. Vorgefertigte Berichte als Buttons ("Kontakt-Liste drucken", "Kalender drucken"). Druck-Dialog mit Format-Auswahl (A4/A5/Landscape). |
|
||||
| 5.20 | Custom Fields: Plugin-Felder in UI | 6h | Plugins sollen Custom Fields mitbringen können. Plugin-Manifest um `custom_fields` Definition erweitern. Frontend: Dynamische Custom-Field-Renderer in Contact-Detail, ContactEditModal. Feld-Typen: text, number, date, select, multiselect, boolean. Felder werden in `contacts.custom` JSONB gespeichert. |
|
||||
| 5.21 | Tasks-Plugin | 12h | Eigenes Tasks-Plugin: Freie Aufgaben/Aktivitäten verwalten (Anruf protokollieren, Notiz, Besuch). Verknüpfung mit Kontakten. Tasks haben Status (open/in_progress/done), Priorität, Fälligkeitsdatum, Zuweisung an Nutzer. Tasks-Liste mit Filter. ARQ-Reminder für fällige Tasks. Plugin-Manifest, Models, Routes, Schemas, Frontend-Seite. |
|
||||
| 5.22 | Saved Searches / Smart Lists | 6h | Jede Listen-Ansicht (Contacts, Mail, Calendar, DMS) bekommt Filter-Funktionalität. Filter können gespeichert werden (Name, Filter-Kriterien). Gespeicherte Filter erscheinen als Tabs oder Sidebar-Einträge. `saved_filters` Tabelle (tenant-scoped, user-scoped). Frontend: Filter-Builder UI, Save-Button, Load-Gespeicherte-Filter. |
|
||||
| 5.23 | Deduplication / Merge (über KI/Automatisierung) | 6h | Contacts-Plugin bietet Dubletten-Erkennung: KI-gestützter Vergleich von Kontakten (Name, E-Mail, Telefon). Automation-Template: "Dubletten finden und zusammenführen". Merge-UI: Zwei Kontakte vergleichen, Felder auswählen, zusammenführen. `contact_merge_history` Tabelle. |
|
||||
| 5.24 | PWA (Progressive Web App) | 6h | Frontend als PWA planen: `manifest.json`, Service Worker, Offline-Caching für statische Assets, Add-to-Home-Screen, App-Icon. Vite PWA Plugin installieren. Push-Notifications vorbereiten (Notification API). |
|
||||
| 5.25 | Dashboard-System ausbauen | 8h | Plugins bringen Dashboard-Komponenten mit und melden diese an. Plugin-Manifest um `dashboard_widgets` erweitern (bereits in Architektur definiert aber nicht implementiert). Dashboard lädt Widgets dynamisch aus Plugin-Registry. Widget-Typen: Stat-Cards, Charts, Recent-Activity, Quick-Actions. Frontend: Dashboard-Grid mit drag-and-drop Widget-Positionierung. |
|
||||
|
||||
**Phase 5 Gesamt: ~145h**
|
||||
|
||||
---
|
||||
|
||||
### PHASE 6: React Hook Form + Zod überall
|
||||
**Ziel:** Konsistente Form-Validierung in allen Formularen
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 6.1 | ComposeModal (Mail) auf RHF + Zod | 4h | E-Mail-Validierung, Pflichtfelder, CC/BCC. |
|
||||
| 6.2 | AppointmentModal (Calendar) auf RHF + Zod | 4h | Datum-Validierung, Pflichtfelder, Recurrence. |
|
||||
| 6.3 | SettingsForms auf RHF + Zod | 6h | SettingsUsers, SettingsRoles, SettingsGroups, SettingsCurrencies, SettingsTaxes, SettingsSequences, SettingsSystem. |
|
||||
| 6.4 | DMS-Forms (Folder create, Share) auf RHF + Zod | 3h | |
|
||||
| 6.5 | Tag-Forms auf RHF + Zod | 2h | |
|
||||
| 6.6 | Mail-Settings-Forms auf RHF + Zod | 4h | Account-Erstellung, Rules, Signatures, Templates. |
|
||||
|
||||
**Phase 6 Gesamt: ~23h**
|
||||
|
||||
---
|
||||
|
||||
### PHASE 7: Test-Vollendung & Wartbarkeit
|
||||
**Ziel:** Vollständige Test-Abdeckung für KI-Wartbarkeit
|
||||
|
||||
| # | Aufgabe | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| 7.1 | Tests für ungetestete Settings-Pages | 6h | SettingsGroups, SettingsSystem, SettingsCurrencies, SettingsTaxes, SettingsSequences, SettingsNotifications, SettingsPlugins. |
|
||||
| 7.2 | Tests für AI-Komponenten | 4h | ChatWindow, SessionList, SuggestionSidebar, AISettings, ProactiveAISettings. |
|
||||
| 7.3 | Tests für Calendar-Page | 3h | Calendar.tsx (717 Zeilen), CalendarKanban.tsx. |
|
||||
| 7.4 | Tests für DMS-Sub-Komponenten | 4h | FileExplorer, SourceTree, FileGrid, FileDetails, BulkActions. |
|
||||
| 7.5 | Tests für Contact-Sub-Komponenten | 3h | ContactDetail, ContactEditModal, ContactFolderTree. |
|
||||
| 7.6 | Tests für Comm-Blocks | 3h | BlockRenderer und alle Block-Typen. |
|
||||
| 7.7 | Tests für Stores | 2h | authStore, uiStore, commStore, pluginToolbarStore, calendarStore. |
|
||||
| 7.8 | Backend-Test-Lücken schließen | 8h | Tests für fehlende Plugin-Routes, Edge-Cases, Multi-Tenant-Szenarien. |
|
||||
| 7.9 | Test-Runner-Script für KI | 3h | `scripts/ai_run_tests.py`: Führt alle Tests aus (Backend + Frontend + E2E), gibt strukturierten Report. |
|
||||
|
||||
**Phase 7 Gesamt: ~36h**
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung: Aufwandsschätzung (korrigiert)
|
||||
|
||||
| Phase | Thema | Aufwand | Vorher | Änderung |
|
||||
|---|---|---|---|---|
|
||||
| 0 | Vorbereitung & Cleanup | ~77h | ~14h | **+63h** (Design, Theme, RBAC, LiteLLM, Search-RBAC, Undo, Storage, Import/Export, Config-Cleanup, Mail-Salt, PyMuPDF→pypdf, OnlyOffice→Collabora) |
|
||||
| 1 | Unified Contact (Backend+Frontend) | **~81h** | ~33h | **+48h** — Company-Referenzen in 6 Plugins + Permission-Registry + Addresses + conftest unterschätzt |
|
||||
| 2 | Code-Splitting & Performance | ~25h | ~25h | — |
|
||||
| 3 | Plugin-UI-System | ~58h | ~48h | +10h (Plugin-Install-System) |
|
||||
| 3.5 | Automation & Agents Plugin | ~105h | — | **NEU** — Agent Builder, Automation, Cron, Logs, Safety, Agent-zu-Agent, Versionshistorie, MiniApps |
|
||||
| 4 | KI-UI-Steuerung | ~44h | ~44h | — |
|
||||
| 5 | API, Testbarkeit, Backup, MCP, Reports, Custom Fields, Tasks, Saved Searches, Dedup, PWA, Dashboard | ~145h | ~57h | +88h |
|
||||
| 6 | React Hook Form + Zod | ~23h | ~23h | — |
|
||||
| 7 | Test-Vollendung | ~36h | ~36h | — |
|
||||
| | **GESAMT** | **~590h** | ~280h | **+310h** |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
```
|
||||
Phase 0 (Vorbereitung & Cleanup)
|
||||
↓
|
||||
Phase 1 (Unified Contact — Backend+Frontend) ← Core-CRM-Feature, größte Phase
|
||||
↓
|
||||
Phase 2 (Code-Splitting & Performance)
|
||||
↓
|
||||
Phase 3 (Plugin-UI-System) ← WordPress-Style, nicht zu lange schieben
|
||||
↓
|
||||
Phase 3.5 (Automation & Agents Plugin) ← Agent Builder, Cron-Scheduler, Automation
|
||||
↓
|
||||
Phase 4 (KI-UI-Steuerung) ← Baut auf Plugin-System auf
|
||||
↓
|
||||
Phase 5 (API-Vollständigkeit & Testbarkeit) ← KI kann selbstständig testen
|
||||
↓
|
||||
Phase 6 (React Hook Form + Zod) ← Qualität
|
||||
↓
|
||||
Phase 7 (Test-Vollendung) ← Wartbarkeit für KI
|
||||
```
|
||||
|
||||
**Begründung der Reihenfolge:**
|
||||
1. Phase 0 zuerst: Dependencies und Cleanup als Fundament
|
||||
2. Phase 1 als Nächstes: Core-CRM-Feature (Contacts) muss vollständig sein. Größte Phase (~74h) weil 'company' überall im Code verankert ist.
|
||||
3. Phase 2: Code-Splitting ist schnell und bringt sofortige Performance-Verbesserung
|
||||
4. Phase 3: Plugin-UI-System — je früher desto besser, sonst wird Umbau später schwieriger
|
||||
5. Phase 4: KI-UI-Steuerung baut auf Plugin-System auf (dynamische Routes, Tabs etc.). Bestehende WebSocket-Infrastruktur aus kommunikation Plugin als Referenz.
|
||||
6. Phase 5: API-Vollständigkeit und E2E-Tests für KI-Wartbarkeit
|
||||
7. Phase 6+7: Qualität und Test-Vollendung
|
||||
|
||||
---
|
||||
|
||||
## Was bei der Überprüfung gefunden wurde
|
||||
|
||||
### Phase 1 Korrektur: +41h Aufwand
|
||||
|
||||
Die ursprüngliche Schätzung von 33h für Phase 1 war **massiv unterschätzt**. Die gründliche Code-Analyse zeigte:
|
||||
|
||||
**'company' als entity_type ist in 6 Plugins verankert:**
|
||||
- `entity_links`: entity_type Pattern, company_router, on_company_deleted Event-Handler
|
||||
- `unified_search`: CompanySearchProvider, index_company, company.created/updated Events, search_engine Mapping
|
||||
- `calendar`: entity_type Pattern für EntryLinks
|
||||
- `tags`: entity_type Pattern für Tag-Assignments
|
||||
- `mail`: company_id Spalte in mails Tabelle (DB-Migration nötig!)
|
||||
- `ai/action_mapper`: Company-Intents (create/delete/update/list)
|
||||
|
||||
**Event-Namen müssen migriert werden:**
|
||||
- `company.created` → `contact.created`
|
||||
- `company.updated` → `contact.updated`
|
||||
- `company.deleted` → `contact.deleted`
|
||||
- Betroffen: unified_search, entity_links, workflows, test_sample, manifest.py
|
||||
|
||||
**DB-Migration nötig:**
|
||||
- `entity_links.entity_type = 'company'` → `'contact'`
|
||||
- `tag_assignments.entity_type = 'company'` → `'contact'`
|
||||
- `calendar_entry_links.entity_type = 'company'` → `'contact'`
|
||||
- `mails.company_id` → `mails.contact_id` (Spalte umbenennen)
|
||||
|
||||
**Was NICHT geändert wird:**
|
||||
- `system_settings.company_name`, `company_street` etc. → Das ist die CRM-Besitzer-Firmeninfo für Rechnungen. Bleibt wie es ist.
|
||||
- `CalendarType = 'company'` → Das ist ein Kalender-Typ (Firmenkalender), keine Entity-Referenz. Kann bleiben.
|
||||
|
||||
### Bestehende WebSocket-Infrastruktur
|
||||
Das `kommunikation` Plugin hat bereits eine vollständige WebSocket-Implementierung (`/api/v1/comm/ws`, `websocket_manager.py`). Diese kann als Referenz für die KI-UI-Steuerung (Phase 4) dienen — das spart Entwicklungszeit.
|
||||
|
||||
---
|
||||
|
||||
## KI-Wartbarkeit: Schlüssel-Anforderungen
|
||||
|
||||
Damit ein KI-Agent die App selbstständig warten kann:
|
||||
|
||||
1. **Vollständige API-Abdeckung:** Jede UI-Funktion per API steuerbar (Phase 5)
|
||||
2. **E2E-Tests:** Playwright-Tests die KI ausführen kann (Phase 5)
|
||||
3. **API-Health-Check:** Script das alle Endpunkte prüft (Phase 5)
|
||||
4. **Test-Runner:** Script das alle Tests ausführt und strukturiert reportet (Phase 7)
|
||||
5. **Deploy-Script:** KI kann Build erstellen, testen, deployen, rollback (Phase 5)
|
||||
6. **Plugin-Richtlinien:** Klare Vorgaben damit KI neue Plugins erstellen kann (Phase 3)
|
||||
7. **Dokumentation:** Aktuelle Architektur-Doku, API-Doku, Plugin-Guide (Phase 0+3+5)
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Nextcloud Backup erstellt (`/Backups/leocrm/leocrm-backup-20260722.bundle`)
|
||||
2. ✅ Plan gründlich überprüft und korrigiert (+45h)
|
||||
3. ⬜ Plan freigeben
|
||||
4. ⬜ Phase 0 starten
|
||||
5. ⬜ Planungsdokumente aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## Test-Strategie (pro Phase)
|
||||
|
||||
### Phase 0: Vorbereitung & Cleanup
|
||||
- **Pro Task:** Unit-Test für geänderte Funktionalität (z.B. Test dass lucide-react Icons rendern, Test dass date-fns formatiert, Test dass Storage Backend local+S3 funktioniert)
|
||||
- **Regression:** Alle bestehenden Tests müssen weiterhin durchlaufen
|
||||
- **Lizenz-Test:** `pip-licenses` Script prüft dass keine AGPL-Packages mehr in requirements.txt
|
||||
|
||||
### Phase 1: Unified Contact Model
|
||||
- **Pro Task:** API-Integration-Test (httpx + pytest) für jeden geänderten Endpoint
|
||||
- **DB-Migration-Test:** Test dass Migration 0023 (entity_type company→contact) korrekt ausführt und rollbackbar ist
|
||||
- **Plugin-Test:** Pro Plugin (entity_links, unified_search, calendar, tags, mail) Test dass entity_type='contact' funktioniert
|
||||
- **Frontend-Test:** Vitest für ContactDetail, ContactEditModal, ContactPerson-Verwaltung
|
||||
- **Cross-Tenant-Test:** Test dass Tenant-Isolation nach Migration noch funktioniert
|
||||
|
||||
### Phase 2: Code-Splitting & Performance
|
||||
- **Bundle-Test:** Test dass Initial-Bundle < 300KB (vorher alle Pages im Bundle)
|
||||
- **Virtual Scrolling Test:** Test mit 10.000 Datensätzen — Rendering-Zeit < 500ms
|
||||
- **Lazy-Loading Test:** Test dass Plugin-Pages nicht im Initial-Bundle sind
|
||||
|
||||
### Phase 3: Plugin-UI-System
|
||||
- **PluginRegistry-Test:** Test dass Manifests korrekt geladen und gerendert werden
|
||||
- **PluginLoader-Test:** Test dass lazy-loaded Komponenten mit Suspense funktionieren
|
||||
- **Plugin-Install-Test:** Test dass ZIP-Upload validiert und installiert wird
|
||||
- **Error-Boundary-Test:** Test dass fehlerhaftes Plugin nicht die ganze App crashen lässt
|
||||
|
||||
### Phase 3.5: Automation & Agents
|
||||
- **Cron-Scheduler-Test:** Test dass Cron-Jobs zur richtigen Zeit enqueued werden
|
||||
- **Workflow-Timeout-Test:** Test dass abgelaufene Workflows cancelled werden
|
||||
- **Agent-Runner-Test:** Test dass Agent LLM-Call ausführt und Ergebnis zurückgibt (Mock-LLM)
|
||||
- **Automation-Engine-Test:** Test dass Event-Trigger → Conditions → Actions korrekt ausgeführt werden
|
||||
- **Agent-zu-Agent-Test:** Test dass Agent A Nachricht an Agent B sendet und B reagiert
|
||||
- **Rate-Limiting-Test:** Test dass Agent nach Max-Ausführungen gestoppt wird
|
||||
- **Dry-Run-Test:** Test dass Dry-Run keine destruktiven Actions ausführt
|
||||
|
||||
### Phase 4: KI-UI-Steuerung
|
||||
- **WebSocket-Test:** Test dass Commands korrekt gesendet und empfangen werden
|
||||
- **Command-Test:** Pro Command-Typ (navigate, filter, open_contact, modal, tab, settings) ein Test
|
||||
- **Feedback-Test:** Test dass Frontend Bestätigung an KI zurücksendet
|
||||
|
||||
### Phase 5: API-Vollständigkeit & Features
|
||||
- **E2E-Tests (Playwright):** auth, contact-crud, search, plugin-toggle, mail, dms, calendar (7 Specs)
|
||||
- **API-Health-Check-Test:** Test dass alle Endpoints erreichbar und korrekt responden
|
||||
- **Backup-Test:** Test dass Backup erstellt wird und Restore funktioniert
|
||||
- **MCP-Test:** Test dass MCP-Server Tools bereitstellt und MCP-Client Tools nutzt
|
||||
- **Report-Test:** Test dass PDF/CSV/Excel generiert wird und korrekt formatiert ist
|
||||
- **Custom-Fields-Test:** Test dass Plugin-Felder in UI gerendert und gespeichert werden
|
||||
- **Tasks-Plugin-Test:** Vollständige CRUD-Tests für Tasks
|
||||
- **Saved-Searches-Test:** Test dass Filter gespeichert und geladen werden
|
||||
- **Dedup-Test:** Test dass Dubletten erkannt und gemerged werden
|
||||
- **PWA-Test:** Test dass Service Worker registriert wird und Offline-Caching funktioniert
|
||||
- **Dashboard-Test:** Test dass Plugin-Widgets dynamisch gerendert werden
|
||||
|
||||
### Phase 6: React Hook Form + Zod
|
||||
- **Pro Form:** Test dass Validierung korrekt funktioniert (Pflichtfelder, E-Mail-Format, Datum-Range)
|
||||
- **Error-Display-Test:** Test dass Fehlermeldungen korrekt angezeigt werden
|
||||
|
||||
### Phase 7: Test-Vollendung
|
||||
- **Coverage-Target:** >80% Backend, >70% Frontend
|
||||
- **Test-Runner-Script:** `scripts/ai_run_tests.py` führt alle Tests aus und gibt strukturierten Report
|
||||
- **Multi-Tenant-Test:** Test mit 3 Tenants — Isolation, Cross-Tenant-Access → 404
|
||||
- **Performance-Test:** 200k Contacts — List < 500ms, FTS < 500ms
|
||||
|
||||
### Test-Infrastruktur
|
||||
- **Backend:** pytest + httpx + pytest-asyncio + pytest-cov (bereits vorhanden)
|
||||
- **Frontend:** Vitest + @testing-library/react (bereits vorhanden)
|
||||
- **E2E:** Playwright (neu in Phase 5)
|
||||
- **Test-DB:** PostgreSQL mit `pytest-asyncio` fixture (bereits in conftest.py)
|
||||
- **Test-Redis:** Redis-Mock oder echte Redis-Instanz
|
||||
- **Mock-LLM:** LiteLLM mock mode für AI-Tests (bereits vorhanden)
|
||||
|
||||
---
|
||||
|
||||
## Agent-Anleitung: Wie ein KI-Agent diesen Plan umsetzt
|
||||
|
||||
Dieser Plan ist so strukturiert dass ein KI-Agent (wie Agent Zero) ihn Task-für-Task umsetzen kann.
|
||||
|
||||
### Vorgehensweise pro Task
|
||||
|
||||
1. **Task lesen:** Jeder Task hat Nummer, Aufwand, Beschreibung und Details
|
||||
2. **Code prüfen:** Vor der Umsetzung den aktuellen Code inspizieren (Dateien lesen, Abhängigkeiten prüfen)
|
||||
3. **Minimal-invasiv arbeiten:** Nur das ändern was der Task verlangt. Keine Refactoring-Touren.
|
||||
4. **Tests schreiben/aktualisieren:** Pro Task mindestens ein Test der die Änderung abdeckt
|
||||
5. **Commit:** Pro Task ein Git-Commit mit klarer Message (z.B. `Phase 0.2: install lucide-react and migrate icons`)
|
||||
6. **Verifizieren:** Nach jedem Task: Tests laufen, Build funktioniert, keine Regressionen
|
||||
|
||||
### Phasen-Reihenfolge ist verbindlich
|
||||
|
||||
- Phase N+1 darf erst starten wenn Phase N abgeschlossen ist
|
||||
- Innerhalb einer Phase können Tasks parallel sein (z.B. 0.2 und 0.3 unabhängig)
|
||||
- Abhängigkeiten sind in den Task-Beschreibungen genannt
|
||||
|
||||
### Was ein Agent pro Task braucht
|
||||
|
||||
- Dateipfade der zu ändernden Dateien (in Task-Beschreibung genannt)
|
||||
- Akzeptanzkriterien (in Task-Beschreibung genannt)
|
||||
- Test-Strategie (pro Task mindestens ein Test)
|
||||
- Git-Commit pro Task
|
||||
|
||||
### Plugin-Entwicklung
|
||||
|
||||
Wenn ein Agent ein neues Plugin erstellt (z.B. Tasks-Plugin 5.21):
|
||||
1. Plugin-Verzeichnis in `app/plugins/builtins/<name>/` erstellen
|
||||
2. `plugin.py` mit Manifest (Name, Version, Dependencies, Routes, Permissions, Events)
|
||||
3. `models.py` mit SQLAlchemy Models (TenantMixin!)
|
||||
4. `schemas.py` mit Pydantic Schemas
|
||||
5. `routes.py` mit FastAPI Router (require_permission!)
|
||||
6. `services.py` mit Business-Logic
|
||||
7. Migration in `migrations/` Verzeichnis
|
||||
8. Frontend-Komponenten in `frontend/src/components/<name>/`
|
||||
9. Frontend-Seite in `frontend/src/pages/<Name>.tsx`
|
||||
10. API-Modul in `frontend/src/api/<name>.ts`
|
||||
11. Route in `frontend/src/routes/index.tsx` registrieren
|
||||
12. i18n-Keys in `frontend/src/i18n/locales/de.json` und `en.json`
|
||||
13. Tests in `tests/test_<name>.py` und `frontend/src/__tests__/<name>/`
|
||||
|
||||
### Plugin-Manifest-Format (für neue Plugins)
|
||||
|
||||
```python
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
version="1.0.0",
|
||||
display_name="My Plugin",
|
||||
description="What it does",
|
||||
dependencies=["permissions"], # other plugins this depends on
|
||||
routes=[PluginRouteDef(path="/api/v1/my-plugin", module="...", router_attr="router")],
|
||||
events=["my.event"], # events this plugin listens to
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["my_plugin:read", "my_plugin:write"],
|
||||
is_core=False,
|
||||
# Neue Felder (nach Phase 3+3.5):
|
||||
# agent_definitions=[...], # Agent-Templates
|
||||
# automation_templates=[...], # Automation-Vorlagen
|
||||
# cron_jobs=[...], # Periodische Tasks
|
||||
# custom_fields=[...], # Custom Field Definitionen
|
||||
# dashboard_widgets=[...], # Dashboard-Komponenten
|
||||
# miniapps=[...], # MiniApp-Definitionen
|
||||
)
|
||||
```
|
||||
|
||||
### Wichtige Regeln für Agent-Updates
|
||||
|
||||
1. **Niemals Tests ändern** um sie grün zu bekommen — Code fixen nicht Tests anpassen
|
||||
2. **Niemals .env committen** — Secrets gehören nicht ins Repo
|
||||
3. **Jede DB-Änderung braucht Alembic-Migration** — keine manuellen SQL-Changes
|
||||
4. **Jede API-Route braucht RBAC** — `require_permission()` auf jedem Endpoint
|
||||
5. **Jedes Plugin-Model braucht TenantMixin** — tenant_id auf jeder Tabelle
|
||||
6. **Frontend-Änderungen brauchen i18n** — alle Texte in de.json und en.json
|
||||
7. **Pro Task ein Commit** — nicht mehrere Tasks in einem Commit
|
||||
8. **Nach jedem Task: Tests + Build verifizieren** — keine Regressionen
|
||||
9. **Nach jedem Task: Progress aktualisieren** — `PROGRESS.md` im Repo aktualisieren mit: Task-Nummer, Status (done/in-progress/blocked), Datum, was gemacht wurde, was als Nächstes ansteht. **Zwingend für jeden Agenten der am Plan arbeitet.**
|
||||
+1233
File diff suppressed because it is too large
Load Diff
@@ -1,921 +0,0 @@
|
||||
# LeoCRM Plugin-System — Kompletter Umbauplan
|
||||
|
||||
**Erstellt:** 2026-07-26
|
||||
**Aktualisiert:** 2026-07-26 (Codebasis-Verifikation + Phase 6)
|
||||
**Geschätzter Gesamtaufwand:** ~149 Stunden (~19 Arbeitstage)
|
||||
**Status:** Geplant — noch nicht gestartet
|
||||
|
||||
**Codebasis-Verifikation (2026-07-26):**
|
||||
- ✅ `base.py` unverändert — Plan passt
|
||||
- ✅ `registry.py` unverändert — Plan passt
|
||||
- ✅ `manifest.py` unverändert — Plan passt
|
||||
- ✅ `contracts.py` (ContractRegistry) unverändert — Plan passt
|
||||
- ✅ Migration 0044 hinzugekommen: RLS Repair + separater DB-User (crm_runtime) — beeinflusst Plugin-System nicht
|
||||
- ✅ Migration 0045 hinzugekommen — neuer Head
|
||||
- ✅ `require_active_plugin` in `deps.py` hinzugekommen — beeinflusst Plugin-System nicht
|
||||
- ✅ 19 echte Plugins (test_sample hat __init__.py statt plugin.py)
|
||||
- ✅ Cross-Imports: 224, Contracts: 8, get_contract: 11 — unverändert
|
||||
|
||||
---
|
||||
|
||||
## Übersicht: 5 Phasen
|
||||
|
||||
| Phase | Punkte | Inhalt | Stunden | Tage |
|
||||
|---|---|---|---|---|
|
||||
| Phase 1 | 1-3 | Contracts konsequent nutzen | 47 | 6 |
|
||||
| Phase 2 | 4 | Hooks/Filters-System | 16 | 2 |
|
||||
| Phase 3 | 5 | Plugin-Isolation (Linting) | 4 | 0,5 |
|
||||
| Phase 4 | 8 | Plugin-Versioning | 20 | 2,5 |
|
||||
| Phase 5 | 6 | Marketplace-Vorbereitung | 42 | 5 |
|
||||
| Phase 6 | — | Manifest-Anpassung & Konsolidierung | 20 | 2,5 |
|
||||
| **Gesamt** | | | **149** | **~19** |
|
||||
|
||||
**Wichtig:** Jede Phase ist unabhängig funktionsfähig. Das System läuft nach jeder Phase ohne Einschränkungen weiter.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Contracts konsequent nutzen (Punkte 1-3)
|
||||
|
||||
**Ziel:** Alle 224 direkten Cross-Plugin-Imports werden durch das Contract-System ersetzt.
|
||||
|
||||
### 1.1 Fehlende contracts.py erstellen (7 Std)
|
||||
|
||||
Für jedes Plugin, das noch keine `contracts.py` hat, eine erstellen:
|
||||
|
||||
| # | Plugin | Exportierte Symbole | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | `ai_proactive` | ContextTools, ProactiveAgent, JobScheduler | 30 Min |
|
||||
| 2 | `ai_ui_control` | WebSocketManager, UIAction | 30 Min |
|
||||
| 3 | `automation` | AgentRunner, ExecutionEngine, Scheduler, WorkflowTimeout | 45 Min |
|
||||
| 4 | `entity_links` | EntityLink model, create_link, get_links | 20 Min |
|
||||
| 5 | `forgejo_error_reporter` | report_error_to_forgejo | 15 Min |
|
||||
| 6 | `mcp_client` | McpClient, McpServerConfig | 30 Min |
|
||||
| 7 | `mcp_server` | McpServer, ToolDefinitions | 30 Min |
|
||||
| 8 | `report_generator` | ReportTemplate, ReportInstance, PdfGenerator | 30 Min |
|
||||
| 9 | `system_notif` | SystemNotifHandler | 15 Min |
|
||||
| 10 | `tags` | Tag, TagAssignment, assign_tags, remove_tags | 20 Min |
|
||||
| 11 | `tasks` | Task, TaskService, create_task, update_task | 30 Min |
|
||||
| 12 | `test_sample` | TestSamplePlugin | 10 Min |
|
||||
| 13 | `dms` (erweitern) | File, Folder, UploadService, DownloadService | 30 Min |
|
||||
| 14 | `permissions` (erweitern) | ShareLink, PermissionResolver | 30 Min |
|
||||
|
||||
**Schema für jede contracts.py:**
|
||||
```python
|
||||
"""Public contract for the <plugin> plugin."""
|
||||
from __future__ import annotations
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
# Import only public symbols from internal modules
|
||||
|
||||
class <Plugin>Contract:
|
||||
contract_name = "<plugin>"
|
||||
# Expose only public API
|
||||
|
||||
_contract = <Plugin>Contract()
|
||||
get_contract_registry().register("<plugin>", _contract)
|
||||
```
|
||||
|
||||
### 1.2 Direkte Imports ersetzen (28 Std)
|
||||
|
||||
224 direkte Imports müssen durch `get_contract()` ersetzt werden.
|
||||
|
||||
**Top-Priorität (häufigste Import-Quellen):**
|
||||
|
||||
| # | Datei | Imports | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | `automation/plugin.py` | 10 | 1,5 Std |
|
||||
| 2 | `automation/routes.py` | 8 | 1,5 Std |
|
||||
| 3 | `ai_proactive/services.py` | 8 | 1,5 Std |
|
||||
| 4 | `ai_proactive/plugin.py` | 8 | 1,5 Std |
|
||||
| 5 | `unified_search/jobs.py` | 7 | 1 Std |
|
||||
| 6 | `builtins/__init__.py` | 7 | 1 Std |
|
||||
| 7 | `ai_proactive/jobs.py` | 7 | 1 Std |
|
||||
| 8 | `ai_assistant/participant_handler.py` | 7 | 1 Std |
|
||||
| 9 | `kommunikation/routes.py` | 6 | 1 Std |
|
||||
| 10 | `kommunikation/contracts.py` | 6 | 1 Std |
|
||||
| 11 | `automation/agent_routes.py` | 6 | 1 Std |
|
||||
| 12 | `automation/agent_comm.py` | 6 | 1 Std |
|
||||
| 13 | `ai_proactive/participant_handler.py` | 6 | 1 Std |
|
||||
| 14 | `ai_assistant/plugin.py` | 6 | 1 Std |
|
||||
| 15 | `unified_search/routes.py` | 5 | 45 Min |
|
||||
| 16-50 | Alle übrigen Dateien | ~122 | 12 Std |
|
||||
|
||||
**Muster für Ersetzung:**
|
||||
```python
|
||||
# VORHER (direkt):
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
|
||||
# NACHHER (über Contract):
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
async def my_function(db, ...):
|
||||
komm = get_contract("kommunikation")
|
||||
if komm:
|
||||
await komm.send_message(db, ...)
|
||||
# Graceful degradation wenn Plugin nicht aktiv
|
||||
```
|
||||
|
||||
### 1.3 Contracts bei Deaktivierung abmelden (4 Std)
|
||||
|
||||
In jedem Plugin's `on_deactivate()`:
|
||||
```python
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
# Contract abmelden
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
get_contract_registry().unregister(self.manifest.name)
|
||||
# ... rest of cleanup
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
```
|
||||
|
||||
| # | Plugin | Aufwand |
|
||||
|---|---|---|
|
||||
| 1-16 | Alle 16 Plugins | 15 Min pro Plugin = 4 Std |
|
||||
|
||||
### 1.4 Tests anpassen (8 Std)
|
||||
|
||||
- Cross-Plugin-Tests müssen mit Contracts laufen
|
||||
- `test_plugins.py` — Contract-Registry Tests
|
||||
- `test_contracts.py` — Neue Test-Datei für Contract-System
|
||||
- Alle Integrationstests mit Contract-Mocks
|
||||
|
||||
### Meilenstein Phase 1:
|
||||
- ✅ Alle 16 Plugins haben contracts.py
|
||||
- ✅ 0 direkte Cross-Plugin-Imports (geprüft mit grep)
|
||||
- ✅ Contracts werden bei Deaktivierung abgemeldet
|
||||
- ✅ Alle Tests bestanden
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hooks/Filters-System (Punkt 4)
|
||||
|
||||
**Ziel:** WordPress-Style Hooks (actions + filters) für Plugin-Erweiterbarkeit.
|
||||
|
||||
### 2.1 HookRegistry erstellen (4 Std)
|
||||
|
||||
**Neue Datei: `app/core/hooks.py`**
|
||||
|
||||
```python
|
||||
"""WordPress-style hooks: actions (fire-and-forget) and filters (modify data)."""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
"""Central registry for actions and filters.
|
||||
|
||||
Actions: do_action('contact.before_create', data) — no return value
|
||||
Filters: result = apply_filters('contact.format_name', name) — returns modified value
|
||||
|
||||
Priority: lower numbers run first (default=10).
|
||||
"""
|
||||
|
||||
_instance: HookRegistry | None = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._actions: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
||||
cls._instance._filters: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
||||
return cls._instance
|
||||
|
||||
def register_action(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
||||
self._actions[hook_name].append((priority, callback))
|
||||
self._actions[hook_name].sort(key=lambda x: x[0])
|
||||
|
||||
def register_filter(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
||||
self._filters[hook_name].append((priority, callback))
|
||||
self._filters[hook_name].sort(key=lambda x: x[0])
|
||||
|
||||
async def do_action(self, hook_name: str, *args, **kwargs) -> None:
|
||||
for _, callback in self._actions.get(hook_name, []):
|
||||
try:
|
||||
result = callback(*args, **kwargs)
|
||||
if hasattr(result, '__await__'):
|
||||
await result
|
||||
except Exception:
|
||||
logger.exception("Error in action %s", hook_name)
|
||||
|
||||
async def apply_filters(self, hook_name: str, value: Any, *args, **kwargs) -> Any:
|
||||
for _, callback in self._filters.get(hook_name, []):
|
||||
try:
|
||||
result = callback(value, *args, **kwargs)
|
||||
if hasattr(result, '__await__'):
|
||||
result = await result
|
||||
value = result
|
||||
except Exception:
|
||||
logger.exception("Error in filter %s", hook_name)
|
||||
return value
|
||||
|
||||
def unregister(self, hook_name: str, callback: Callable) -> None:
|
||||
self._actions[hook_name] = [(p, c) for p, c in self._actions.get(hook_name, []) if c != callback]
|
||||
self._filters[hook_name] = [(p, c) for p, c in self._filters.get(hook_name, []) if c != callback]
|
||||
|
||||
def unregister_all(self, hook_name: str) -> None:
|
||||
self._actions.pop(hook_name, None)
|
||||
self._filters.pop(hook_name, None)
|
||||
|
||||
def _reset_for_testing(self) -> None:
|
||||
self._actions.clear()
|
||||
self._filters.clear()
|
||||
|
||||
|
||||
def get_hook_registry() -> HookRegistry:
|
||||
return HookRegistry()
|
||||
|
||||
async def do_action(hook_name: str, *args, **kwargs) -> None:
|
||||
await get_hook_registry().do_action(hook_name, *args, **kwargs)
|
||||
|
||||
async def apply_filters(hook_name: str, value: Any, *args, **kwargs) -> Any:
|
||||
return await get_hook_registry().apply_filters(hook_name, value, *args, **kwargs)
|
||||
```
|
||||
|
||||
### 2.2 Integration in BasePlugin (2 Std)
|
||||
|
||||
```python
|
||||
# In BasePlugin.on_activate:
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
# ... existing code ...
|
||||
# Hooks werden in Subklassen registriert
|
||||
|
||||
# In BasePlugin.on_deactivate:
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
# Alle Hooks dieses Plugins abmelden
|
||||
from app.core.hooks import get_hook_registry
|
||||
# Plugin-spezifische Hooks entfernen (prefix mit plugin name)
|
||||
# ... existing code ...
|
||||
```
|
||||
|
||||
### 2.3 Hook-Punkte in Core-Services (6 Std)
|
||||
|
||||
| # | Service | Hook-Name | Typ | Beschreibung |
|
||||
|---|---|---|---|---|
|
||||
| 1 | contact_service | `contact.before_create` | Action | Vor Kontakt-Erstellung |
|
||||
| 2 | contact_service | `contact.after_create` | Action | Nach Kontakt-Erstellung |
|
||||
| 3 | contact_service | `contact.format_display_name` | Filter | Anzeigenamen formatieren |
|
||||
| 4 | contact_service | `contact.before_update` | Action | Vor Kontakt-Update |
|
||||
| 5 | contact_service | `contact.after_update` | Action | Nach Kontakt-Update |
|
||||
| 6 | contact_service | `contact.before_delete` | Action | Vor Kontakt-Löschung |
|
||||
| 7 | mail_service | `mail.before_send` | Filter | E-Mail vor Versand modifizieren |
|
||||
| 8 | mail_service | `mail.after_send` | Action | Nach E-Mail-Versand |
|
||||
| 9 | calendar | `calendar.before_appointment` | Action | Vor Termin-Erstellung |
|
||||
| 10 | calendar | `calendar.after_appointment` | Action | Nach Termin-Erstellung |
|
||||
| 11 | auth_service | `auth.before_login` | Filter | Login-Daten validieren/modifizieren |
|
||||
| 12 | auth_service | `auth.after_login` | Action | Nach erfolgreichem Login |
|
||||
| 13 | user_service | `user.before_create` | Action | Vor User-Erstellung |
|
||||
| 14 | user_service | `user.after_create` | Action | Nach User-Erstellung |
|
||||
| 15 | dms | `dms.before_upload` | Filter | Datei-Upload validieren/modifizieren |
|
||||
|
||||
### 2.4 Tests für Hooks/Filters (4 Std)
|
||||
|
||||
- `test_hooks.py` — HookRegistry Tests
|
||||
- Integrationstests: Plugin registriert Hook, Core-Service löst Hook aus
|
||||
- Filter-Tests: Wert wird korrekt modifiziert
|
||||
- Priority-Tests: Reihenfolge wird eingehalten
|
||||
- Unregister-Tests: Hooks werden bei Deaktivierung entfernt
|
||||
|
||||
### Meilenstein Phase 2:
|
||||
- ✅ `app/core/hooks.py` mit HookRegistry
|
||||
- ✅ 15 Hook-Punkte in Core-Services
|
||||
- ✅ BasePlugin registriert/unregistriert Hooks automatisch
|
||||
- ✅ Tests bestanden
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Plugin-Isolation (Punkt 5)
|
||||
|
||||
**Ziel:** Direkte Cross-Plugin-Imports werden durch Linting verhindert.
|
||||
|
||||
### 3.1 Linting-Regel erstellen (2 Std)
|
||||
|
||||
**Neue Datei: `.ruff/rules/no_cross_plugin_imports.py`**
|
||||
|
||||
```python
|
||||
"""Ruff rule: forbid direct imports from app.plugins.builtins.* (except contracts)."""
|
||||
|
||||
# Erlaubt:
|
||||
# from app.plugins.builtins.contracts import get_contract
|
||||
# from app.plugins.builtins.<name>.contracts import ...
|
||||
#
|
||||
# Verboten:
|
||||
# from app.plugins.builtins.<name>.services import ...
|
||||
# from app.plugins.builtins.<name>.models import ...
|
||||
# from app.plugins.builtins.<name>.routes import ...
|
||||
```
|
||||
|
||||
### 3.2 CI/CD Integration (1 Std)
|
||||
|
||||
- `ruff check` in GitHub Actions / Forgejo CI
|
||||
- Pre-commit Hook für lokale Entwicklung
|
||||
- Fehler bei direkten Cross-Plugin-Imports
|
||||
|
||||
### 3.3 Ausnahmen definieren (1 Std)
|
||||
|
||||
- `conftest.py` — Tests dürfen direkt importieren
|
||||
- `app/plugins/builtins/__init__.py` — Plugin-Discovery
|
||||
- `app/plugins/registry.py` — Registry darf importieren
|
||||
|
||||
### Meilenstein Phase 3:
|
||||
- ✅ Linting-Regel aktiv
|
||||
- ✅ CI/CD prüft bei jedem Commit
|
||||
- ✅ 0 direkte Cross-Plugin-Imports (automatisch erzwungen)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Plugin-Versioning (Punkt 8)
|
||||
|
||||
**Ziel:** Vollständige Versionsverwaltung mit SemVer, Rollback und Kompatibilitäts-Check.
|
||||
|
||||
### 4.1 SemVer-Vergleich (3 Std)
|
||||
|
||||
**Neue Datei: `app/plugins/semver.py`**
|
||||
|
||||
```python
|
||||
"""Semantic version comparison for plugin versions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
@dataclass
|
||||
class SemVer:
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
prerelease: str = ""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version: str) -> "SemVer":
|
||||
match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(.+))?", version)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid semver: {version}")
|
||||
return cls(int(match[1]), int(match[2]), int(match[3]), match[4] or "")
|
||||
|
||||
def __lt__(self, other): ...
|
||||
def __eq__(self, other): ...
|
||||
def __le__(self, other): ...
|
||||
def __gt__(self, other): ...
|
||||
|
||||
def is_breaking_change(self, other: "SemVer") -> bool:
|
||||
return self.major != other.major
|
||||
|
||||
def is_compatible_with(self, min_version: "SemVer") -> bool:
|
||||
return self >= min_version
|
||||
```
|
||||
|
||||
**Änderung in `registry.py`:**
|
||||
```python
|
||||
# VORHER: String-Vergleich
|
||||
if record.version != plugin.manifest.version:
|
||||
|
||||
# NACHHER: SemVer-Vergleich
|
||||
old_ver = SemVer.parse(record.version)
|
||||
new_ver = SemVer.parse(plugin.manifest.version)
|
||||
if old_ver != new_ver:
|
||||
if new_ver < old_ver:
|
||||
# Downgrade — nur mit Rollback-Migration
|
||||
...
|
||||
```
|
||||
|
||||
### 4.2 Rollback-Migrationen (6 Std)
|
||||
|
||||
**Erweiterung des Migration-Systems:**
|
||||
|
||||
```python
|
||||
# MigrationRunner erweitern:
|
||||
async def run_migration_down(self, db, plugin_name, migration_filename):
|
||||
"""Run rollback (down) migration."""
|
||||
# Suche <filename>_down.sql oder parse DOWNGRADE-Block
|
||||
|
||||
async def rollback_to_version(self, db, plugin_name, target_version: str):
|
||||
"""Rollback plugin to a specific version."""
|
||||
# 1. Finde alle Migrationen nach target_version
|
||||
# 2. Führe sie in umgekehrter Reihenfolge aus
|
||||
# 3. Aktualisiere DB-Version
|
||||
```
|
||||
|
||||
**Migration-Datei-Format:**
|
||||
```sql
|
||||
-- 0001_initial.sql
|
||||
-- UP:
|
||||
CREATE TABLE ...;
|
||||
-- DOWN:
|
||||
DROP TABLE ... CASCADE;
|
||||
```
|
||||
|
||||
Oder separate Dateien:
|
||||
- `0001_initial_up.sql`
|
||||
- `0001_initial_down.sql`
|
||||
|
||||
### 4.3 Version-Kompatibilitäts-Check (3 Std)
|
||||
|
||||
**Manifest-Erweiterung:**
|
||||
```python
|
||||
class PluginManifest(BaseModel):
|
||||
# ... existing fields ...
|
||||
min_app_version: str = Field(
|
||||
default="0.0.0",
|
||||
description="Minimum LeoCRM version required"
|
||||
)
|
||||
```
|
||||
|
||||
**Check bei Installation:**
|
||||
```python
|
||||
async def install(self, db, name):
|
||||
plugin = self.get_plugin(name)
|
||||
# Check app version compatibility
|
||||
app_version = SemVer.parse(settings.app_version)
|
||||
min_version = SemVer.parse(plugin.manifest.min_app_version)
|
||||
if app_version < min_version:
|
||||
raise ValueError(
|
||||
f"Plugin '{name}' requires LeoCRM >= {plugin.manifest.min_app_version}, "
|
||||
f"but current version is {settings.app_version}"
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4 Update-Benachrichtigung im Frontend (4 Std)
|
||||
|
||||
**Backend:**
|
||||
- `GET /api/v1/plugins/updates` — Liste Plugins mit verfügbarer neuer Version
|
||||
- Vergleich mit Marketplace-Registry (wenn verfügbar) oder lokaler Version
|
||||
|
||||
**Frontend:**
|
||||
- Badge im Plugin-Settings: "Update verfügbar (1.2.0 → 1.3.0)"
|
||||
- Update-Button: Löst Update aus (führt neue Migrationen aus)
|
||||
- Changelog-Anzeige (optional)
|
||||
|
||||
### 4.5 Tests (4 Std)
|
||||
|
||||
- `test_semver.py` — SemVer-Vergleich, Parse, Edge Cases
|
||||
- `test_versioning.py` — Upgrade, Downgrade, Kompatibilitäts-Check
|
||||
- `test_rollback.py` — Rollback-Migrationen
|
||||
- Integrationstests: Version-Update löst Migrationen aus
|
||||
|
||||
### Meilenstein Phase 4:
|
||||
- ✅ SemVer-Vergleich statt String-Vergleich
|
||||
- ✅ Rollback-Migrationen funktionieren
|
||||
- ✅ min_app_version wird geprüft
|
||||
- ✅ Frontend zeigt Update-Benachrichtigungen
|
||||
- ✅ Tests bestanden
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Marketplace-Vorbereitung (Punkt 6)
|
||||
|
||||
**Ziel:** Code so vorbereiten, dass ein Marketplace nur noch gebaut werden muss — ohne Systemänderungen.
|
||||
|
||||
**Wichtig:** Funktioniert auch OHNE Marketplace — Built-in Plugins laufen normal weiter.
|
||||
|
||||
### 5.1 Externe Plugin-Discovery (6 Std)
|
||||
|
||||
**Erweiterung `registry.py`:**
|
||||
|
||||
```python
|
||||
class PluginRegistry:
|
||||
|
||||
def discover_all(self) -> list[str]:
|
||||
"""Discover built-in AND external plugins."""
|
||||
discovered = self.discover_builtins()
|
||||
discovered.extend(self.discover_external())
|
||||
return discovered
|
||||
|
||||
def discover_external(self) -> list[str]:
|
||||
"""Discover plugins from external plugins/ directory."""
|
||||
external_dir = Path(settings.external_plugins_path or "plugins")
|
||||
if not external_dir.exists():
|
||||
return []
|
||||
|
||||
discovered = []
|
||||
for plugin_dir in external_dir.iterdir():
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
|
||||
continue
|
||||
# Look for plugin.py or __init__.py with BasePlugin subclass
|
||||
plugin_file = plugin_dir / "plugin.py"
|
||||
if not plugin_file.exists():
|
||||
continue
|
||||
# Import and register
|
||||
import sys
|
||||
sys.path.insert(0, str(external_dir))
|
||||
try:
|
||||
module = importlib.import_module(f"{plugin_dir.name}.plugin")
|
||||
# ... find BasePlugin subclass ...
|
||||
finally:
|
||||
sys.path.remove(str(external_dir))
|
||||
return discovered
|
||||
```
|
||||
|
||||
### 5.2 Plugin-Signatur-Validierung (8 Std)
|
||||
|
||||
**Neue Datei: `app/plugins/signature.py`**
|
||||
|
||||
```python
|
||||
"""Plugin signature verification for external plugins."""
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
# Ed25519 oder HMAC-SHA256 Signatur
|
||||
|
||||
class PluginSignature:
|
||||
"""Verify plugin package signatures."""
|
||||
|
||||
@staticmethod
|
||||
def verify_signature(zip_path: Path, signature: bytes, public_key: bytes) -> bool:
|
||||
"""Verify Ed25519 signature of plugin ZIP."""
|
||||
# 1. Read ZIP content
|
||||
# 2. Compute hash
|
||||
# 3. Verify signature with public key
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def compute_hash(zip_path: Path) -> bytes:
|
||||
"""Compute SHA-256 hash of plugin ZIP."""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def sign_plugin(zip_path: Path, private_key: bytes) -> bytes:
|
||||
"""Sign a plugin ZIP (for plugin authors)."""
|
||||
pass
|
||||
```
|
||||
|
||||
### 5.3 Plugin-Allowlist (4 Std)
|
||||
|
||||
**Neue Alembic-Migration: `0044_plugin_allowlist.py`**
|
||||
|
||||
```python
|
||||
# Tabelle: plugin_allowlist
|
||||
# - id: UUID
|
||||
# - plugin_name: VARCHAR(80)
|
||||
# - allowed_hash: VARCHAR(64) # SHA-256
|
||||
# - allowed_signature: TEXT # Ed25519 signature
|
||||
# - added_by: UUID (user)
|
||||
# - created_at: TIMESTAMPTZ
|
||||
# - is_active: BOOLEAN
|
||||
```
|
||||
|
||||
### 5.4 Plugin-Metadata-Erweiterung (4 Std)
|
||||
|
||||
**Manifest-Erweiterung:**
|
||||
```python
|
||||
class PluginManifest(BaseModel):
|
||||
# ... existing fields ...
|
||||
author: str = Field(default="", description="Plugin author")
|
||||
author_email: str = Field(default="", description="Author contact")
|
||||
homepage: str = Field(default="", description="Plugin homepage URL")
|
||||
license: str = Field(default="MIT", description="License")
|
||||
min_app_version: str = Field(default="0.0.0")
|
||||
icon: str = Field(default="", description="Icon URL or emoji")
|
||||
screenshots: list[str] = Field(default_factory=list)
|
||||
changelog: str = Field(default="", description="Changelog URL or text")
|
||||
tags: list[str] = Field(default_factory=list, description="Marketplace categories")
|
||||
price: float = Field(default=0.0, description="Price (0 = free)")
|
||||
```
|
||||
|
||||
### 5.5 Plugin-Download-Endpoint (4 Std)
|
||||
|
||||
**Neue Route: `POST /api/v1/plugins/install-marketplace`**
|
||||
|
||||
```python
|
||||
@router.post("/install-marketplace")
|
||||
async def install_from_marketplace(
|
||||
body: MarketplaceInstall,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("plugins:configure")),
|
||||
):
|
||||
"""Install a plugin from the marketplace.
|
||||
|
||||
1. Download ZIP from marketplace URL
|
||||
2. Verify signature against allowlist
|
||||
3. Validate manifest
|
||||
4. Check dangerous imports
|
||||
5. Validate migration SQL
|
||||
6. Install (migrations + DB record)
|
||||
7. Activate (optional)
|
||||
"""
|
||||
# 1. Download
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(body.url)
|
||||
zip_data = resp.content
|
||||
|
||||
# 2. Verify signature
|
||||
if not PluginSignature.verify_signature(zip_data, body.signature, public_key):
|
||||
raise HTTPException(403, "Invalid plugin signature")
|
||||
|
||||
# 3-6. Validate and install
|
||||
# ... (reuse existing validation + install logic)
|
||||
```
|
||||
|
||||
### 5.6 Plugin-Update-Check (4 Std)
|
||||
|
||||
```python
|
||||
@router.get("/updates")
|
||||
async def check_plugin_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("plugins:read")),
|
||||
):
|
||||
"""Check for available plugin updates from marketplace."""
|
||||
# 1. Query marketplace registry (if configured)
|
||||
# 2. Compare versions with installed plugins
|
||||
# 3. Return list of available updates
|
||||
```
|
||||
|
||||
### 5.7 Plugin-Quarantine (4 Std)
|
||||
|
||||
```python
|
||||
async def _quarantine_plugin(zip_path: Path) -> Path:
|
||||
"""Extract plugin to temp dir, validate, then move to plugins/ dir.
|
||||
|
||||
1. Extract to /tmp/plugin_upload_<uuid>/
|
||||
2. Validate manifest exists
|
||||
3. Check dangerous imports
|
||||
4. Validate migration SQL
|
||||
5. Check signature
|
||||
6. If all OK: move to plugins/ dir
|
||||
7. If any fail: delete temp dir, raise error
|
||||
"""
|
||||
```
|
||||
|
||||
### 5.8 Tests (8 Std)
|
||||
|
||||
- `test_marketplace.py` — Download, Verify, Install Flow
|
||||
- `test_signature.py` — Signatur-Validierung
|
||||
- `test_allowlist.py` — Allowlist-Management
|
||||
- `test_quarantine.py` — Quarantine-Validierung
|
||||
- `test_external_discovery.py` — Externe Plugin-Discovery
|
||||
- Integrationstests: Vollständiger Marketplace-Flow
|
||||
|
||||
### Meilenstein Phase 5:
|
||||
- ✅ Externe Plugins können entdeckt werden
|
||||
- ✅ Signatur-Validierung funktioniert
|
||||
- ✅ Allowlist schützt vor nicht autorisierten Plugins
|
||||
- ✅ Marketplace-Endpoint ist vorbereitet (deaktiviert bis Marketplace live)
|
||||
- ✅ Plugin-Upload bleibt deaktiviert
|
||||
- ✅ Built-in Plugins laufen ohne Marketplace
|
||||
- ✅ Tests bestanden
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Manifest-Anpassung & Konsolidierung
|
||||
|
||||
**Ziel:** Alle in Phase 4 und 5 definierten Manifest-Felder werden ins `PluginManifest` integriert, bestehende Manifeste aktualisiert, und das Manifest-System finalisiert.
|
||||
|
||||
**Wichtig:** Diese Phase baut auf Phase 4 (Versioning) und Phase 5 (Marketplace) auf und muss als letztes durchgeführt werden.
|
||||
|
||||
### 6.1 PluginManifest erweitern (4 Std)
|
||||
|
||||
**Aktuelles Manifest (verifiziert 2026-07-26):**
|
||||
```python
|
||||
class PluginManifest(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
display_name: str
|
||||
description: str
|
||||
dependencies: list[str]
|
||||
routes: list[PluginRouteDef]
|
||||
events: list[str]
|
||||
migrations: list[str]
|
||||
permissions: list[str]
|
||||
is_core: bool
|
||||
field_definitions: list[FieldDefinition]
|
||||
agent_capabilities: list[str]
|
||||
menu_items: list[FrontendMenuItem]
|
||||
page_routes: list[FrontendPageRoute]
|
||||
detail_tabs: list[FrontendDetailTab]
|
||||
settings_pages: list[FrontendSettingsPage]
|
||||
dashboard_widgets: list[FrontendDashboardWidget]
|
||||
agent_definitions: list[AgentDefinitionContribution]
|
||||
automation_templates: list[AutomationTemplateContribution]
|
||||
cron_jobs: list[CronJobContribution]
|
||||
heartbeat_configs: list[HeartbeatConfigContribution]
|
||||
miniapps: list[MiniAppContribution]
|
||||
custom_fields: list[CustomFieldDefinition]
|
||||
model_config = {"extra": "forbid"}
|
||||
```
|
||||
|
||||
**Neue Felder hinzufügen:**
|
||||
```python
|
||||
class PluginManifest(BaseModel):
|
||||
# ... alle bestehenden Felder ...
|
||||
|
||||
# ── Versioning (Phase 4) ──
|
||||
min_app_version: str = Field(
|
||||
default="0.0.0",
|
||||
description="Minimum LeoCRM version required (SemVer)"
|
||||
)
|
||||
|
||||
# ── Marketplace (Phase 5) ──
|
||||
author: str = Field(default="", max_length=200, description="Plugin author name")
|
||||
author_email: str = Field(default="", max_length=200, description="Author contact email")
|
||||
homepage: str = Field(default="", max_length=500, description="Plugin homepage URL")
|
||||
license: str = Field(default="MIT", max_length=50, description="License identifier")
|
||||
icon: str = Field(default="", description="Icon URL or emoji")
|
||||
screenshots: list[str] = Field(default_factory=list, description="Screenshot URLs for marketplace")
|
||||
changelog: str = Field(default="", description="Changelog URL or inline text")
|
||||
marketplace_tags: list[str] = Field(default_factory=list, description="Marketplace category tags")
|
||||
price: float = Field(default=0.0, ge=0.0, description="Price (0 = free)")
|
||||
|
||||
# ── Hooks (Phase 2) ──
|
||||
hooks: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Hook names this plugin registers (e.g. 'contact.before_create')"
|
||||
)
|
||||
|
||||
# ── Contracts (Phase 1) ──
|
||||
contract_version: str = Field(
|
||||
default="1.0.0",
|
||||
description="Contract API version this plugin exposes"
|
||||
)
|
||||
```
|
||||
|
||||
### 6.2 Manifest-Schema-Dokumentation aktualisieren (3 Std)
|
||||
|
||||
**`MANIFEST_SCHEMA_DOC` in `manifest.py` erweitern:**
|
||||
- Alle neuen Felder in `fields`-Dict aufnehmen
|
||||
- `example`-Manifest mit neuen Feldern aktualisieren
|
||||
- API-Endpoint `GET /api/v1/plugins/manifest` liefert vollständiges Schema
|
||||
|
||||
### 6.3 Alle 19 Plugin-Manifeste aktualisieren (8 Std)
|
||||
|
||||
Jedes Plugin-Manifest muss um die neuen Felder erweitert werden:
|
||||
|
||||
| # | Plugin | Aufwand | Neue Felder |
|
||||
|---|---|---|---|
|
||||
| 1 | `ai_assistant` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||
| 2 | `ai_proactive` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||
| 3 | `ai_ui_control` | 20 Min | author, min_app_version, contract_version |
|
||||
| 4 | `automation` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||
| 5 | `calendar` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||
| 6 | `dms` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||
| 7 | `entity_links` | 15 Min | author, min_app_version, contract_version |
|
||||
| 8 | `forgejo_error_reporter` | 15 Min | author, min_app_version, contract_version |
|
||||
| 9 | `kommunikation` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||
| 10 | `mail` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||
| 11 | `mcp_client` | 20 Min | author, min_app_version, contract_version |
|
||||
| 12 | `mcp_server` | 20 Min | author, min_app_version, contract_version |
|
||||
| 13 | `permissions` | 20 Min | author, min_app_version, contract_version |
|
||||
| 14 | `report_generator` | 20 Min | author, min_app_version, contract_version |
|
||||
| 15 | `system_notif` | 15 Min | author, min_app_version, contract_version |
|
||||
| 16 | `tags` | 15 Min | author, min_app_version, contract_version |
|
||||
| 17 | `tasks` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||
| 18 | `test_sample` | 10 Min | author, min_app_version, contract_version |
|
||||
| 19 | `unified_search` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||
|
||||
**Muster für Aktualisierung:**
|
||||
```python
|
||||
# VORHER:
|
||||
manifest = PluginManifest(
|
||||
name="calendar",
|
||||
version="1.0.0",
|
||||
display_name="Calendar",
|
||||
...
|
||||
)
|
||||
|
||||
# NACHHER:
|
||||
manifest = PluginManifest(
|
||||
name="calendar",
|
||||
version="1.0.0",
|
||||
display_name="Calendar",
|
||||
# ... bestehende Felder ...
|
||||
# ── Neue Felder ──
|
||||
min_app_version="1.0.0",
|
||||
author="LeoCRM Team",
|
||||
license="MIT",
|
||||
hooks=["calendar.before_appointment", "calendar.after_appointment"],
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
```
|
||||
|
||||
### 6.4 Frontend Plugin-Manifest-Typen aktualisieren (2 Std)
|
||||
|
||||
**`frontend/src/api/pluginManifests.ts` und `frontend/src/types/automation.ts`:**
|
||||
- TypeScript-Interfaces um neue Manifest-Felder erweitern
|
||||
- `PluginManifestResponse`-Typ aktualisieren
|
||||
- Frontend-Komponenten die Manifest-Felder anzeigen erweitern
|
||||
|
||||
### 6.5 Manifest-Validierung verschärfen (3 Std)
|
||||
|
||||
**Neue Validierungsregeln in `PluginManifest`:**
|
||||
```python
|
||||
@field_validator("min_app_version")
|
||||
@classmethod
|
||||
def validate_min_app_version(cls, v: str) -> str:
|
||||
"""Validate SemVer format."""
|
||||
from app.plugins.semver import SemVer
|
||||
SemVer.parse(v) # Raises ValueError if invalid
|
||||
return v
|
||||
|
||||
@field_validator("hooks")
|
||||
@classmethod
|
||||
def validate_hooks(cls, v: list[str]) -> list[str]:
|
||||
"""Validate hook names follow namespace.pattern."""
|
||||
for hook in v:
|
||||
if not re.match(r"^[a-z_]+\.[a-z_]+$", hook):
|
||||
raise ValueError(f"Invalid hook name '{hook}': must be 'namespace.action'")
|
||||
return v
|
||||
```
|
||||
|
||||
### 6.6 Tests für erweitertes Manifest (3 Std)
|
||||
|
||||
- `test_manifest.py` — Neue Felder validieren
|
||||
- `test_manifest_validation.py` — SemVer-Validierung, Hook-Name-Validierung
|
||||
- Alle Plugin-Tests: Manifest mit neuen Feldern erstellen
|
||||
- Frontend-Tests: Manifest mit neuen Feldern rendern
|
||||
|
||||
### Meilenstein Phase 6:
|
||||
- ✅ `PluginManifest` hat alle neuen Felder (min_app_version, author, hooks, contract_version, etc.)
|
||||
- ✅ `MANIFEST_SCHEMA_DOC` ist vollständig aktualisiert
|
||||
- ✅ Alle 19 Plugin-Manifeste haben die neuen Felder
|
||||
- ✅ Frontend-Typen sind aktualisiert
|
||||
- ✅ Manifest-Validierung ist verschärft
|
||||
- ✅ Tests bestanden
|
||||
|
||||
---
|
||||
|
||||
## Zeitplan
|
||||
|
||||
```
|
||||
Woche 1 (Tag 1-5): Phase 1 — Contracts (Teil 1: contracts.py + Imports)
|
||||
Woche 2 (Tag 6-8): Phase 1 — Contracts (Teil 2: Deaktivierung + Tests)
|
||||
(Tag 9-10): Phase 2 — Hooks/Filters-System
|
||||
Woche 3 (Tag 11): Phase 3 — Plugin-Isolation
|
||||
(Tag 12-14): Phase 4 — Plugin-Versioning
|
||||
Woche 4 (Tag 15-19): Phase 5 — Marketplace-Vorbereitung
|
||||
Woche 5 (Tag 20-22): Phase 6 — Manifest-Anpassung & Konsolidierung
|
||||
(Tag 23): Puffer / Bugfixes / Doku
|
||||
```
|
||||
|
||||
### Abhängigkeiten
|
||||
```
|
||||
Phase 1 (Contracts) ──→ Phase 3 (Isolation: Linting braucht Contracts als Ausnahme)
|
||||
│
|
||||
└──→ Phase 2 (Hooks: unabhängig, kann parallel)
|
||||
│
|
||||
└──→ Phase 4 (Versioning: braucht Contracts für min_app_version)
|
||||
│
|
||||
└──→ Phase 5 (Marketplace: braucht alles)
|
||||
│
|
||||
└──→ Phase 6 (Manifest: braucht Phase 4 + 5 Felder)
|
||||
```
|
||||
|
||||
### Parallelisierungsmöglichkeiten
|
||||
- Phase 1 und Phase 2 können **parallel** laufen (verschiedene Entwickler)
|
||||
- Phase 3 kann erst nach Phase 1 starten
|
||||
- Phase 4 kann nach Phase 1 starten
|
||||
- Phase 5 kann erst nach Phase 1+4 starten
|
||||
- Phase 6 kann erst nach Phase 4+5 starten (braucht deren Manifest-Felder)
|
||||
|
||||
---
|
||||
|
||||
## Risiken
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Contract-Refactoring bricht bestehende Funktionalität | Mittel | Hoch | Tests nach jedem Plugin, schrittweise Migration |
|
||||
| Hooks/Filters verändern Core-Verhalten | Niedrig | Mittel | Tests für alle Hook-Punkte, Priority-System |
|
||||
| Externe Plugin-Discovery hat Sicherheitslücken | Mittel | Hoch | Signatur-Validierung, Quarantine, Allowlist |
|
||||
| SemVer-Parse-Fehler bei bestehenden Versionen | Niedrig | Niedrig | Fallback auf String-Vergleich |
|
||||
| Rollback-Migrationen löschen Daten | Mittel | Hoch | Bestätigungs-Prompt, Backup vor Rollback |
|
||||
|
||||
---
|
||||
|
||||
## Erfolgskriterien
|
||||
|
||||
Nach Abschluss aller 5 Phasen:
|
||||
|
||||
1. ✅ **0 direkte Cross-Plugin-Imports** (grep-verifiziert, linting-enforced)
|
||||
2. ✅ **Alle 16 Plugins haben contracts.py** mit klarer öffentlicher API
|
||||
3. ✅ **Contracts werden bei Deaktivierung abgemeldet**
|
||||
4. ✅ **Hooks/Filters-System** mit 15+ Hook-Punkten in Core-Services
|
||||
5. ✅ **Plugin-Isolation** durch Linting-Regeln erzwungen
|
||||
6. ✅ **SemVer-Vergleich** statt String-Vergleich
|
||||
7. ✅ **Rollback-Migrationen** für alle Plugins verfügbar
|
||||
8. ✅ **min_app_version** wird bei Installation geprüft
|
||||
9. ✅ **Update-Benachrichtigung** im Frontend
|
||||
10. ✅ **Marketplace-Endpoint** vorbereitet (deaktiviert)
|
||||
11. ✅ **Signatur-Validierung** für externe Plugins
|
||||
12. ✅ **Allowlist** schützt vor nicht autorisierten Plugins
|
||||
13. ✅ **Externe Plugin-Discovery** funktioniert
|
||||
14. ✅ **Alle Tests bestanden**
|
||||
15. ✅ **Built-in Plugins laufen ohne Marketplace**
|
||||
16. ✅ **PluginManifest hat alle neuen Felder** (min_app_version, author, hooks, contract_version, etc.)
|
||||
17. ✅ **Alle 19 Plugin-Manifeste aktualisiert** mit neuen Feldern
|
||||
18. ✅ **Manifest-Validierung verschärft** (SemVer, Hook-Names)
|
||||
19. ✅ **Frontend-Typen aktualisiert** für neue Manifest-Felder
|
||||
|
||||
---
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Nach Abschluss jeder Phase:
|
||||
- `docs/plugin-system/phase-N.md` — Was wurde gemacht, was geändert
|
||||
- `docs/plugin-system/contracts-api.md` — Contract-API Referenz
|
||||
- `docs/plugin-system/hooks-api.md` — Hooks/Filters Referenz
|
||||
- `docs/plugin-system/marketplace-api.md` — Marketplace-API Referenz
|
||||
- `docs/plugin-system/plugin-development-guide.md` — Wie man ein Plugin entwickelt
|
||||
|
||||
---
|
||||
|
||||
**Dieser Plan ist vollständig. Alle Aufgaben, Aufwände, Abhängigkeiten und Risiken sind erfasst.**
|
||||
+228
-733
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
# RBAC Build Progress — LeoCRM
|
||||
|
||||
## Letztes Update: 2026-07-29 03:17 CEST
|
||||
|
||||
## Alle 23 Sprints — Code vollständig erstellt ✅
|
||||
|
||||
### Sprint Übersicht
|
||||
|
||||
| Sprint | Inhalt | Status |
|
||||
|--------|--------|:---:|
|
||||
| 1 — Fundament | entity_permissions + OwnedMixin + Service + API + Redis-Cache + RLS + Rate Limiting | ✅ Deployed |
|
||||
| 2 — Row-Level Security | visibility.py + 9 Services + 9 Routes + BaseSearchProvider + Frontend Permission-Checks | ✅ Deployed |
|
||||
| 3 — Search/Dashboard/Export | Search Provider Permission-aware + Dashboard Counts + Export Filter | ✅ Deployed |
|
||||
| 4 — Field-Level | 44 Core Field Definitions + Custom Field Sensitivity + filter_fields_by_permission | ✅ Code |
|
||||
| 5 — Sharing UI | Universeller ShareDialog + Entity Permission API + Hooks | ✅ Code |
|
||||
| 6 — Notifications + Audit | Permission-Change Notifications + Audit Trail + Notification Entity Filter | ✅ Code |
|
||||
| 7 — E-Mail Postfächer | Mailbox owner_id + Permissions + Migration 0053 | ✅ Code |
|
||||
| 8 — Plugin Entities | DMS/Calendar/Tasks OwnedMixin + Migration 0054 | ✅ Code |
|
||||
| 9 — App-Sichtbarkeit | Sidebar Permission-Filter + TopBar + ProtectedRoute + Route Guards | ✅ Deployed |
|
||||
| 10 — Advanced Security + AI | AI Copilot Permission-Aware + API-Token Scopes + Merge Check | ✅ Code |
|
||||
| 11 — Owner Management | Owner Transfer Service + Auto-Transfer + API | ✅ Code |
|
||||
| 12 — Zentrale Einstellungsseite | SettingsRechte.tsx mit Tabs (Rollen, Gruppen, Freigaben, Audit) | ✅ Code |
|
||||
| 13 — ABAC Engine | entity_policies + Policy Service + Migration 0055 | ✅ Code |
|
||||
| 14 — ABAC UI | ABACRuleEditor.tsx + policies.ts + policyHooks.ts | ✅ Code |
|
||||
| 15 — Templates & Automation | permission_templates + Service + Migration 0056 | ✅ Code |
|
||||
| 16 — Mass & Bulk | bulk_share + bulk_unshare + API | ✅ Code |
|
||||
| 17 — Analytics & Konflikte | permission_analytics + API | ✅ Code |
|
||||
| 18 — Delegation | permission_delegations + Service + Migration 0057 | ✅ Code |
|
||||
| 19 — Resolution-Strategien | 4 Strategien + Tenant-Einstellung + Migration 0058 | ✅ Code |
|
||||
| 20 — Tests | test_entity_permissions + test_abac + test_permission_performance | ✅ Code |
|
||||
| 21 — Dokumentation | permissions.md + permissions_plugin_dev.md | ✅ Code |
|
||||
| 22 — Guest Access | guest_users + Guest Auth + Invitation + Guest Frontend + Migration 0059 | ✅ Code |
|
||||
| 23 — Infrastructure | PgBouncer + Audit Partitioning docs + scripts | ✅ Code |
|
||||
|
||||
### Migrationen in Produktion
|
||||
| # | Beschreibung | Status |
|
||||
|---|-------------|:---:|
|
||||
| 0048 | contact_folder_permissions Tabelle | ✅ |
|
||||
| 0049 | entity_permissions Tabelle | ✅ |
|
||||
| 0050 | owner_id auf 15 Tabellen | ✅ |
|
||||
| 0051 | Folder ACLs → entity_permissions | ✅ |
|
||||
| 0052 | RLS Policies auf contacts | ✅ |
|
||||
| 0053 | mail_accounts owner_id | ✅ |
|
||||
| 0054 | Plugin owner_id (files, folders, calendars, tasks) | ✅ |
|
||||
| 0055 | entity_policies Tabelle | ✅ |
|
||||
| 0056 | permission_templates Tabelle | ✅ |
|
||||
| 0057 | permission_delegations Tabelle | ✅ |
|
||||
| 0058 | tenants resolution_strategy | ✅ |
|
||||
| 0059 | guest_users Tabelle | ✅ |
|
||||
|
||||
### Git Commits (Diese Session)
|
||||
| Hash | Beschreibung |
|
||||
|------|-------------|
|
||||
| cc021cd | feat: folder permissions (ACLs) |
|
||||
| 5afa1fa | sprint1: entity_permissions + owned_mixin + service + API |
|
||||
| 48647a5 | sprint1: set_user_context + RLS policies + folder ACL migration |
|
||||
| ea1c1d5 | sprint1 complete: rate limiting |
|
||||
| 479ee04 | sprint2: visibility filter + contact service access checks |
|
||||
| 9fc84b7 | sprint2: 8 services + 8 routes visibility filter + BaseSearchProvider |
|
||||
| 52a5c34 | sprint2: frontend permission checks |
|
||||
| 517e1b6 | sprint2+3: remaining services + search provider permission-aware |
|
||||
| b06aeeb | sprint3: dashboard counts + import owner_id + export filter |
|
||||
| 71ed592 | sprint4+5: field-level permissions + universal ShareDialog |
|
||||
| 88c0428 | sprint6+7: notifications + audit + mail permissions |
|
||||
| 48b2dfd | sprint9: app visibility — sidebar + route guards |
|
||||
| 958e412 | sprint8: plugin entities migration 0054 |
|
||||
| b7ccd9e | sprint8: fix migration 0054 |
|
||||
| 2c14368 | sprint10+11: AI permission + owner transfer |
|
||||
| e0003b9 | sprint12+13: rechte settings + ABAC engine |
|
||||
| ddf73ee | sprint14-19: ABAC UI + templates + bulk + analytics + delegation + resolution |
|
||||
| 24690fb | sprint20-23: tests + docs + guest access + infrastructure |
|
||||
| 680d5ab | fix: migration 0058 checkconstraint |
|
||||
| 015eb94 | fix: SettingsRechte TypeScript errors |
|
||||
| 4c134c6 | fix: GuestContacts title prop |
|
||||
|
||||
### Was in Produktion läuft (Backend)
|
||||
- ✅ entity_permissions Tabelle (universelle ACLs für alle Entities)
|
||||
- ✅ owner_id auf 20+ Tabellen
|
||||
- ✅ PostgreSQL RLS auf contacts (4 Policies)
|
||||
- ✅ set_user_context() bei jedem Request
|
||||
- ✅ Universelle Permission API (/api/v1/permissions/*)
|
||||
- ✅ Rate Limiting auf Permission-Änderungen
|
||||
- ✅ Visibility Filter in 12+ Services
|
||||
- ✅ BaseSearchProvider für Permission-aware Search
|
||||
- ✅ Dashboard Counts pro User
|
||||
- ✅ Export Filter
|
||||
- ✅ AI Copilot Permission-Aware
|
||||
- ✅ Owner Transfer Service
|
||||
- ✅ ABAC Engine (entity_policies + policy_service)
|
||||
- ✅ Permission Templates
|
||||
- ✅ Bulk Share
|
||||
- ✅ Permission Analytics
|
||||
- ✅ Permission Delegation
|
||||
- ✅ Resolution Strategies (4 Strategien)
|
||||
- ✅ Guest Access (guest_users + guest_auth + invitation)
|
||||
- ✅ Permission-Change Notifications + Audit Trail
|
||||
- ✅ Mailbox Permissions
|
||||
|
||||
### Was in Produktion läuft (Frontend)
|
||||
- ✅ Permission-Checks in ContactDetail + ContactsList
|
||||
- ✅ Field-Level UI (hidden/readonly)
|
||||
- ✅ Sidebar Permission-Filter
|
||||
- ✅ TopBar Permission-Filter
|
||||
- ✅ ProtectedRoute + Route Guards
|
||||
- ✅ Universeller ShareDialog
|
||||
- ✅ ABAC Rule Editor
|
||||
- ✅ SettingsRechte (Zentrale Rechte-Seite mit Tabs)
|
||||
- ✅ Guest Login + Guest Contacts
|
||||
|
||||
### Was noch deployed werden muss
|
||||
- Backend: Sprint 4-8, 10-19, 22 Dateien sind im Code aber noch nicht alle im Container (Coolify Full Deploy nötig)
|
||||
- Frontend: Build erfolgreich, dist vorhanden
|
||||
@@ -1,7 +1,58 @@
|
||||
# LeoCRM v1.0
|
||||
|
||||
> Self-hosted CRM for small sales teams (5–25 sales reps).
|
||||
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL + Redis + React 18 + TypeScript + Vite + TanStack Query + Zustand + Tailwind + Docker + Coolify
|
||||
> Plugin-basierte KI und Business-Plattform mit 25 Plugins (CRM, Mail, DMS, Chat, AI-Agenten, Workflows, Knowledge, Search, Self-Improvement, Compliance). FastAPI Backend + React/TypeScript Frontend. Deployiert über Coolify auf Hetzner VPS.
|
||||
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL 16 (pgvector) + Redis 7 + React 18 + TypeScript + Vite + TanStack Query + Zustand + Tailwind + Docker + Coolify
|
||||
|
||||
## Features
|
||||
|
||||
### Core Platform
|
||||
- **Multi-Tenant** — Tenant-Isolation via ORM Auto-Filter + Row Level Security (RLS)
|
||||
- **Plugin System** — 25 Built-in Plugins, Manifest-basiert, aktivierbar/deaktivierbar
|
||||
- **Permission System** — ABAC/RBAC mit feingranularen Permissions
|
||||
- **Audit Log** — Vollständige Audit-Trail, CSV/JSON Export, 365 Tage Retention
|
||||
- **Entity History** — Undo/Restore für alle Entitäten
|
||||
- **Soft Delete** — `deleted_at` auf allen Entitäten, Hard-Delete mit `?gdpr=true`
|
||||
- **Unified Search** — Hybrid-Suche (PostgreSQL FTS + pgvector), KI Query-Understanding
|
||||
- **System Dashboard** — Admin-only Monitoring (DB, Redis, Worker, Errors, LLM Costs)
|
||||
- **Backup Automation** — ARQ-gesteuert, einstellbar in Settings, Backup-History
|
||||
- **Trash Cleanup** — Automatische endgültige Löschung nach 90 Tagen
|
||||
|
||||
### 25 Plugins
|
||||
|
||||
| # | Plugin | Beschreibung |
|
||||
|---|--------|-------------|
|
||||
| 1 | **contacts** | Kontakt-Verwaltung (Personen, Firmen, Ordner, Custom Fields) |
|
||||
| 2 | **mail** | IMAP/SMTP E-Mail-Integration, PGP, Filter-Regeln, Vacation Responder |
|
||||
| 3 | **dms** | Document Management System, File Upload, Preview, Sharing, Permissions |
|
||||
| 4 | **calendar** | Kalender, Termine, Ressourcen-Buchung, ICS Import/Export, Kanban |
|
||||
| 5 | **tasks** | Unified Task System, Subtasks, Goals, polymorphe Zuweisung |
|
||||
| 6 | **kommunikation** | Unified Messaging, Chat, Mini-Apps, WebSocket-basiert |
|
||||
| 7 | **automation** | Automation Builder, Trigger, Agent Runner, Cron-Scheduler |
|
||||
| 8 | **ai_assistant** | AI Chat Sessions, Provider, Models, Presets, Tools |
|
||||
| 9 | **ai_proactive** | Proactive AI, Suggestions, SSE Streaming, Settings |
|
||||
| 10 | **ai_ui_control** | AI-driven UI Control via WebSocket |
|
||||
| 11 | **agent_memory** | Agent Memory Plugin, eigene Routes |
|
||||
| 12 | **unified_search** | Hybrid-Suche, Embeddings, RRF Rank Fusion, Facets |
|
||||
| 13 | **graph_rag** | GraphRAG, Knowledge Graph, Relationship Extraction |
|
||||
| 14 | **wiki** | Wiki Plugin, Article Versioning, Categories, Entity Links |
|
||||
| 15 | **report_generator** | Report Templates, Generation, Download |
|
||||
| 16 | **entity_links** | Entity Linking, File-Entity Connections |
|
||||
| 17 | **tags** | Tag Management, Bulk-Assign, Entity-Tag Queries |
|
||||
| 18 | **permissions** | File-level Permissions, Share Links |
|
||||
| 19 | **mcp_server** | MCP Server, Tool Definitions für AI Agents |
|
||||
| 20 | **mcp_client** | MCP Client für externe Tool-Integration |
|
||||
| 21 | **marketplace** | Marketplace Listings |
|
||||
| 22 | **system_notif** | System Notifications, Alerting via Communication-System |
|
||||
| 23 | **forgejo_error_reporter** | Forgejo Error Reporting |
|
||||
| 24 | **knowledge** | LLM-based Knowledge Extraction, Ask-Knowledge, Review Queue |
|
||||
| 25 | **self_improvement** | Controlled Self-Improvement Loop (Signals, Patterns, Proposals, Impact) |
|
||||
|
||||
### AI & Automation
|
||||
- **Agent System** — ReAct-Loop, Tool-Calls, Skills, Approvals, Monitoring, SSE Streaming
|
||||
- **Workflow Engine** — 14 Step-Types, Durable Runs, Retry, Idempotency, SSRF-Schutz
|
||||
- **Decision Guard** — Automated-Decision Guard für High-Risk Actions
|
||||
- **Approval System** — Human Approval für Agent Actions und Workflow Steps
|
||||
- **LLM Client** — Zentraler LLM Client, Cost-Tracking, Multi-Provider
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
@@ -60,13 +111,10 @@ Open:
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env — set DATABASE_URL, REDIS_URL, SECRET_KEY, CORS_ORIGINS
|
||||
cp .env.docker.example .env.docker
|
||||
# Edit .env.docker — set DB_PASSWORD, REDIS_PASSWORD, SECRET_KEY, APP_DOMAIN
|
||||
# Set ENVIRONMENT=production, SESSION_COOKIE_SECURE=true
|
||||
docker compose up -d
|
||||
|
||||
# Run migrations
|
||||
docker compose exec api alembic upgrade head
|
||||
docker compose --env-file .env.docker up --build -d
|
||||
```
|
||||
|
||||
### Manual (without Docker)
|
||||
@@ -90,13 +138,23 @@ See [docs/admin-guide.md](docs/admin-guide.md) for detailed deployment, backup,
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/v1/health` | GET | No | Health check (DB, Redis, storage, worker) |
|
||||
| `/health/live` | GET | No | Liveness probe |
|
||||
| `/health/ready` | GET | No | Readiness probe (DB, Redis, storage, worker) |
|
||||
| `/api/v1/health` | GET | No | Full health check (DB, Redis, storage, worker) |
|
||||
| `/api/v1/metrics` | GET | Admin | Prometheus metrics (text/plain) |
|
||||
| `/api/v1/system/dashboard` | GET | Admin | System dashboard (DB, Redis, worker, errors, LLM costs) |
|
||||
| `/api/v1/system/alerts` | GET | Admin | Active system alerts |
|
||||
| `/api/v1/auth/login` | POST | No | Login |
|
||||
| `/api/v1/contacts` | GET | Yes | List contacts (paginated, max page_size=100) |
|
||||
| `/api/v1/contacts/export` | GET | Yes | Stream contacts as CSV |
|
||||
| `/api/v1/companies` | GET | Yes | List companies (paginated, max page_size=100) |
|
||||
| `/api/v1/companies/export` | GET | Yes | Stream companies as CSV |
|
||||
| `/api/v1/search` | POST | Yes | Hybrid search (FTS + pgvector) |
|
||||
| `/api/v1/audit-log` | GET | Admin | Query audit log entries |
|
||||
| `/api/v1/audit-log/export` | GET | Admin | Export audit log (CSV/JSON) |
|
||||
| `/api/v1/system-settings/backup-config` | GET/PUT | Admin | Backup configuration |
|
||||
| `/api/v1/system-settings/backup-now` | POST | Admin | Trigger immediate backup |
|
||||
| `/api/v1/system-settings/backup-history` | GET | Admin | Backup history (last 10) |
|
||||
|
||||
### Pagination
|
||||
|
||||
@@ -112,18 +170,25 @@ Uses `StreamingResponse` — does not buffer the entire file in memory.
|
||||
|
||||
Interactive API documentation: http://localhost:8000/docs
|
||||
|
||||
See [docs/api-overview.md](docs/api-overview.md) for the full endpoint summary.
|
||||
See [docs/api-documentation.md](docs/api-documentation.md) for the full endpoint reference.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/health
|
||||
```
|
||||
# Liveness
|
||||
curl http://localhost:8000/health/live
|
||||
# → {"status":"alive"}
|
||||
|
||||
Returns JSON with overall status (`healthy`/`degraded`) and individual checks for
|
||||
`database`, `redis`, `storage`, and `worker`.
|
||||
# Readiness
|
||||
curl http://localhost:8000/health/ready
|
||||
# → {"status":"ready","checks":{"database":"ok","redis":"ok","storage":"ok"}}
|
||||
|
||||
# Full health
|
||||
curl http://localhost:8000/api/v1/health
|
||||
# → {"status":"healthy","version":"1.0.0","checks":{...}}
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
@@ -138,6 +203,15 @@ Available metrics:
|
||||
- `leocrm_db_pool_connections` — Database connection pool size
|
||||
- `leocrm_arq_jobs_total` — Total ARQ background jobs
|
||||
|
||||
### System Dashboard
|
||||
|
||||
Admin-only dashboard at `/system-dashboard` in the WebUI. Shows:
|
||||
- System Health, DB Stats, Redis Stats, Worker Queue
|
||||
- API Stats (total requests, error rate, avg response time)
|
||||
- Plugin Stats (discovered, active)
|
||||
- Storage Stats (disk usage, file count)
|
||||
- Alert Feed (system messages from Communication-System)
|
||||
|
||||
### Structured Logging
|
||||
|
||||
LeoCRM uses `structlog` for structured JSON logging. All API requests are logged with:
|
||||
@@ -202,41 +276,73 @@ leocrm/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI entry point with logging middleware
|
||||
│ ├── config.py # Pydantic settings
|
||||
│ ├── deps.py # FastAPI dependencies (auth, permissions)
|
||||
│ ├── core/
|
||||
│ │ ├── monitoring.py # Prometheus metrics + structured logging + health checks
|
||||
│ │ ├── db.py # Async database engine
|
||||
│ │ ├── middleware.py # CSRF middleware
|
||||
│ │ ├── worker.py # ARQ worker settings
|
||||
│ │ ├── backup_job.py # Automated backup job
|
||||
│ │ ├── notifications.py # System notification dispatch
|
||||
│ │ └── ...
|
||||
│ ├── routes/
|
||||
│ │ ├── health.py # Health endpoint
|
||||
│ │ ├── health.py # Health endpoints
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint (admin-only)
|
||||
│ │ ├── system_dashboard.py # System dashboard (admin-only)
|
||||
│ │ ├── system_settings.py # System settings + backup config
|
||||
│ │ ├── audit.py # Audit log (list, export, retention)
|
||||
│ │ ├── contacts.py # Contact CRUD + streaming CSV export
|
||||
│ │ ├── companies.py # Company CRUD + streaming CSV export
|
||||
│ │ ├── workflows.py # Workflow engine routes
|
||||
│ │ └── ...
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── services/ # Business logic
|
||||
│ └── plugins/ # Plugin system
|
||||
│ ├── plugins/ # Plugin system (registry, manifest, base)
|
||||
│ │ └── builtins/ # 25 built-in plugins
|
||||
│ ├── workflows/ # Workflow engine
|
||||
│ └── ai/ # AI modules
|
||||
├── scripts/
|
||||
│ ├── fast-deploy.sh # Frontend-only / full deploy
|
||||
│ ├── deploy.py # Coolify API deployment
|
||||
│ ├── backup.py # Backup script (pg_dump + files)
|
||||
│ ├── restore.py # Restore script
|
||||
│ ├── seed_perf_data.py # Performance test data seeding
|
||||
│ └── check_indexes.py # Database index verification
|
||||
├── tests/ # Test suite (pytest + pytest-asyncio)
|
||||
├── docs/
|
||||
│ ├── admin-guide.md # Admin guide (deploy, backup, restore, troubleshooting)
|
||||
│ └── api-overview.md # API endpoint summary
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── api-documentation.md # Full API endpoint reference
|
||||
│ ├── monitoring.md # Monitoring & health checks
|
||||
│ ├── infrastructure.md # Infrastructure guide
|
||||
│ ├── deploy-guide.md # Deploy guide (fast-deploy, Coolify, server info)
|
||||
│ └── ...
|
||||
├── alembic/ # Database migrations (130+ files)
|
||||
├── frontend/ # React + TypeScript + Vite + Tailwind
|
||||
│ └── src/pages/ # SystemDashboard, Contacts, Mail, DMS, Calendar, etc.
|
||||
├── requirements.txt # Production dependencies
|
||||
├── requirements-dev.txt # Test/lint dependencies
|
||||
├── .env.example # Environment template
|
||||
├── docker-compose.yml # Docker Compose
|
||||
├── docker-compose.yaml # Docker Compose (postgres, redis, crm_app, crm_worker)
|
||||
├── Dockerfile # Multi-stage build (frontend → builder → runtime)
|
||||
├── prestart.sh # Container entrypoint (migrations, seed, uvicorn)
|
||||
├── worker.sh # ARQ worker entrypoint
|
||||
├── healthcheck.sh # Container healthcheck
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Admin Guide](docs/admin-guide.md) — Deployment, backup, restore, env vars, troubleshooting
|
||||
- [API Overview](docs/api-overview.md) — Full endpoint reference
|
||||
- [Coolify Setup](COOLIFY_SETUP.md) — Coolify deployment instructions
|
||||
- [API Documentation](docs/api-documentation.md) — Full endpoint reference (300+ endpoints)
|
||||
- [Monitoring](docs/monitoring.md) — Health checks, metrics, system dashboard, alerting
|
||||
- [Infrastructure](docs/infrastructure.md) — Docker, PgBouncer, audit partitioning, backup
|
||||
- [Deploy Guide](docs/deploy-guide.md) — Fast-deploy, Coolify API, server info
|
||||
- [Plugin Development](docs/plugin-development-guide.md) — Plugin development guide
|
||||
- [Security Kernel](docs/security_kernel.md) — ABAC, RLS, session security
|
||||
- [Permissions](docs/permissions.md) — Permission system documentation
|
||||
- [Test Strategy](docs/test-strategy.md) — Test conventions and constraints
|
||||
- [UI Design Guidelines](docs/ui-design-guidelines.md) — UI design rules
|
||||
- [Swagger UI](http://localhost:8000/docs) — Interactive API docs (auto-generated)
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# LeoCRM Sanierungsfortschritt
|
||||
|
||||
**Letztes Update:** 2026-08-03
|
||||
**Git-Commit:** 310a9f0 (main)
|
||||
**Alembic-Head:** 0092
|
||||
**Produktion:** https://crm.media-on.de — healthy
|
||||
|
||||
> Diese Datei ist der kompakte Fortschritts-Tracker für den Sanierungsplan.
|
||||
> Der vollständige Sanierungsplan steht in `docs/ABSCHLUSSBERICHT_PHASE0_PHASE1.md`.
|
||||
> Die Installationsanleitung steht in `docs/INSTALL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Status
|
||||
|
||||
| Phase | Status | Commit | Tests | Migration |
|
||||
|-------|--------|--------|-------|----------|
|
||||
| 0 — Ausgangsbasis | ✅ Abgeschlossen | v-phase0-baseline | — | — |
|
||||
| 1 — Login, DB-Rollen, RLS | ✅ Abgeschlossen | 733fa1c | 35 Backend + 14 Plugin | 0085–0090 |
|
||||
| 2 — Datenintegrität | ✅ Abgeschlossen | 745bc4f | FK-Tests auf Produktion | 0091 |
|
||||
| 3 — Plugin-Lifecycle | ✅ Abgeschlossen | dfd9e77 | 14/14 pytest | — |
|
||||
| 4 — KI-Delegation | ⏳ Nicht begonnen | — | — | — |
|
||||
| 5 — Outbox | ✅ Abgeschlossen | 07a9997 | 18/18 pytest + Prod-Smoke | 0092 |
|
||||
| 6 — Workspaces | ✅ Abgeschlossen | 310a9f0 | 25 Backend + 12 Frontend | 0072–0074 |
|
||||
| 7 — DMS/Attachments | ⏳ Nicht begonnen | — | — | — |
|
||||
| 8 — Sicherheitsreste | ⏳ Nicht begonnen | — | — | — |
|
||||
| 9 — CI/Quality Gates | ⏳ Nicht begonnen | — | — | — |
|
||||
| 10 — Backup/Monitoring/Pilot | ⏳ Nicht begonnen | — | — | — |
|
||||
|
||||
---
|
||||
|
||||
## Abgenommene Gates (Phase 0+1)
|
||||
|
||||
| Gate | Beschreibung | Status |
|
||||
|------|-------------|--------|
|
||||
| Gate 1 | Reproduzierbares Coolify-Deployment | ✅ |
|
||||
| Gate 2 | Neuinstallation auf leerer Datenbank | ✅ |
|
||||
| Gate 3 | Vollständiger Restore-Test | ✅ |
|
||||
| Gate 4 | Passwort-Reset end-to-end | ✅ |
|
||||
| Gate 5 | Worker und Eventhandler | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Produktions-Setup
|
||||
|
||||
### Coolify-Ressourcen
|
||||
|
||||
| Ressource | UUID | Typ |
|
||||
|-----------|------|------|
|
||||
| API (crm.media-on.de) | stvabl4vaqru7jclx4ittzr3 | Application |
|
||||
| Worker | asxqaq3566to108xordck0ff | Service |
|
||||
| PostgreSQL | (Coolify Service) | Service |
|
||||
| Redis | (Coolify Service) | Service |
|
||||
|
||||
### Datenbankrollen
|
||||
|
||||
| Rolle | Superuser | BYPASSRLS | Verwendung |
|
||||
|-------|----------|-----------|------------|
|
||||
| crm_user | Ja | Ja | Bootstrap (POSTGRES_USER) |
|
||||
| crm_migration | Nein | Ja | Alembic + Plugin-Migrationen (DDL) |
|
||||
| crm_auth | Nein | Nein | Login, Authentifizierung |
|
||||
| crm_api | Nein | Nein | API-Abfragen |
|
||||
| crm_worker | Nein | Nein | ARQ-Worker, Outbox |
|
||||
|
||||
### Volumes
|
||||
|
||||
| Volume | Verwendung |
|
||||
|--------|------------|
|
||||
| crm-postgres-data | PostgreSQL-Daten |
|
||||
| crm-redis-data | Redis-Daten |
|
||||
| stvabl4vaqru7jclx4ittzr3_storage | API + Worker Storage (geteilt) |
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Full deploy (API + Worker) über Coolify API
|
||||
COOLIFY_API_TOKEN=<token> python scripts/deploy.py
|
||||
|
||||
# Nur Verifikation
|
||||
COOLIFY_API_TOKEN=<token> python scripts/deploy.py --verify-only
|
||||
|
||||
# Nur Worker
|
||||
COOLIFY_API_TOKEN=<token> python scripts/deploy.py --worker-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Was erledigt ist
|
||||
|
||||
### Phase 0+1 (Security & RLS)
|
||||
- 5 DB-Rollen mit separaten Verbindungen
|
||||
- RLS fail-closed auf 108 Tenant-Tabellen
|
||||
- FORCE ROW LEVEL SECURITY aktiviert
|
||||
- 0 legacy app.tenant_id Policies
|
||||
- Plugin-Migrationen über crm_migration (DDL)
|
||||
- Worker per-Tenant Outbox-Processing mit RLS-Kontext
|
||||
- Event-Handler nur für aktive Plugins
|
||||
- Passwort-Reset end-to-end mit SMTP getestet
|
||||
- Leere DB-Installation ohne manuelle Eingriffe
|
||||
- Restore + Upgrade verifiziert
|
||||
- Coolify Redeploy/Stop/Start funktioniert ohne manuelles Eingreifen
|
||||
|
||||
### Phase 2 (Datenintegrität)
|
||||
- 74 FK-Constraints (tenant_id → tenants.id ON DELETE CASCADE) hinzugefügt
|
||||
- 10 globale Tabellen ausgeschlossen
|
||||
- Orphan-Cleanup durchgeführt
|
||||
- FK-Tests auf Produktion: INSERT mit ungültiger tenant_id blockiert ✅
|
||||
|
||||
### Phase 3 (Plugin-Lifecycle)
|
||||
- 14 Tests: Registry, Lifecycle, Idempotency, Dependencies, Core-Schutz
|
||||
- Plugin-Lifecycle war bereits korrekt implementiert
|
||||
- Tests bestätigen: activate → deactivate → reactivate funktioniert
|
||||
|
||||
---
|
||||
|
||||
## Was als nächstes zu tun ist
|
||||
|
||||
### Phase 5 (Outbox) — abgeschlossen (produktionsverifiziert)
|
||||
- Per-Tenant Outbox-Processing (Gate 5)
|
||||
- Dead-Letter-Queue: error_message + failed_at Spalten, Replay-Funktionen
|
||||
- Monitoring: /api/v1/outbox/stats, /failed, /consumer-registry Endpoints
|
||||
- Consumer-Registry: outbox_deliveries pro Consumer-Handler geschrieben
|
||||
- Processing-Recovery: recover_stuck_events (stuck processing -> pending)
|
||||
- Retention-Cleanup: cleanup_published_events (hourly cron job, 30 days)
|
||||
- Replay setzt outbox_deliveries zurueck (clean retry)
|
||||
- 23/23 Unit-Tests + Produktions-Verifikation:
|
||||
- outbox_deliveries: 4 Eintraege mit status=delivered
|
||||
- recover-stuck: 200, 0 stuck events
|
||||
- cleanup-published: 200, 22 alte Events geloescht
|
||||
- consumer-registry: 200, alle Handler gelistet
|
||||
- failed: 200, 0 failed events
|
||||
- stats: 200, korrekte counts
|
||||
- deploy.py repariert: Worker-Deploy funktioniert jetzt korrekt
|
||||
|
||||
### Phase 7 (DMS/Attachments) — nicht begonnen
|
||||
- Streaming Upload/Download
|
||||
- Deduplikation tenantlokal
|
||||
- Keine Cross-Tenant-Dateireferenzen
|
||||
- Aufwand: 10–16h
|
||||
|
||||
### Phase 4 (KI-Delegation) — nicht begonnen
|
||||
- Delegation-Contract, Tenant-scoped Permissions
|
||||
- Audit, Rollback, Approval
|
||||
- Aufwand: 10–16h
|
||||
|
||||
### Phase 6 (Workspaces) — abgeschlossen (produktionsverifiziert)
|
||||
- Backend: Widget CRUD (create, list, update, delete), Manager-Role-Check, Cross-Tenant-Validierung
|
||||
- Default-Workspace Seeding (12 Standard-Module), Set-User-Default-Workspace
|
||||
- Fix: create_workspace Default-Uniqueness (unset others before insert)
|
||||
- Frontend: workspaceStore (Zustand) mit sessionStorage Persistenz
|
||||
- API-Client Interceptor: X-Workspace-ID Header auf allen Requests
|
||||
- useWorkspace hook auf workspaceStore umgestellt
|
||||
- Widget API hooks: useWorkspaceWidgets, useCreateWorkspaceWidget, etc.
|
||||
- Settings-Route: /settings/workspaces mit WorkspaceManagerPage
|
||||
- 25 Backend-Tests + 12 Frontend-Tests (alle bestanden)
|
||||
- Produktions-Verifikation:
|
||||
- 2 Workspaces (Verkauf/Einkauf) mit unterschiedlichen Modulen ✅
|
||||
- Hidden module (calendar in Einkauf) nicht in Context ✅
|
||||
- Multiple widgets mit gleichem key (2x recent_contacts) ✅
|
||||
- Widget CRUD: create, update, delete ✅
|
||||
- Set-default: Workspace-Wechsel funktioniert ✅
|
||||
- Manager-Role: Creator ist Manager ✅
|
||||
- Cross-Tenant: RLS isoliert Workspaces pro Tenant ✅
|
||||
|
||||
### Phase 8–10 — nicht begonnen
|
||||
- Sicherheitsreste, CI, Backup/Monitoring
|
||||
- Aufwand: 38–66h
|
||||
|
||||
---
|
||||
## Wichtige Dateien
|
||||
|
||||
| Datei | Inhalt |
|
||||
|-------|--------|
|
||||
| `docs/ABSCHLUSSBERICHT_PHASE0_PHASE1.md` | Vollständiger Abschlussbericht + Sanierungsplan |
|
||||
| `docs/INSTALL.md` | Vollständige Installationsanleitung |
|
||||
| `docs/phase0_phase1_acceptance_report.md` | Abnahmeprotokoll Phase 0+1 |
|
||||
| `scripts/deploy.py` | Coolify API Deployment-Skript |
|
||||
| `scripts/seed_admin.py` | Admin-User erstellen |
|
||||
| `docker-compose.yml` | Referenz-Compose (API + Worker + DB + Redis) |
|
||||
| `.env.docker.example` | ENV-Template |
|
||||
| `prestart.sh` | Container-Entrypoint (Migrationen + Rollen) |
|
||||
| `worker.sh` | Worker-Entrypoint |
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Regeln für den nächsten Agenten
|
||||
|
||||
1. **Keine manuellen Docker-Befehle** — alles über Coolify API oder deploy.py
|
||||
2. **Repo lesen bevor ändern** — docker-compose.yml und deploy.py beachten
|
||||
3. **Migrationen sind Forward-Only** — keine alten Migrationen verändern
|
||||
4. **RLS ist fail-closed** — kein Tenant-Kontext = kein Zugriff
|
||||
5. **crm_api hat keine DDL-Rechte** — Plugin-Migrationen über get_migration_engine()
|
||||
6. **Worker ist Coolify Service** — UUID asxqaq3566to108xordck0ff
|
||||
7. **Alle DB-Passwörter sind identisch** — siehe .env.docker.example
|
||||
8. **pgvector/pgvector:pg16** als DB-Image — nicht postgres:16-alpine
|
||||
9. **Tests müssen mit echten unprivilegierten Rollen laufen** — nicht mit Superuser
|
||||
10. **Jede Phase: analysieren → implementieren → migrieren → testen → dokumentieren**
|
||||
@@ -0,0 +1,348 @@
|
||||
# LeoCRM UI-Overhaul-Plan (v2)
|
||||
|
||||
> **Erstellt:** 2026-08-21
|
||||
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
||||
> **Status:** Planung — nicht gestartet
|
||||
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
|
||||
|
||||
---
|
||||
|
||||
## Standard-Layout (Referenz: ContactsList.tsx)
|
||||
|
||||
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────┬──────────────────────┐
|
||||
│ Tree │ Liste/Ansicht │ Detail │
|
||||
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
|
||||
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
|
||||
└─────────────┴──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
|
||||
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
|
||||
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
|
||||
- **Rechts:** Detail-Bereich für ausgewähltes Element
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Echte Bugs fixen (2-3 Tage)
|
||||
|
||||
### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
|
||||
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
|
||||
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
|
||||
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
|
||||
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
|
||||
- **Aufwand:** 3 Stunden
|
||||
|
||||
### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
|
||||
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
|
||||
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
|
||||
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.4 Wiki — Artikel kann nicht gespeichert werden
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
|
||||
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.5 Kalender — Dialog schließt nicht nach Speichern
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
|
||||
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
### 1.6 Kommunikation — Chats können nicht angelegt werden
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.7 Wiki — Doppelt im Menü
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
|
||||
- **Problem:** Wiki erscheint zweimal im Menü
|
||||
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
|
||||
- **Fix:** Doppelte Route entfernen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
|
||||
|
||||
### Problem
|
||||
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
|
||||
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
|
||||
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
|
||||
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
|
||||
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
|
||||
|
||||
### 2.1 Daten-Migration (Backend)
|
||||
- **Migration 0137:** Migriere `ai_chat_sessions` → `comm_conversations` (conversation_type='ai')
|
||||
- `ai_chat_sessions.id` → `comm_conversations.id`
|
||||
- `ai_chat_sessions.title` → `comm_conversations.title`
|
||||
- `ai_chat_sessions.tenant_id` → `comm_conversations.tenant_id`
|
||||
- `ai_chat_sessions.user_id` → `comm_conversations.owner_id`
|
||||
- `ai_chat_sessions.agent_id` → `comm_conversations.metadata.agent_id`
|
||||
- `ai_chat_sessions.created_at` → `comm_conversations.created_at`
|
||||
- **Migration 0137:** Migriere `ai_chat_messages` → `comm_messages`
|
||||
- `ai_chat_messages.id` → `comm_messages.id`
|
||||
- `ai_chat_messages.session_id` → `comm_messages.conversation_id`
|
||||
- `ai_chat_messages.role` → `comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
|
||||
- `ai_chat_messages.content` → `comm_messages.content`
|
||||
- `ai_chat_messages.tenant_id` → `comm_messages.tenant_id`
|
||||
- **Migration 0137:** Migriere `ai_conversations` → `comm_conversations` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Migriere `ai_messages` → `comm_messages` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 2.2 Backend — AI Chat API auf Communication umleiten
|
||||
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
|
||||
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.3 Frontend — AI Assistant Page entfernen
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
|
||||
- **Route anpassen:** `/ai-assistant` → **gelöscht** (kein Redirect nötig)
|
||||
- **Route anpassen:** `/ai-assistant-standalone` → **gelöscht** (kein Redirect nötig)
|
||||
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.4 Frontend — Communication AI-Chat verbessern
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
|
||||
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
|
||||
- **Änderung:** AI Chat Messages aus `comm_messages` laden
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.5 Backend — ai_assistant plugin models aufräumen
|
||||
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
|
||||
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
|
||||
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
|
||||
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.6 Unified Search — AI Chat Provider anpassen
|
||||
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
|
||||
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
**Gesamtaufwand Phase 2:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
|
||||
|
||||
### 3.1 WYSIWYG Editor
|
||||
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
|
||||
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
|
||||
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
|
||||
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
|
||||
- Floating Toolbar über dem Textblock (wie Notion)
|
||||
- Markdown-Export für Backend-Speicherung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
### 3.2 Wiki Layout — 3-Spalten
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
|
||||
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
|
||||
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
|
||||
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
|
||||
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 3.3 View/Edit Mode Toggle
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`
|
||||
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
|
||||
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
|
||||
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 3:** ~3-4 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 4.1 Tasks Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
|
||||
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
|
||||
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
|
||||
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
|
||||
- **Aufwand:** 2-3 Tage
|
||||
|
||||
**Gesamtaufwand Phase 4:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Kalender UI-Überarbeitung (1 Tag)
|
||||
|
||||
### 5.1 Toolbar und Filter standardisieren
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
|
||||
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
|
||||
- **Fix:**
|
||||
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
|
||||
- Drucken-Button in PluginToolbar
|
||||
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 5.2 Kalender-Auswahl fixen
|
||||
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
|
||||
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
|
||||
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 5:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Tags Umstrukturierung (2 Tage)
|
||||
|
||||
### 6.1 Tags in Settings verschieben
|
||||
- **Datei:** `frontend/src/pages/Tags.tsx` → `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Route:** `/settings/tags` statt `/tags`
|
||||
- **Anforderung:** Tags gehören in die Einstellungen, bei System
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 6.2 Tags Baumstruktur
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
|
||||
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
|
||||
- **Frontend:** TreeView Komponente für Tags
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 6.3 Pro Tag einstellbar wo er verfügbar ist
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
|
||||
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
|
||||
- **Frontend:** Multi-Select im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 6.4 Symbol und Farbe pro Tag
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
|
||||
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
|
||||
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 6:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Reports UI-Überarbeitung (2 Tage)
|
||||
|
||||
### 7.1 Reports Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
|
||||
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
|
||||
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
|
||||
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
|
||||
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
**Gesamtaufwand Phase 7:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 8.1 Baumstruktur verbessern und Ordner
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
|
||||
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
|
||||
- **Aufbau:**
|
||||
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
|
||||
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
|
||||
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
|
||||
- **Aufwand:** 1-2 Tage
|
||||
|
||||
### 8.2 AI Chat in Kommunikation (nach Phase 2)
|
||||
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
|
||||
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
|
||||
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
|
||||
- AI Messages werden in `comm_messages` gespeichert
|
||||
- **Aufwand:** in Phase 2
|
||||
|
||||
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Strukturelle Änderungen (0.5 Tage)
|
||||
|
||||
### 9.1 System Dashboard als eigener Menüpunkt
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
|
||||
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
|
||||
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 9.2 Mail — Postfach mit IMAP anlegen testen
|
||||
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
|
||||
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
|
||||
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
|
||||
|
||||
**Gesamtaufwand Phase 9:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
||||
|-------|--------|---------|-----------|-------------|
|
||||
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
|
||||
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
|
||||
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
|
||||
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
|
||||
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
|
||||
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
|
||||
| 7 | Reports UI | 2 Tage | 0139 | Keine |
|
||||
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
|
||||
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
|
||||
|
||||
**Gesamtaufwand:** ~17-22 Tage
|
||||
|
||||
### Reihenfolge:
|
||||
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
|
||||
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
|
||||
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
|
||||
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
|
||||
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
|
||||
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
|
||||
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
|
||||
8. **Phase 7** (Reports) — großer Umbau, unabhängig
|
||||
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
|
||||
|
||||
### Migrationen:
|
||||
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
|
||||
- **0138:** Tags: parent_id, applicable_to, icon Spalten
|
||||
- **0139:** Reports: folder_id Spalte
|
||||
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
|
||||
|
||||
### Was ich NICHT tun werde:
|
||||
- Keine Massen-Scripts die neue Fehler verursachen
|
||||
- Keine Änderungen ohne Verifizierung gegen Produktion
|
||||
- Keine neuen Plugins wenn bestehende erweitert werden können
|
||||
- Keine neuen Pages wenn bestehende umgebaut werden können
|
||||
- Jede Änderung wird mit tsc und API-Test verifiziert
|
||||
|
||||
### Was ich brauche:
|
||||
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
|
||||
-1041
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,9 @@ if config.config_file_name is not None:
|
||||
|
||||
target_metadata = Base.metadata
|
||||
settings = get_settings()
|
||||
# ⚠️ RLS Migration History: 21 Migrationen mit 8 Disable-Zyklen. Dies ist historisch bedingt
|
||||
# und zeigt trial-and-error. Aktuelle RLS-Konfiguration ist stabil (113 Tabellen).
|
||||
# Bei neuen RLS-Änderungen nur noch Migration-Runner nutzen.
|
||||
# Use migration_database_url (crm_migration role, table owner) for Alembic
|
||||
config.set_main_option("sqlalchemy.url", settings.migration_database_url or settings.database_url)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
1f59cbca47ea189432d25a9bd924ead13b6f285ce7740510714e01ccc4bb7dd8 0001_initial.py
|
||||
6e5af9bb75ea05893bcd929152dbea449c54e0df27a1cb450a86fd675089519c 0002_contacts_fts.py
|
||||
6e7ac65fce63d0fcea897a897abe527ce360ae747ab96be5e0439cf6ad1dbeff 0003_plugin_system.py
|
||||
129dca600710901612ff71dd409a40bedf50570cbc19419ebe38987516369991 0004_ai_workflows.py
|
||||
22187aa9158aa994b96b496475adf46c95db4c7c98aa99c3d39d27c00696d084 0005_user_role_fk.py
|
||||
e7d4bf646eb7e88807f9fa81ba014596f6f15386908887936dcd7c7f8db4233f 0006_add_addresses.py
|
||||
b125bdbf99b7f2239860a99258750941f6711a7082ae2391686f1a351abea18b 0007_currencies.py
|
||||
c15fa1c8883c27520624945cad88a052c7e1f35f524e8d5ebf9d9c7f46a9cba1 0008_tax_rates.py
|
||||
19da33700de8f512f4ed0b1761f525e66f2bc429620eff2ebea1533a5c1acbd3 0009_sequences.py
|
||||
10761f179cd5e51007ae5cf09ff72da5c31d2dd0f5b3f8a8b4a09c6d086f8c22 0010_system_settings.py
|
||||
90965449194517d7e9de4c4d9c81947632dcd0fdd392b545c775bcccf5f8b706 0011_attachments.py
|
||||
f60cc4ee0c2b5b1b963453d821910196422d488f94ddbaface7a5ebe8f998554 0012_soft_delete.py
|
||||
79d675096e1d546ea3bf2ccdb768ae0d50099c4cd10091796660ef0307326e0b 0013_addresses.py
|
||||
c327ac7e64becaecbb0d64639e65084ad79b7eddda3bdedc0c69b8db38749a2c 0014_currency_unique_fix.py
|
||||
bb764156af7ec85d3d157c85c7f4694296d124d1bddb8e9a92eb8afba7a3769a 0015_rls_policies.py
|
||||
a59265ece8e32886b447138d23203c2689dfe5a5bd3fcd06f853c027748f72e9 0016_plugin_is_core.py
|
||||
eef54bd0625d0d53463a22560cee2c18903bf83d72c377c948c7164e000570fa 0017_notification_preferences.py
|
||||
eb7789038fe80185e95c412a0011287fa8a1e15b96d0d858f2b59168eec2271e 0018_fix_notification_preferences_columns.py
|
||||
af2dbd9f06a2fa67c00417025088147463c58a5547ae90e80050c8adf972e0e5 0019_rbac_groups.py
|
||||
d6288d579085b64c688a01ed7e071705c0347f03d0af56de0c7e2554991496ae 0020_notifications_updated_at.py
|
||||
67f0f745af1f77b2db6e8f39c61e10d160b0c770a8eb0c748c342361c31bed87 0021_unified_contacts.py
|
||||
62f105366204bcb8bbfbb5537d3135725010873d1007323f0c8c4a10e1914f63 0022_contact_folders.py
|
||||
f6e266744c91465bc9cb5739e57bc69a575484b93dee49f7cecc5dc0d1faa746 0023_theme_customization.py
|
||||
56587cd59d6d7d39a5859c8707cdb0fc05b3dd5c34afc20caeb5391b89604afd 0024_heartbeat_config.py
|
||||
fe98eaa00e3de292ee23539399b62c847574d01743066b084a693d7ff22d84dd 0025_entity_history.py
|
||||
4ede1b730f8e00c8ad33d1f184b07fda333bfa55bab5ced2f35d05da2a4699e2 0026_mail_salt_security.py
|
||||
5fd05dbb6bc8a1f97d04f6dfff1491e002cea3a0fd1e6138f3a0a627ae8d7681 0027_unify_company_to_contact.py
|
||||
4f61886ec7649debc2a1d0ea65f35a8a13947c1faed14512712e28210644a20b 0028_rls_force.py
|
||||
92792e3fe5591a1de73605b1d1faefd7910fee41b4773757092fb8fcf6ebfca9 0028_user_preferences.py
|
||||
873484c820181b0190e8ca175eb16a6445eac399d614c7fdd81026c2ae88e399 0029_saved_filters.py
|
||||
d3b5fe559110b070cb642feb9801b48df600b5e11c469d4a6aa0fe04beddd4da 0030_contact_merge_history.py
|
||||
3ca8a3c626bead4e14da8ebf1adef5b34c21622662158ceb2db997256f8a240f 0031_permissions_soft_delete.py
|
||||
4f21f30045fa9b9798df26701bef88499d2f2f871727cffefd5f98ce7b344d91 0032_user_profile_fields.py
|
||||
e736f93427dd128b45007d351923af150c7093eec1f41e3dafb22900875084d1 0033_bank_accounts.py
|
||||
2eca394a15cb1bef34c4a3e3d60e58a9fdc46321715eefb74272e3079f94d516 0034_automation_config.py
|
||||
6f07d56fe2204ff181c61b16e71fa59f6270d6245045fd8ce5174570339b09d0 0035_comm_search_index.py
|
||||
c891187cbb5cee0281322855f4232134093e3ce26db20d142e29900c14a5b651 0036_cross_tenant_fk.py
|
||||
ac0239040a0f5695d4477dda2728297bfee15b0c090a13e91650d0c2a17922ba 0037_user_tenant_model.py
|
||||
19ecb258a0db97db3ecce0e21018a73602f680cdcdafc9203a778c256437fb29 0038_dms_content_hash.py
|
||||
a886a1c4b8c89fb1d244aef8559accfdc21209393bffd1c1d86ee6995bfb4d4b 0039_contact_normalize.py
|
||||
815899de164dc7b4418044ff8de3631449c7baec1c83b1f7ae683577becb185f 0040_outbox.py
|
||||
7af62a3ce31bcad2e5dbddae509194586b4f45f28b1fca47fd2365c9f288d695 0041_custom_field_definitions.py
|
||||
19ef4dfb877683bf794f7009e4cdb33a2674418a54d893a1120c742253e7eb3d 0042_webhooks.py
|
||||
cb04f579ad7fb1444446d6e06dcb5a5d9cb824d0fe71c46835d2243d92c2df8f 0043_backups.py
|
||||
0efd2a980f1e104b4cf7b3ea5ce4de776ca7d73a09d34834fd65a5de0c9a6b7e 0044_rls_repair_and_db_roles.py
|
||||
d1e8f1fd12237d8635918b89da34ef45c99af832b3f372e0bde876ca8314639d 0045_repair_contact_migration.py
|
||||
07fc01641d4dc30881f664e9c795466adaff864dc72d377ff1f6b6b7b5ba0b1c 0046_plugin_allowlist.py
|
||||
afc8c9f2b1392882cd41d8b28a98640167a162cd210beeb1bd64df5b649b6500 0047_saved_views.py
|
||||
4f3daeec7ae3a5ba3a40c4329d5e1664d29539608b13f101d8914b00a69cbb48 0048_contact_folder_permissions.py
|
||||
b352752857101f46779c0d9232a793af79f3850121fe9cc77c27fb08fc14e29a 0049_entity_permissions.py
|
||||
831551810e0ba27f186123c2e8113722a4ed664fdc5ffd014a1efd139f4c9bdf 0050_owner_id_all_tables.py
|
||||
17867264f7631016349293c1a38114446d4261516e8ed0e1bf181a105a828217 0051_migrate_folder_acls.py
|
||||
ee73eba6e99341380b8129da620f6a2d309af1d3ed8e300b11ee7740d1208b33 0052_rls_contacts.py
|
||||
49a0c541bdbd4b1a0e92e1487d502d8f330776aec60ce022b349ce6462fefd0e 0053_mail_owner_id.py
|
||||
1a4285967290c358130bac536ec9d0a40bca370639c4cac53b295e217ee7082b 0054_plugin_owner_id.py
|
||||
27ce5c11c3fb0c0b69b87f4499f7eae936f3035f3eca4696de9daef94610c219 0055_entity_policies.py
|
||||
690dd996dc2bf44777ed0d7ecb717d1af0a641aa58294f9e7092e2d94a9a3f16 0056_permission_templates.py
|
||||
b5389ab783714d9f391484b7dd1437088de06fe8b8dd753090f755ed62e61fb4 0057_permission_delegations.py
|
||||
0bdf3a15a532c0934c73c36a15a5367c4f69d92138e0155c255b8cde64f4a795 0058_resolution_strategy.py
|
||||
bb87f8836425f097c7d70e736896e9f6fd68c3e8ea80756065e74e45ebc77162 0059_guest_users.py
|
||||
240957a7bdc90bac008d8af3ffbc1c4205c0aa582fff6b89861655631c4670fa 0060_rls_contacts_secure.py
|
||||
f020ea4b687a148663c8da4188503e55ba3c5d2072408590767f5984512b9287 0061_db_roles_secure.py
|
||||
ad6876b5e15b44547cd91bebb54e977f985decc4b25e9c8c63cd9b1f000ae0a7 0062_guest_invitations_secure.py
|
||||
78db5dea0a068749b0e86c157d1fa92068e023d9605b32eec26fffe477a78e64 0063_notification_entity_fields.py
|
||||
c2a1669e0afa8f30bc1c2696fe2a20541507a515266f1f8d3416fd7daafaabe2 0064_rls_all_tenant_tables.py
|
||||
eafe25abb7cd7a493d590ae04a15326c8c4aa6ee22693f1599c72ebdf859b847 0065_consumer_inbox.py
|
||||
c69e5d22853555b79b2fc4632308a0520ddb6639f61fa1c39d912fce175d1ca2 0066_tenant_plugin_activation.py
|
||||
790fd62ee1523633720963802287bf31c607f0fcd2b8ec2a3d6dd1eb4e0951bb 0067_disable_rls_system_tables.py
|
||||
c9b22694060fa92a725c79c781988ff66b326301090c290062af7226dcbf84f2 0068_entity_permissions_deleted_at.py
|
||||
6e269eab56fa261bed460bedcf9fcb1dba55bfb36918cedd8adda36b6bddc20a 0069_rls_tenant_isolation_only.py
|
||||
4d93eb1c7d26d51a4f411041a6979c7f5dcaaa411d7bba23cc37aa27fa045374 0070_db_roles_separation.py
|
||||
1d750493a9d5d224952308c8903a6b86f6ca5dfe74e11a270888edea0d873005 0071_entity_attachments.py
|
||||
fce10ad1f18c0a383d1c4ab60d403f14298d8cb644c7e0637a2e56f349bbb4cb 0072_workspaces.py
|
||||
4a2409f12241c129f1e0a28219be9d2f6801a6d9a6b5d8671be376a9f7d0a622 0073_workspace_deleted_at.py
|
||||
a6256de26d248323e4f68d9b035fb42349aac98458dd15dec1597e2223e71e27 0074_workspace_users_timestamps.py
|
||||
5c48afc9032acdcb05cdd89fb650116dacac1662c7bf2605c28596b7d14d31d4 0075_outbox_envelope.py
|
||||
48558039eee96b6d4b0f687d5231ce7643460e64f5803112d3c330af654c3c7b 0076_disable_rls_startup_tables.py
|
||||
d15e524e257a738beb955ab891db35492089aaded7033f1e3d5d82f739cefe25 0077_disable_rls_tax_rates.py
|
||||
5e102c1ff963b5ddbefa96515a114ffa5bec25e9e41f53a555f743af06e2d24e 0078_disable_rls_automation.py
|
||||
2e72ed88053416b8525205ab0c71d416a4caed32ac475d3c539541b86e5ab683 0079_disable_rls_system_tables.py
|
||||
099b0259a865a8b9aff6c6af40c9481a813ed30d6cf9e061a054e85545e6ca75 0080_disable_rls_audit_sessions.py
|
||||
ba5b221f7ce0271a1b531eb441d2f0afe7b3d53bd44e602b8e839a3806059bfb 0081_disable_rls_all_system_tables.py
|
||||
1705c1788ea57085c2ffe99d985e077ffa2e2e45482a5b6af162a76dcbeda34c 0082_add_sensitivity_to_custom_field_definitions.py
|
||||
f8409a0e4952703b5a1a1ba064f8622071f12c657ad4e8ff1a09c2020d768762 0083_add_missing_deleted_at_columns.py
|
||||
d2bdad015bdf16f6c911f58a08103b1814f0f6d987b4ecd290732ee7a185a843 0084_rls_fail_closed_reactivate.py
|
||||
b66e11bbcb52d7cfde518cde523a4d8808ddb4a62cc3b8c39aec3c58b95abe19 0085_restore_tenant_rls.py
|
||||
b184eab067c0dfaa66712bd74471b4c65715e90a07521b17577ed15bac707259 0086_fix_global_tables_force_rls.py
|
||||
f0f33e314b52a849f1bad06cfa9ffb5da07890764bc8d22dcd43237293ed90db 0087_add_timestamps_to_password_reset_tokens.py
|
||||
38e3f4454e079faed2e6fc78cec632d6f78189c46750a7668a9c9c1a845f2bd4 0088_auth_rls_policies.py
|
||||
2e279fe7afd72b2093695249e16bdf7bf3be400935099fe21f3c4c3aa87059ba 0089_sessions_updated_at.py
|
||||
d7cabfb4c3d4665bd12aded82dc0727a55705bf9124c7e0b11574929dc806ab2 0090_fix_legacy_tenant_policies.py
|
||||
94d48243191c7fee0c2106afc9e4809fbc8ef3a38786b0e0582f2cce488a219d 0091_add_tenant_fk_constraints.py
|
||||
53d4c6e01d59da4fbf9785de05237d2656473a5c5fcccb08edf79be8284db4c4 0092_outbox_dlq.py
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Restore tenant RLS, transfer ownership, fix roles and grants.
|
||||
|
||||
This migration implements Phase 1 of the Sanierungsplan:
|
||||
This migration implements Phase 1 of the security hardening:
|
||||
|
||||
1. Transfer ALL table ownership from crm_user (SUPERUSER) to crm_migration (NOSUPERUSER, NOBYPASSRLS)
|
||||
2. ALTER ROLE crm_migration NOBYPASSRLS
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Fix files.size_bytes type: INTEGER → BIGINT.
|
||||
|
||||
The DMS plugin migration (0001_initial.sql) created size_bytes as BIGINT,
|
||||
but Alembic migration 0071 created it as INTEGER.
|
||||
Production already has BIGINT (from plugin migration).
|
||||
This migration aligns Alembic with production.
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0093"
|
||||
down_revision = "0092"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Align size_bytes with production (BIGINT)
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS files "
|
||||
"ALTER COLUMN size_bytes TYPE BIGINT"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS files "
|
||||
"ALTER COLUMN size_bytes TYPE INTEGER"
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Fix GIN indexes and remove duplicate plugins.name index.
|
||||
|
||||
Alembic 0002 created search indexes without USING GIN.
|
||||
Production already has GIN indexes (corrected by later migrations or manual).
|
||||
This migration ensures GIN indexes exist for both fresh install and existing DBs.
|
||||
|
||||
Also removes the redundant ix_plugins_name unique index (plugins_name_key
|
||||
already enforces uniqueness from the column definition).
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0094"
|
||||
down_revision = "0093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# GIN indexes that should exist with USING GIN
|
||||
GIN_INDEXES = [
|
||||
("contacts", "ix_contacts_search_tsv", "search_tsv"),
|
||||
("audit_log", "ix_audit_log_search_tsv", "search_tsv"),
|
||||
("calendar_entries", "ix_cal_entries_search_tsv", "search_tsv"),
|
||||
("comm_messages", "ix_comm_messages_search_tsv", "search_tsv"),
|
||||
("files", "ix_files_content_tsv", "content_tsv"),
|
||||
("mails", "ix_mails_body_tsv", "body_tsv"),
|
||||
("tags", "ix_tags_search_tsv", "search_tsv"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Fix GIN indexes: drop and recreate with USING GIN (idempotent)
|
||||
# Only create index if the column exists (avoids failure on fresh DB)
|
||||
for table, index_name, column in GIN_INDEXES:
|
||||
op.execute(f"DROP INDEX IF EXISTS {index_name}")
|
||||
# Check if column exists before creating index
|
||||
op.execute(
|
||||
"DO $do$ BEGIN "
|
||||
"IF EXISTS (SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = '{column}') THEN "
|
||||
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||
f"ON {table} USING gin ({column}); "
|
||||
"END IF; "
|
||||
"END $do$;"
|
||||
)
|
||||
|
||||
# Remove redundant plugins.name index (plugins_name_key already enforces uniqueness)
|
||||
op.execute("DROP INDEX IF EXISTS ix_plugins_name")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the dropped index without GIN (not truly reversible to wrong state)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_plugins_name ON plugins (name)"
|
||||
)
|
||||
# GIN indexes cannot be meaningfully downgraded to non-GIN
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Fix guest_users email+tenant_id unique index.
|
||||
|
||||
Alembic 0059 created ix_guest_users_email_tenant as a normal (non-unique) index.
|
||||
The SQLAlchemy model defines it as unique=True, and production already has
|
||||
a UNIQUE INDEX. This migration aligns Alembic with production.
|
||||
|
||||
Before creating the unique index, checks for duplicate (email, tenant_id) pairs.
|
||||
If duplicates exist, the migration aborts with a data cleanup report.
|
||||
|
||||
Revision ID: 0095
|
||||
Revises: 0094
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0095"
|
||||
down_revision = "0094"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check for duplicates before creating unique index
|
||||
conn = op.get_bind()
|
||||
duplicates = conn.execute(
|
||||
sa.text(
|
||||
"SELECT email, tenant_id, count(*) FROM guest_users "
|
||||
"GROUP BY email, tenant_id HAVING count(*) > 1"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if duplicates:
|
||||
raise RuntimeError(
|
||||
f"Cannot create unique index: {len(duplicates)} duplicate (email, tenant_id) pairs found. "
|
||||
"Data cleanup required before migration."
|
||||
)
|
||||
|
||||
# Drop the non-unique index and recreate as unique
|
||||
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||
"ON guest_users (email, tenant_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||
"ON guest_users (email, tenant_id)"
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Workspace tenant integrity constraints.
|
||||
|
||||
Plan 4.3: Add tenant-bound foreign keys to workspace child tables.
|
||||
|
||||
- workspaces: UNIQUE (tenant_id, id)
|
||||
- workspace_modules: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_widgets: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_users: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_users: FK (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||
|
||||
Revision ID: 0096
|
||||
Revises: 0095
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0096"
|
||||
down_revision = "0095"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add UNIQUE (tenant_id, id) on workspaces
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_workspaces_tenant_id "
|
||||
"ON workspaces (tenant_id, id)"
|
||||
)
|
||||
|
||||
# 2. Drop existing FKs on workspace_modules (workspace_id → workspaces.id)
|
||||
# and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS workspace_modules_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_modules "
|
||||
"ADD CONSTRAINT fk_wm_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 3. Drop existing FK on workspace_widgets and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS workspace_widgets_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_widgets "
|
||||
"ADD CONSTRAINT fk_ww_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 4. Drop existing FK on workspace_users and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS workspace_users_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT fk_wu_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 5. Add FK on workspace_users (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT fk_wu_tenant_user "
|
||||
"FOREIGN KEY (tenant_id, user_id) "
|
||||
"REFERENCES user_tenants (tenant_id, user_id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove tenant-bound FKs, restore simple FKs
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_user")
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT workspace_users_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS fk_ww_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_widgets "
|
||||
"ADD CONSTRAINT workspace_widgets_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS fk_wm_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_modules "
|
||||
"ADD CONSTRAINT workspace_modules_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS uq_workspaces_tenant_id")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Fix api_tokens table: add updated_at column.
|
||||
|
||||
The ApiToken model inherits from TenantMixin which includes TimestampMixin
|
||||
(created_at, updated_at). Migration 0001 created api_tokens without updated_at.
|
||||
Migration 0083 added deleted_at but missed updated_at.
|
||||
|
||||
Revision ID: 0097
|
||||
Revises: 0096
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0097"
|
||||
down_revision = "0096"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS api_tokens "
|
||||
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE IF EXISTS api_tokens DROP COLUMN IF EXISTS updated_at")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add tenant-local deduplication index on files.
|
||||
|
||||
Plan 6.6: Partial unique index on (tenant_id, content_hash)
|
||||
WHERE content_hash IS NOT NULL AND deleted_at IS NULL.
|
||||
|
||||
Before creating the unique index, checks for existing duplicates.
|
||||
|
||||
Revision ID: 0098
|
||||
Revises: 0097
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0098"
|
||||
down_revision = "0097"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check for duplicates before creating unique index
|
||||
conn = op.get_bind()
|
||||
duplicates = conn.execute(
|
||||
sa.text(
|
||||
"SELECT tenant_id, content_hash, count(*) FROM files "
|
||||
"WHERE content_hash IS NOT NULL AND deleted_at IS NULL "
|
||||
"GROUP BY tenant_id, content_hash HAVING count(*) > 1"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if duplicates:
|
||||
raise RuntimeError(
|
||||
f"Cannot create unique index: {len(duplicates)} duplicate (tenant_id, content_hash) pairs found. "
|
||||
"Data cleanup required before migration."
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_files_tenant_content_hash "
|
||||
"ON files (tenant_id, content_hash) "
|
||||
"WHERE content_hash IS NOT NULL AND deleted_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_files_tenant_content_hash")
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Add created_at indexes for performance on large tables.
|
||||
|
||||
Order by created_at DESC is the slowest query at 68ms with 100k rows.
|
||||
This migration adds indexes on created_at for all tenant-scoped tables
|
||||
that are commonly sorted by created_at.
|
||||
|
||||
Revision ID: 0099
|
||||
Revises: 0098
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0099"
|
||||
down_revision = "0098"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Tables that are commonly sorted by created_at DESC
|
||||
TABLES = [
|
||||
"contacts",
|
||||
"audit_log",
|
||||
"calendar_entries",
|
||||
"comm_messages",
|
||||
"files",
|
||||
"mails",
|
||||
"tasks",
|
||||
"automation_runs",
|
||||
"ai_chat_messages",
|
||||
"ai_chat_sessions",
|
||||
"entity_history",
|
||||
"notifications",
|
||||
"event_outbox",
|
||||
"outbox_deliveries",
|
||||
"entity_attachments",
|
||||
"contact_merge_history",
|
||||
"webhooks",
|
||||
"tags",
|
||||
"contact_folder_permissions",
|
||||
"entity_permissions",
|
||||
"entity_policies",
|
||||
"guest_users",
|
||||
"guest_invitations",
|
||||
"user_groups",
|
||||
"saved_filters",
|
||||
"saved_views",
|
||||
"workspaces",
|
||||
"workspace_modules",
|
||||
"workspace_users",
|
||||
"workspace_widgets",
|
||||
"api_tokens",
|
||||
"sessions",
|
||||
"password_reset_tokens",
|
||||
"custom_field_definitions",
|
||||
"system_settings",
|
||||
"plugin_migrations",
|
||||
"tenant_plugin_activation",
|
||||
"plugin_allowlist",
|
||||
"permissions",
|
||||
"permission_templates",
|
||||
"permission_delegations",
|
||||
"currencies",
|
||||
"tax_rates",
|
||||
"sequences",
|
||||
"addresses",
|
||||
"bank_accounts",
|
||||
"contactpersons",
|
||||
"contact_folders",
|
||||
"user_preferences",
|
||||
"deletion_log",
|
||||
"backups",
|
||||
"consumer_inbox",
|
||||
"folders",
|
||||
"calendars",
|
||||
"calendar_entry_links",
|
||||
"calendar_shares",
|
||||
"user_calendar_visibility",
|
||||
"subtasks",
|
||||
"resources",
|
||||
"resource_bookings",
|
||||
"entity_links",
|
||||
"forgejo_reported_errors",
|
||||
"comm_conversations",
|
||||
"comm_participants",
|
||||
"comm_message_blocks",
|
||||
"comm_message_attachments",
|
||||
"comm_message_reactions",
|
||||
"comm_message_reads",
|
||||
"comm_conversation_pins",
|
||||
"comm_conversation_mutes",
|
||||
"mail_accounts",
|
||||
"mail_folders",
|
||||
"mail_labels",
|
||||
"mail_label_assignments",
|
||||
"mail_attachments",
|
||||
"mail_signatures",
|
||||
"mail_templates",
|
||||
"mail_rules",
|
||||
"mail_sync_queue",
|
||||
"mail_seen_by",
|
||||
"mail_account_delegates",
|
||||
"mail_account_send_permissions",
|
||||
"pgp_keys",
|
||||
"contact_pgp_keys",
|
||||
"ai_providers",
|
||||
"ai_models",
|
||||
"ai_presets",
|
||||
"ai_agents",
|
||||
"ai_chat_folders",
|
||||
"ai_chat_attachments",
|
||||
"ai_proactive_suggestions",
|
||||
"ai_proactive_context_log",
|
||||
"ai_proactive_settings",
|
||||
"automation_agent_definitions",
|
||||
"automation_agent_versions",
|
||||
"automation_definitions",
|
||||
"automation_versions",
|
||||
"automation_cron_jobs",
|
||||
"automation_agent_runs",
|
||||
"unified_search_index_log",
|
||||
"unified_search_providers",
|
||||
"report_templates",
|
||||
"report_instances",
|
||||
"mcp_server_configs",
|
||||
"share_links",
|
||||
"tag_assignments",
|
||||
"plugin_test_data",
|
||||
"vacation_sent_log",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add created_at index only if the column exists
|
||||
for table in TABLES:
|
||||
op.execute(
|
||||
"DO $do$ BEGIN "
|
||||
"IF EXISTS (SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = 'created_at') THEN "
|
||||
f"CREATE INDEX IF NOT EXISTS ix_{table}_created_at "
|
||||
f"ON {table} (created_at DESC); "
|
||||
"END IF; "
|
||||
"END $do$;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES:
|
||||
op.execute(f"DROP INDEX IF EXISTS ix_{table}_created_at")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Restrict DELETE grants on sensitive tables.
|
||||
|
||||
Removes DELETE privilege from crm_api and crm_worker on:
|
||||
api_tokens, audit_log, notification_types, password_reset_tokens,
|
||||
plugin_allowlist, plugin_migrations, plugins, sessions,
|
||||
tenant_plugin_activation, tenants, user_tenants, users.
|
||||
|
||||
crm_auth keeps DELETE on sessions + password_reset_tokens (for logout/reset).
|
||||
|
||||
Revision ID: 0100
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0100"
|
||||
down_revision = "0099"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Tables where DELETE must be removed from crm_api and crm_worker
|
||||
SENSITIVE_TABLES = [
|
||||
"api_tokens",
|
||||
"audit_log",
|
||||
"notification_types",
|
||||
"password_reset_tokens",
|
||||
"plugin_allowlist",
|
||||
"plugin_migrations",
|
||||
"plugins",
|
||||
"sessions",
|
||||
"tenant_plugin_activation",
|
||||
"tenants",
|
||||
"user_tenants",
|
||||
"users",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in SENSITIVE_TABLES:
|
||||
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;")
|
||||
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_worker;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in SENSITIVE_TABLES:
|
||||
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_api;")
|
||||
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_worker;")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Fix RLS on auth tables — disable RLS that was accidentally enabled.
|
||||
|
||||
This migration ONLY disables RLS on auth tables and does NOT enable
|
||||
new RLS. RLS on other tables will be added in a later migration
|
||||
after the app is confirmed working.
|
||||
|
||||
Revision ID: 0101
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0101"
|
||||
down_revision = "0100"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Disable RLS on auth tables (safety measure — these tables must not have RLS)
|
||||
op.execute("ALTER TABLE IF EXISTS password_reset_tokens DISABLE ROW LEVEL SECURITY;")
|
||||
op.execute("ALTER TABLE IF EXISTS sessions DISABLE ROW LEVEL SECURITY;")
|
||||
op.execute("DROP POLICY IF EXISTS password_reset_tokens_tenant_isolation ON password_reset_tokens;")
|
||||
op.execute("DROP POLICY IF EXISTS sessions_tenant_isolation ON sessions;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Add owner_id column to Phase 2 tables for row-level ownership.
|
||||
|
||||
Adds nullable owner_id (FK -> users.id, ON DELETE SET NULL) to tables
|
||||
that gained OwnedMixin in Phase 2. For tables that already have a
|
||||
non-nullable user_id column, owner_id is backfilled from user_id.
|
||||
|
||||
Plugin tables may not exist yet at migration time (created by plugin
|
||||
migrations separately), so we check existence before adding columns.
|
||||
|
||||
Revision ID: 0102
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
revision = "0102"
|
||||
down_revision = "0101"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# (table_name, has_user_id_to_backfill)
|
||||
TABLES = [
|
||||
# Core models (always exist at this migration point)
|
||||
("contact_folders", True),
|
||||
("user_preferences", True),
|
||||
("workspaces", False),
|
||||
# Plugin models (may not exist yet — created by plugin migrations)
|
||||
("mcp_server_configs", False),
|
||||
("automation_agent_definitions", False),
|
||||
("automation_definitions", False),
|
||||
("report_templates", False),
|
||||
("report_instances", False),
|
||||
("entity_links", False),
|
||||
("comm_conversations", False),
|
||||
("ai_proactive_suggestions", True),
|
||||
("ai_agents", False),
|
||||
("ai_chat_sessions", True),
|
||||
("tags", False),
|
||||
("share_links", True),
|
||||
]
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)"),
|
||||
{"name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table_name, has_user_id in TABLES:
|
||||
if not _table_exists(conn, table_name):
|
||||
print(f"[0102] Skipping {table_name} — table does not exist yet")
|
||||
continue
|
||||
# Check if owner_id column already exists
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :name AND column_name = 'owner_id')"
|
||||
),
|
||||
{"name": table_name},
|
||||
).scalar()
|
||||
if col_exists:
|
||||
print(f"[0102] Skipping {table_name} — owner_id already exists")
|
||||
continue
|
||||
op.add_column(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"owner_id",
|
||||
PGUUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
f"ix_{table_name}_owner_id",
|
||||
table_name,
|
||||
["owner_id"],
|
||||
)
|
||||
if has_user_id:
|
||||
op.execute(
|
||||
f"UPDATE {table_name} SET owner_id = user_id WHERE owner_id IS NULL;"
|
||||
)
|
||||
print(f"[0102] Added owner_id to {table_name} (backfilled from user_id)")
|
||||
else:
|
||||
print(f"[0102] Added owner_id to {table_name}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table_name, _ in TABLES:
|
||||
if not _table_exists(conn, table_name):
|
||||
continue
|
||||
op.drop_index(f"ix_{table_name}_owner_id", table_name=table_name)
|
||||
op.drop_column(table_name, "owner_id")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Add tenant_id FK CASCADE to remaining tables not covered by migration 0091.
|
||||
|
||||
Tables missing from migration 0091:
|
||||
- contact_merge_history (has tenant_id from TenantMixin but no FK)
|
||||
- tenant_plugin_activation (plugin table, may not exist yet)
|
||||
- user_groups (has FK already but verify CASCADE)
|
||||
- guest_users (has FK already but verify CASCADE)
|
||||
- user_tenants (has FK already but verify CASCADE)
|
||||
|
||||
Also adds FK to calendar_entry_links.tenant_id if not already present
|
||||
(migration 0091 includes it but the model definition lacks the FK).
|
||||
|
||||
Revision ID: 0103
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0103"
|
||||
down_revision = "0102"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Tables that need tenant_id FK with CASCADE but were not in migration 0091
|
||||
TABLES_NEEDING_FK = [
|
||||
"contact_merge_history",
|
||||
"tenant_plugin_activation",
|
||||
]
|
||||
|
||||
# Tables that should already have FK but we verify CASCADE is set
|
||||
TABLES_VERIFY_CASCADE = [
|
||||
"user_groups",
|
||||
"guest_users",
|
||||
"user_tenants",
|
||||
]
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)"),
|
||||
{"name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def _fk_exists(conn, table_name: str, constraint_name: str) -> bool:
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = :name AND constraint_type = 'FOREIGN KEY')"
|
||||
),
|
||||
{"name": constraint_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Add FK CASCADE to tables that are missing it
|
||||
for table_name in TABLES_NEEDING_FK:
|
||||
if not _table_exists(conn, table_name):
|
||||
print(f"[0103] Skipping {table_name} — table does not exist")
|
||||
continue
|
||||
constraint_name = f"fk_{table_name}_tenant_id"
|
||||
if _fk_exists(conn, table_name, constraint_name):
|
||||
print(f"[0103] Skipping {table_name} — FK already exists")
|
||||
continue
|
||||
# Check if tenant_id column exists
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :name AND column_name = 'tenant_id')"
|
||||
),
|
||||
{"name": table_name},
|
||||
).scalar()
|
||||
if not col_exists:
|
||||
print(f"[0103] Skipping {table_name} — no tenant_id column")
|
||||
continue
|
||||
op.execute(
|
||||
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||
)
|
||||
print(f"[0103] Added FK CASCADE to {table_name}")
|
||||
|
||||
# Verify CASCADE on existing FKs (drop and recreate if missing CASCADE)
|
||||
for table_name in TABLES_VERIFY_CASCADE:
|
||||
if not _table_exists(conn, table_name):
|
||||
continue
|
||||
constraint_name = f"fk_{table_name}_tenant_id"
|
||||
if not _fk_exists(conn, table_name, constraint_name):
|
||||
# Check if any FK exists on tenant_id
|
||||
existing_fk = conn.execute(
|
||||
sa.text(
|
||||
"SELECT conname FROM pg_constraint con "
|
||||
"JOIN pg_class cls ON con.conrelid = cls.oid "
|
||||
"WHERE cls.relname = :table AND con.contype = 'f' "
|
||||
"AND EXISTS (SELECT 1 FROM pg_attribute att "
|
||||
"WHERE att.attrelid = con.conrelid AND att.attname = 'tenant_id' "
|
||||
"AND att.attnum = ANY(con.conkey))"
|
||||
),
|
||||
{"table": table_name},
|
||||
).scalar_one_or_none()
|
||||
if existing_fk:
|
||||
# Drop existing FK and recreate with CASCADE
|
||||
op.execute(f"ALTER TABLE {table_name} DROP CONSTRAINT {existing_fk};")
|
||||
op.execute(
|
||||
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||
)
|
||||
print(f"[0103] Replaced FK on {table_name} with CASCADE (was: {existing_fk})")
|
||||
else:
|
||||
op.execute(
|
||||
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||
)
|
||||
print(f"[0103] Added FK CASCADE to {table_name}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table_name in TABLES_NEEDING_FK + TABLES_VERIFY_CASCADE:
|
||||
if not _table_exists(conn, table_name):
|
||||
continue
|
||||
constraint_name = f"fk_{table_name}_tenant_id"
|
||||
if _fk_exists(conn, table_name, constraint_name):
|
||||
op.execute(f"ALTER TABLE {table_name} DROP CONSTRAINT {constraint_name};")
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Add embedding column to contacts + timestamp columns to audit_log.
|
||||
|
||||
Fixes two issues found by API integration tests:
|
||||
1. contacts.embedding (vector(768)) — ORM model was updated in Phase 5.3 but
|
||||
the plugin migration 0002_embeddings.sql was never run as an Alembic migration.
|
||||
2. audit_log.created_at, updated_at, deleted_at — AuditLog model inherits TenantMixin
|
||||
which expects these columns, but they were never added to the DB table.
|
||||
|
||||
Revision ID: 0104
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0104"
|
||||
down_revision = "0103"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 0. Ensure pgvector extension is installed
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
# 1. Add embedding column to contacts (if not exists)
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'contacts' AND column_name = 'embedding'"
|
||||
))
|
||||
if result.fetchone() is None:
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN embedding vector(768)")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_contacts_embedding "
|
||||
"ON contacts USING hnsw(embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
# 2. Add embedding columns to other tables (from plugin migration 0002)
|
||||
for table in ["mails", "files", "calendar_entries"]:
|
||||
result = conn.execute(sa.text(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = 'embedding'"
|
||||
))
|
||||
if result.fetchone() is None:
|
||||
# Check if table exists
|
||||
table_exists = conn.execute(sa.text(
|
||||
f"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = '{table}')"
|
||||
)).scalar()
|
||||
if table_exists:
|
||||
op.execute(f"ALTER TABLE {table} ADD COLUMN embedding vector(768)")
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS ix_{table}_embedding "
|
||||
f"ON {table} USING hnsw(embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
# Tags use 384-dim embeddings
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'tags' AND column_name = 'embedding'"
|
||||
))
|
||||
if result.fetchone() is None:
|
||||
table_exists = conn.execute(sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tags')"
|
||||
)).scalar()
|
||||
if table_exists:
|
||||
op.execute("ALTER TABLE tags ADD COLUMN embedding vector(384)")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tags_embedding "
|
||||
"ON tags USING hnsw(embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
# 3. Add timestamp columns to audit_log (if not exists)
|
||||
for col in ["created_at", "updated_at", "deleted_at"]:
|
||||
result = conn.execute(sa.text(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_name = 'audit_log' AND column_name = '{col}'"
|
||||
))
|
||||
if result.fetchone() is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE audit_log ADD COLUMN {col} "
|
||||
f"TIMESTAMPTZ DEFAULT NOW()"
|
||||
)
|
||||
|
||||
# 4. Add timestamp columns to deletion_log (if not exists)
|
||||
for col in ["created_at", "updated_at", "deleted_at"]:
|
||||
result = conn.execute(sa.text(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_name = 'deletion_log' AND column_name = '{col}'"
|
||||
))
|
||||
if result.fetchone() is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE deletion_log ADD COLUMN {col} "
|
||||
f"TIMESTAMPTZ DEFAULT NOW()"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop embedding columns
|
||||
for table in ["contacts", "mails", "companies", "files", "calendar_entries"]:
|
||||
op.execute(f"DROP INDEX IF EXISTS ix_{table}_embedding")
|
||||
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS embedding")
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_tags_embedding")
|
||||
op.execute("ALTER TABLE tags DROP COLUMN IF EXISTS embedding")
|
||||
|
||||
# Drop timestamp columns from audit_log
|
||||
for col in ["created_at", "updated_at", "deleted_at"]:
|
||||
op.execute(f"ALTER TABLE audit_log DROP COLUMN IF EXISTS {col}")
|
||||
|
||||
# Drop timestamp columns from deletion_log
|
||||
for col in ["created_at", "updated_at", "deleted_at"]:
|
||||
op.execute(f"ALTER TABLE deletion_log DROP COLUMN IF EXISTS {col}")
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Fix tags.owner_id missing column and contacts_tsv_trigger column mismatch.
|
||||
|
||||
Bug 1: tags.owner_id — Migration 0102 tried to add owner_id to tags but only
|
||||
if the table existed at that point. If the tags table was created later (by
|
||||
plugin migration), owner_id was never added. This migration ensures owner_id
|
||||
exists on the tags table.
|
||||
|
||||
Bug 2: contacts_tsv_trigger — The unified_search plugin migration 0001 created
|
||||
a trigger function referencing NEW.first_name, NEW.last_name, NEW.email,
|
||||
NEW.phone, NEW.mobile, NEW.notes. After migration 0021 unified contacts,
|
||||
the columns are named firstname, surname, email_1, phone_1, phone_2,
|
||||
projectnote. The trigger must be recreated with correct column names.
|
||||
|
||||
Revision ID: 0105
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
revision = "0105"
|
||||
down_revision = "0104"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_name = :name)"
|
||||
),
|
||||
{"name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :col)"
|
||||
),
|
||||
{"table": table_name, "col": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── Bug 1: Add owner_id to tags table if missing ──
|
||||
if _table_exists(conn, "tags"):
|
||||
if not _column_exists(conn, "tags", "owner_id"):
|
||||
op.add_column(
|
||||
"tags",
|
||||
sa.Column(
|
||||
"owner_id",
|
||||
PGUUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tags_owner_id",
|
||||
"tags",
|
||||
["owner_id"],
|
||||
)
|
||||
print("[0105] Added owner_id to tags table")
|
||||
else:
|
||||
print("[0105] tags.owner_id already exists — skipping")
|
||||
else:
|
||||
print("[0105] tags table does not exist — skipping")
|
||||
|
||||
# ── Bug 2: Recreate contacts_tsv_trigger with correct column names ──
|
||||
if _table_exists(conn, "contacts"):
|
||||
# Drop old trigger and function
|
||||
op.execute("DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts")
|
||||
op.execute("DROP FUNCTION IF EXISTS contacts_tsv_trigger()")
|
||||
|
||||
# Recreate trigger function with current column names
|
||||
# Contacts table after migration 0021 uses: firstname, surname, email_1,
|
||||
# email_2, phone_1, phone_2, name, displayname, code, mailing_city,
|
||||
# mailing_postalcode, tags, projectnote
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION contacts_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german',
|
||||
coalesce(NEW.name, '') || ' ' || coalesce(NEW.displayname, '') ||
|
||||
' ' || coalesce(NEW.firstname, '') || ' ' || coalesce(NEW.surname, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german',
|
||||
coalesce(NEW.email_1, '') || ' ' || coalesce(NEW.email_2, '')), 'B') ||
|
||||
setweight(to_tsvector('pg_catalog.german',
|
||||
coalesce(NEW.phone_1, '') || ' ' || coalesce(NEW.phone_2, '')), 'C') ||
|
||||
setweight(to_tsvector('pg_catalog.german',
|
||||
coalesce(NEW.code, '') || ' ' || coalesce(NEW.mailing_city, '') ||
|
||||
' ' || coalesce(NEW.mailing_postalcode, '') || ' ' || coalesce(NEW.tags, '') ||
|
||||
' ' || coalesce(NEW.projectnote, '')), 'D');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
|
||||
# Recreate trigger
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER contacts_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION contacts_tsv_trigger();
|
||||
"""
|
||||
)
|
||||
print("[0105] Recreated contacts_tsv_trigger with correct column names")
|
||||
else:
|
||||
print("[0105] contacts table does not exist — skipping trigger fix")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Restore old trigger function (with incorrect column names for rollback)
|
||||
if _table_exists(conn, "contacts"):
|
||||
op.execute("DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts")
|
||||
op.execute("DROP FUNCTION IF EXISTS contacts_tsv_trigger()")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION contacts_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.phone, '') || ' ' || coalesce(NEW.mobile, '')), 'C') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.notes, '')), 'D');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER contacts_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION contacts_tsv_trigger();
|
||||
"""
|
||||
)
|
||||
|
||||
# Remove owner_id from tags
|
||||
if _table_exists(conn, "tags") and _column_exists(conn, "tags", "owner_id"):
|
||||
op.drop_index("ix_tags_owner_id", table_name="tags")
|
||||
op.drop_column("tags", "owner_id")
|
||||
""
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Grant DELETE permission on notification_types to app DB roles.
|
||||
|
||||
The unified_search plugin activation calls sync_notification_types() which
|
||||
DELETEs stale rows from notification_types. The app DB user (crm_api) lacks
|
||||
DELETE permission on this table, causing plugin activation to fail with
|
||||
InsufficientPrivilegeError.
|
||||
|
||||
Revision ID: 0106
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0106"
|
||||
down_revision = "0105"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Grant all necessary permissions on notification_types to app roles
|
||||
for role in ["crm_api", "crm_auth", "crm_worker"]:
|
||||
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON notification_types TO {role}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for role in ["crm_api", "crm_auth", "crm_worker"]:
|
||||
op.execute(f"REVOKE DELETE ON notification_types FROM {role}")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Widen notification_types.type_key from VARCHAR(20) to VARCHAR(100).
|
||||
|
||||
The unified_search plugin declares 'search_reindex_complete' (22 chars) as a
|
||||
notification type key, which exceeds the VARCHAR(20) limit and causes
|
||||
StringDataRightTruncationError during plugin activation (sync_notification_types).
|
||||
This breaks ALL plugin activations since sync runs for all active plugins.
|
||||
|
||||
Revision ID: 0107
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0107"
|
||||
down_revision = "0106"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"notification_types",
|
||||
"type_key",
|
||||
existing_type=sa.String(20),
|
||||
type=sa.String(100),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"notification_types",
|
||||
"type_key",
|
||||
existing_type=sa.String(100),
|
||||
type=sa.String(20),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Enable RLS for all tenant tables that were added after migration 0085.
|
||||
|
||||
Migration 0085 activated Row Level Security only for tables that existed at the
|
||||
time it ran. Plugin tables and other tables created by later migrations were
|
||||
not covered, leaving ~84 tables with a ``tenant_id`` column but without RLS.
|
||||
|
||||
This migration dynamically discovers every table in the ``public`` schema that
|
||||
has a ``tenant_id`` column but does **not** yet have RLS enabled, then:
|
||||
|
||||
1. Enables and forces RLS.
|
||||
2. Drops any stale ``tenant_isolation`` / ``{table}_tenant_isolation`` policies.
|
||||
3. Creates a fail-closed ``{table}_tenant_isolation`` policy scoped to
|
||||
``crm_api`` and ``crm_worker``.
|
||||
4. Grants CRUD to ``crm_api`` and ``crm_worker``.
|
||||
5. Grants the appropriate permissions to ``crm_auth`` on login tables
|
||||
(users, user_tenants, tenants, sessions, password_reset_tokens).
|
||||
|
||||
Global tables and login tables without tenant isolation requirements are skipped.
|
||||
|
||||
⚠️ LOGIN-TABELLEN DÜRFEN KEIN RLS BEKOMMEN — RLS blockiert crm_auth beim Login.
|
||||
Siehe 0085 AUTH_TABLES für die korrekten Grants.
|
||||
|
||||
Revision ID: 0108
|
||||
Revises: 0107
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0108"
|
||||
down_revision = "0107"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Tables that must never get RLS (global / cross-tenant infrastructure)
|
||||
GLOBAL_TABLES = [
|
||||
"alembic_version",
|
||||
"plugin_migrations",
|
||||
"marketplace_listings",
|
||||
"sequences",
|
||||
"notification_types",
|
||||
]
|
||||
|
||||
# ⚠️ LOGIN-TABELLEN DÜRFEN KEIN RLS BEKOMMEN — RLS blockiert crm_auth beim Login.
|
||||
# Siehe 0085 AUTH_TABLES für die korrekten Grants.
|
||||
# These tables have tenant_id but must NOT get RLS because crm_auth (the login
|
||||
# role) is not included in the RLS policy. RLS on these tables blocks the
|
||||
# login flow (crm_auth cannot read users → 401 Invalid email or password).
|
||||
LOGIN_TABLES = [
|
||||
"users",
|
||||
"user_tenants",
|
||||
"tenants",
|
||||
"sessions",
|
||||
"password_reset_tokens",
|
||||
]
|
||||
|
||||
# Permissions that crm_auth needs on login tables (matching 0085 AUTH_TABLES)
|
||||
AUTH_TABLES = {
|
||||
"users": ["SELECT"],
|
||||
"user_tenants": ["SELECT"],
|
||||
"tenants": ["SELECT"],
|
||||
"password_reset_tokens": ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
"sessions": ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
}
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(sql)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dynamic discovery + RLS activation for every tenant table that #
|
||||
# was created after migration 0085 and therefore lacks RLS. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
_exec("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
policy_sql TEXT;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
JOIN information_schema.columns c
|
||||
ON c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
WHERE t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name NOT IN (
|
||||
'alembic_version',
|
||||
'plugin_migrations',
|
||||
'marketplace_listings',
|
||||
'sequences',
|
||||
'notification_types',
|
||||
-- ⚠️ LOGIN-TABELLEN DÜRFEN KEIN RLS BEKOMMEN — RLS blockiert
|
||||
-- crm_auth beim Login. Siehe 0085 AUTH_TABLES.
|
||||
'users',
|
||||
'user_tenants',
|
||||
'tenants',
|
||||
'sessions',
|
||||
'password_reset_tokens'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class pc
|
||||
JOIN pg_namespace pn ON pn.oid = pc.relnamespace
|
||||
WHERE pn.nspname = 'public'
|
||||
AND pc.relname = t.table_name
|
||||
AND pc.relrowsecurity = true
|
||||
)
|
||||
LOOP
|
||||
-- Enable + force RLS
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.table_name);
|
||||
EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.table_name);
|
||||
|
||||
-- Drop stale policies (idempotent)
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON public.%I', r.table_name);
|
||||
EXECUTE format('DROP POLICY IF EXISTS %s_tenant_isolation ON public.%I', r.table_name, r.table_name);
|
||||
|
||||
-- Create fail-closed policy
|
||||
policy_sql := format(
|
||||
'CREATE POLICY %s_tenant_isolation '
|
||||
'ON public.%I '
|
||||
'FOR ALL '
|
||||
'TO crm_api, crm_worker '
|
||||
'USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) '
|
||||
'WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||
r.table_name, r.table_name
|
||||
);
|
||||
EXECUTE policy_sql;
|
||||
|
||||
-- Grant CRUD to crm_api and crm_worker
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_api', r.table_name);
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_worker', r.table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_auth grants on login tables (NO RLS on these tables!) #
|
||||
# ⚠️ LOGIN-TABELLEN DÜRFEN KEIN RLS BEKOMMEN — RLS blockiert #
|
||||
# crm_auth beim Login. Siehe 0085 AUTH_TABLES für die korrekten Grants.#
|
||||
# ------------------------------------------------------------------ #
|
||||
for table, privs in AUTH_TABLES.items():
|
||||
priv_str = ", ".join(privs)
|
||||
_exec(
|
||||
f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables "
|
||||
f"WHERE table_schema = 'public' AND table_name = '{table}') "
|
||||
f"THEN GRANT {priv_str} ON public.{table} TO crm_auth; END IF; END $$"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Disable RLS on login-related tables (roles, permissions).
|
||||
|
||||
⚠️ KI / AGENT HINWEIS — BITTE VOR ÄNDERUNGEN LESEN ⚠️
|
||||
|
||||
Migration 0108 hat RLS auf alle Tabellen mit tenant_id aktiviert, darunter auch
|
||||
`roles` und `permissions`. Diese Tabellen werden vom Login-Flow gelesen um die
|
||||
Benutzerrolle zu bestimmen. Mit RLS auf diesen Tabellen und keinem tenant_id
|
||||
Kontext beim Login → Query gibt nichts zurück → Login schlägt fehl mit 401.
|
||||
|
||||
Diese Tabellen MÜSSEN ohne RLS bleiben, genau wie users, sessions, tenants,
|
||||
user_tenants, password_reset_tokens (siehe 0108 LOGIN_TABLES).
|
||||
|
||||
Lektion: RLS darf NICHT auf Tabellen aktiviert werden die vom Login-Flow
|
||||
gelesen werden, wenn der Login-Flow noch keinen tenant_id Kontext hat.
|
||||
|
||||
Revision ID: 0109
|
||||
Revises: 0108
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0109"
|
||||
down_revision = "0108"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# ⚠️ LOGIN-RELATED TABLES — KEIN RLS! Diese Tabellen werden vom Login-Flow
|
||||
# gelesen bevor ein tenant_id Kontext existiert. RLS blockiert den Login.
|
||||
# Siehe auch 0108 LOGIN_TABLES und 0085 AUTH_TABLES.
|
||||
LOGIN_RELATED_TABLES = [
|
||||
"roles",
|
||||
"permissions",
|
||||
]
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(sql)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Disable RLS on login-related tables
|
||||
for table in LOGIN_RELATED_TABLES:
|
||||
_exec(
|
||||
f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables "
|
||||
f"WHERE table_schema = 'public' AND table_name = '{table}') THEN "
|
||||
f"DROP POLICY IF EXISTS tenant_isolation ON public.{table}; "
|
||||
f"DROP POLICY IF EXISTS {table}_tenant_isolation ON public.{table}; "
|
||||
f"ALTER TABLE public.{table} DISABLE ROW LEVEL SECURITY; "
|
||||
f"END IF; END $$"
|
||||
)
|
||||
|
||||
# Ensure crm_auth can read roles and permissions (needed for login flow)
|
||||
for table in LOGIN_RELATED_TABLES:
|
||||
_exec(
|
||||
f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables "
|
||||
f"WHERE table_schema = 'public' AND table_name = '{table}') THEN "
|
||||
f"GRANT SELECT ON public.{table} TO crm_auth; "
|
||||
f"END IF; END $$"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Add password_salt column to mail_accounts.
|
||||
|
||||
⚠️ KI / AGENT HINWEIS — BITTE VOR ÄNDERUNGEN LESEN ⚠️
|
||||
|
||||
Das ORM-Model MailAccount (app/plugins/builtins/mail/models.py) referenziert
|
||||
die Spalte `password_salt` (String(64), nullable=False, default="").
|
||||
In der Produktions-DB fehlt diese Spalte, was zu SQLAlchemy-Fehlern führt
|
||||
beim Lesen oder Schreiben von MailAccount-Datensätzen.
|
||||
|
||||
Diese Migration fügt die Spalte mit ADD COLUMN IF NOT EXISTS hinzu, sodass
|
||||
bestehende Datensätze den Default-Wert "" (leerer String) erhalten.
|
||||
|
||||
Revision ID: 0110
|
||||
Revises: 0109
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0110"
|
||||
down_revision = "0109"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only add the column if the mail_accounts table exists.
|
||||
# On fresh installs, mail_accounts is created by the mail plugin's own
|
||||
# migration (0001_initial.sql) which runs AFTER core alembic migrations.
|
||||
op.execute(
|
||||
"DO $$ "
|
||||
"BEGIN "
|
||||
" IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'mail_accounts') THEN "
|
||||
" ALTER TABLE mail_accounts "
|
||||
" ADD COLUMN IF NOT EXISTS password_salt VARCHAR(64) NOT NULL DEFAULT ''; "
|
||||
" END IF; "
|
||||
"END $$"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"DO $$ "
|
||||
"BEGIN "
|
||||
" IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'mail_accounts') THEN "
|
||||
" ALTER TABLE mail_accounts "
|
||||
" DROP COLUMN IF EXISTS password_salt; "
|
||||
" END IF; "
|
||||
"END $$"
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Enable RLS for all remaining tenant tables that still lack RLS after 0108/0109.
|
||||
|
||||
Migration 0108 dynamically discovered tables with tenant_id and enabled RLS.
|
||||
However, new tables may have been added since, or some were missed.
|
||||
|
||||
This migration re-runs the same dynamic discovery to catch any stragglers.
|
||||
|
||||
Login tables (users, user_tenants, tenants, sessions, password_reset_tokens,
|
||||
roles, permissions) are explicitly excluded — they must NOT have RLS.
|
||||
|
||||
Global tables (alembic_version, plugin_migrations, marketplace_listings,
|
||||
sequences, notification_types) are also excluded.
|
||||
|
||||
Revision ID: 0111
|
||||
Revises: 0110
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0111"
|
||||
down_revision = "0110"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Tables that must never get RLS (global / cross-tenant infrastructure)
|
||||
GLOBAL_TABLES = [
|
||||
"alembic_version",
|
||||
"plugin_migrations",
|
||||
"marketplace_listings",
|
||||
"sequences",
|
||||
"notification_types",
|
||||
]
|
||||
|
||||
# Login-related tables — RLS blocks crm_auth during login flow
|
||||
# See 0108 and 0109 for detailed explanation
|
||||
LOGIN_TABLES = [
|
||||
"users",
|
||||
"user_tenants",
|
||||
"tenants",
|
||||
"sessions",
|
||||
"password_reset_tokens",
|
||||
"roles",
|
||||
"permissions",
|
||||
]
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(sql)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Dynamic discovery + RLS activation for any tenant table still missing RLS
|
||||
_exec("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
policy_sql TEXT;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
JOIN information_schema.columns c
|
||||
ON c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
WHERE t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name NOT IN (
|
||||
'alembic_version',
|
||||
'plugin_migrations',
|
||||
'marketplace_listings',
|
||||
'sequences',
|
||||
'notification_types',
|
||||
-- Login tables must NOT have RLS
|
||||
'users',
|
||||
'user_tenants',
|
||||
'tenants',
|
||||
'sessions',
|
||||
'password_reset_tokens',
|
||||
'roles',
|
||||
'permissions'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class pc
|
||||
JOIN pg_namespace pn ON pn.oid = pc.relnamespace
|
||||
WHERE pn.nspname = 'public'
|
||||
AND pc.relname = t.table_name
|
||||
AND pc.relrowsecurity = true
|
||||
)
|
||||
LOOP
|
||||
-- Enable + force RLS
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.table_name);
|
||||
EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.table_name);
|
||||
|
||||
-- Drop stale policies (idempotent)
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON public.%I', r.table_name);
|
||||
EXECUTE format('DROP POLICY IF EXISTS %s_tenant_isolation ON public.%I', r.table_name, r.table_name);
|
||||
|
||||
-- Create fail-closed policy
|
||||
policy_sql := format(
|
||||
'CREATE POLICY %s_tenant_isolation '
|
||||
'ON public.%I '
|
||||
'FOR ALL '
|
||||
'TO crm_api, crm_worker '
|
||||
'USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) '
|
||||
'WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||
r.table_name, r.table_name
|
||||
);
|
||||
EXECUTE policy_sql;
|
||||
|
||||
-- Grant CRUD to crm_api and crm_worker
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_api', r.table_name);
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_worker', r.table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Migrate legacy role strings (admin/editor/viewer) to real Role records with role_id.
|
||||
|
||||
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
|
||||
|
||||
This migration creates Role records for each tenant's built-in roles (admin, editor,
|
||||
viewer) and links UserTenant.role_id to the corresponding Role. After this migration,
|
||||
the legacy role string on UserTenant.role is no longer used for permission checks —
|
||||
all permissions come through the Role-based RBAC system.
|
||||
|
||||
Revision ID: 0112
|
||||
Revises: 0111
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0112"
|
||||
down_revision = "0111"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Permission sets for built-in roles
|
||||
ADMIN_PERMISSIONS = ["*:*"]
|
||||
|
||||
EDITOR_PERMISSIONS = [
|
||||
"contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read",
|
||||
"attachments:read", "attachments:write",
|
||||
"workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write",
|
||||
"addresses:read", "addresses:write",
|
||||
"taxes:read", "taxes:write",
|
||||
"currencies:read", "currencies:write",
|
||||
"notifications:read", "notifications:write",
|
||||
"import_export:read", "import_export:write",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
]
|
||||
|
||||
VIEWER_PERMISSIONS = [
|
||||
"contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read",
|
||||
"sequences:read", "addresses:read", "taxes:read",
|
||||
"currencies:read", "notifications:read",
|
||||
"import_export:read",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
]
|
||||
|
||||
GUEST_PERMISSIONS = [
|
||||
"contacts:read",
|
||||
"attachments:read",
|
||||
"user_preferences:read",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# For each tenant, create Role records for built-in roles and link UserTenant.role_id
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_rec RECORD;
|
||||
admin_role_id UUID;
|
||||
editor_role_id UUID;
|
||||
viewer_role_id UUID;
|
||||
guest_role_id UUID;
|
||||
BEGIN
|
||||
FOR tenant_rec IN SELECT id FROM tenants
|
||||
LOOP
|
||||
-- Create or find admin role for this tenant
|
||||
SELECT id INTO admin_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'admin'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF admin_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'admin',
|
||||
'["*:*"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO admin_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find editor role for this tenant
|
||||
SELECT id INTO editor_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'editor'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF editor_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'editor',
|
||||
'["contacts:read","contacts:write","users:read","roles:read","audit:read","attachments:read","attachments:write","workflows:read","workflows:write","sequences:read","sequences:write","addresses:read","addresses:write","taxes:read","taxes:write","currencies:read","currencies:write","notifications:read","notifications:write","import_export:read","import_export:write","user_preferences:read","user_preferences:write"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO editor_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find viewer role for this tenant
|
||||
SELECT id INTO viewer_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'viewer'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF viewer_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'viewer',
|
||||
'["contacts:read","users:read","roles:read","audit:read","attachments:read","workflows:read","sequences:read","addresses:read","taxes:read","currencies:read","notifications:read","import_export:read","user_preferences:read","user_preferences:write"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO viewer_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find guest role for this tenant
|
||||
SELECT id INTO guest_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'guest'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF guest_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'guest',
|
||||
'["contacts:read","attachments:read","user_preferences:read"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO guest_role_id;
|
||||
END IF;
|
||||
|
||||
-- Link UserTenant records to the appropriate Role based on legacy role string
|
||||
UPDATE user_tenants SET role_id = admin_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'admin'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = editor_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'editor'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = viewer_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'viewer'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = guest_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'guest'
|
||||
AND role_id IS NULL;
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Unlink role_id for built-in role mappings (keep the Role records)
|
||||
op.execute("""
|
||||
UPDATE user_tenants SET role_id = NULL
|
||||
WHERE role IN ('admin', 'editor', 'viewer', 'guest')
|
||||
AND role_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM roles r
|
||||
WHERE r.id = user_tenants.role_id
|
||||
AND r.name IN ('admin', 'editor', 'viewer', 'guest')
|
||||
);
|
||||
""")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Migrate guest_users to regular users with role='guest' and drop guest tables.
|
||||
|
||||
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
|
||||
This migration:
|
||||
1. Creates User records for each guest (or links to existing users by email)
|
||||
2. Creates UserTenant records with role='guest' and appropriate status
|
||||
3. Drops guest_invitations and guest_users tables
|
||||
|
||||
After this migration, guests authenticate via the normal login flow and are
|
||||
managed through the regular user system with role='guest' in user_tenants.
|
||||
|
||||
Revision ID: 0113
|
||||
Revises: 0112
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0113"
|
||||
down_revision = "0112"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Migrate guest_users into users + user_tenants with role='guest'
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
guest_rec RECORD;
|
||||
existing_user_id UUID;
|
||||
new_user_id UUID;
|
||||
mapped_status TEXT;
|
||||
BEGIN
|
||||
FOR guest_rec IN SELECT * FROM guest_users
|
||||
LOOP
|
||||
-- Map guest status to user_tenants status
|
||||
mapped_status := CASE
|
||||
WHEN guest_rec.status = 'active' THEN 'active'
|
||||
WHEN guest_rec.status = 'invited' THEN 'invited'
|
||||
WHEN guest_rec.status = 'expired' THEN 'disabled'
|
||||
WHEN guest_rec.status = 'revoked' THEN 'disabled'
|
||||
ELSE 'disabled'
|
||||
END;
|
||||
|
||||
-- Check if a user with this email already exists
|
||||
SELECT id INTO existing_user_id
|
||||
FROM users
|
||||
WHERE email = guest_rec.email
|
||||
LIMIT 1;
|
||||
|
||||
IF existing_user_id IS NOT NULL THEN
|
||||
-- User already exists — just create the tenant membership if missing
|
||||
new_user_id := existing_user_id;
|
||||
|
||||
-- Check if user_tenants entry already exists for this user+tenant
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM user_tenants
|
||||
WHERE user_id = new_user_id
|
||||
AND tenant_id = guest_rec.tenant_id
|
||||
) THEN
|
||||
INSERT INTO user_tenants (user_id, tenant_id, is_default, role, status, created_at, updated_at)
|
||||
VALUES (
|
||||
new_user_id,
|
||||
guest_rec.tenant_id,
|
||||
false,
|
||||
'guest',
|
||||
mapped_status,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
);
|
||||
END IF;
|
||||
ELSE
|
||||
-- Create new user from guest record
|
||||
INSERT INTO users (id, email, name, password_hash, is_active, preferences, is_system_admin, created_at, updated_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
guest_rec.email,
|
||||
guest_rec.name,
|
||||
COALESCE(guest_rec.password_hash, ''),
|
||||
true,
|
||||
'{}'::jsonb,
|
||||
false,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
)
|
||||
RETURNING id INTO new_user_id;
|
||||
|
||||
-- Create user_tenants membership with guest role
|
||||
INSERT INTO user_tenants (user_id, tenant_id, is_default, role, status, created_at, updated_at)
|
||||
VALUES (
|
||||
new_user_id,
|
||||
guest_rec.tenant_id,
|
||||
false,
|
||||
'guest',
|
||||
mapped_status,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Drop guest tables
|
||||
op.execute("DROP TABLE IF EXISTS guest_invitations CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS guest_users CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate guest_users table (data is lost — this is a one-way migration)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS guest_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'invited',
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS guest_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_user_id UUID NOT NULL REFERENCES guest_users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Migrate contact_folder_permissions to entity_permissions.
|
||||
|
||||
This migration moves all ACL entries from the dedicated
|
||||
``contact_folder_permissions`` table into the universal
|
||||
``entity_permissions`` table with ``entity_type='contact_folder'``.
|
||||
|
||||
Mapping:
|
||||
- folder_id → entity_id (entity_type='contact_folder')
|
||||
- user_id → principal_type='user', principal_id=user_id
|
||||
- group_id → principal_type='group', principal_id=group_id
|
||||
- permission_level → permission_level (unchanged)
|
||||
- inherit_to_subfolders is dropped (always treated as True after migration)
|
||||
|
||||
After data migration the ``contact_folder_permissions`` table is dropped.
|
||||
|
||||
Revision ID: 0114
|
||||
Revises: 0113
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0114"
|
||||
down_revision = "0113"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Migrate user-based permissions
|
||||
op.execute("""
|
||||
INSERT INTO entity_permissions (
|
||||
id, tenant_id, entity_type, entity_id,
|
||||
principal_type, principal_id,
|
||||
permission_level, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
cfp.id,
|
||||
cfp.tenant_id,
|
||||
'contact_folder',
|
||||
cfp.folder_id,
|
||||
'user',
|
||||
cfp.user_id,
|
||||
cfp.permission_level,
|
||||
cfp.created_at,
|
||||
cfp.updated_at
|
||||
FROM contact_folder_permissions cfp
|
||||
WHERE cfp.user_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# 2. Migrate group-based permissions
|
||||
op.execute("""
|
||||
INSERT INTO entity_permissions (
|
||||
id, tenant_id, entity_type, entity_id,
|
||||
principal_type, principal_id,
|
||||
permission_level, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
cfp.id,
|
||||
cfp.tenant_id,
|
||||
'contact_folder',
|
||||
cfp.folder_id,
|
||||
'group',
|
||||
cfp.group_id,
|
||||
cfp.permission_level,
|
||||
cfp.created_at,
|
||||
cfp.updated_at
|
||||
FROM contact_folder_permissions cfp
|
||||
WHERE cfp.group_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# 3. Drop the old table
|
||||
op.execute("DROP TABLE IF EXISTS contact_folder_permissions CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the old table (data is lost — this is a one-way migration)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS contact_folder_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
folder_id UUID NOT NULL REFERENCES contact_folders(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) NOT NULL DEFAULT 'read',
|
||||
inherit_to_subfolders BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
# Restore user permissions
|
||||
op.execute("""
|
||||
INSERT INTO contact_folder_permissions (
|
||||
id, tenant_id, folder_id, user_id, group_id,
|
||||
permission_level, inherit_to_subfolders, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
ep.id,
|
||||
ep.tenant_id,
|
||||
ep.entity_id,
|
||||
CASE WHEN ep.principal_type = 'user' THEN ep.principal_id ELSE NULL END,
|
||||
CASE WHEN ep.principal_type = 'group' THEN ep.principal_id ELSE NULL END,
|
||||
ep.permission_level,
|
||||
TRUE,
|
||||
ep.created_at,
|
||||
ep.updated_at
|
||||
FROM entity_permissions ep
|
||||
WHERE ep.entity_type = 'contact_folder'
|
||||
AND ep.principal_type IN ('user', 'group')
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# Remove migrated entries from entity_permissions
|
||||
op.execute("""
|
||||
DELETE FROM entity_permissions
|
||||
WHERE entity_type = 'contact_folder'
|
||||
AND principal_type IN ('user', 'group')
|
||||
""")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Drop redundant DB roles (crm_platform_admin).
|
||||
|
||||
crm_runtime was already dropped in migration 0085.
|
||||
crm_platform_admin was created in 0085 for one-time infrastructure use
|
||||
and is no longer needed.
|
||||
|
||||
Revision ID: 0115
|
||||
Revises: 0114
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0115"
|
||||
down_revision = "0114"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop crm_platform_admin if it exists
|
||||
op.execute(
|
||||
"DO $$ BEGIN "
|
||||
"DROP ROLE IF EXISTS crm_platform_admin; "
|
||||
"EXCEPTION WHEN insufficient_privilege THEN NULL; "
|
||||
"WHEN dependent_objects_still_exist THEN NULL; "
|
||||
"END $$;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate crm_platform_admin (for rollback)
|
||||
op.execute(
|
||||
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_platform_admin') THEN "
|
||||
"CREATE ROLE crm_platform_admin NOSUPERUSER NOBYPASSRLS NOLOGIN; "
|
||||
"END IF; END $$;"
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Merge deletion_log data into entity_history and drop deletion_log table.
|
||||
|
||||
DeletionLog has been merged into EntityHistory with action='delete'.
|
||||
This migration migrates existing DeletionLog records and drops the table.
|
||||
|
||||
Revision ID: 0116
|
||||
Revises: 0115
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0116"
|
||||
down_revision = "0115"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Migrate existing deletion_log records to entity_history
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO entity_history (id, tenant_id, user_id, entity_type, entity_id, action, snapshot_before, snapshot_after, changes, owner_id, created_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
tenant_id,
|
||||
user_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
'delete'::text,
|
||||
entity_snapshot::jsonb,
|
||||
NULL::jsonb,
|
||||
NULL::jsonb,
|
||||
user_id,
|
||||
deleted_at
|
||||
FROM deletion_log
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
)
|
||||
|
||||
# Drop the deletion_log table
|
||||
op.execute("DROP TABLE IF EXISTS deletion_log CASCADE;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate deletion_log table
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS deletion_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
entity_snapshot JSONB NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Migrate data back from entity_history
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO deletion_log (id, tenant_id, user_id, entity_type, entity_id, entity_snapshot, deleted_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
tenant_id,
|
||||
user_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
snapshot_before::jsonb,
|
||||
created_at
|
||||
FROM entity_history
|
||||
WHERE action = 'delete' AND snapshot_before IS NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Remove migrated records from entity_history
|
||||
op.execute("DELETE FROM entity_history WHERE action = 'delete' AND snapshot_before IS NOT NULL;")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Change plugins.config column from Text to JSONB.
|
||||
|
||||
The config column was stored as a JSON string in a Text column.
|
||||
This migration converts it to native JSONB for proper querying and validation.
|
||||
|
||||
Revision ID: 0117
|
||||
Revises: 0116
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0117"
|
||||
down_revision = "0116"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Convert Text column to JSONB, casting existing JSON strings
|
||||
op.alter_column(
|
||||
"plugins",
|
||||
"config",
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB,
|
||||
postgresql_using="config::jsonb",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Convert back to Text, casting JSONB to text
|
||||
op.alter_column(
|
||||
"plugins",
|
||||
"config",
|
||||
existing_type=JSONB,
|
||||
type_=sa.Text(),
|
||||
postgresql_using="config::text",
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Optimize HNSW index parameters for better vector search recall.
|
||||
|
||||
Recreates existing HNSW indices with tuned parameters:
|
||||
- ef_construction=128 (default 64, higher = better index quality, slower build)
|
||||
- m=16 (default 16, higher = more memory, better recall)
|
||||
|
||||
IVFFlat Alternative (B-VEC-IVF):
|
||||
-----------------------------
|
||||
comm_messages uses IVFFlat with lists=100 (migration 0035).
|
||||
Rule of thumb for IVFFlat: lists = sqrt(rows)
|
||||
~10k rows → lists ≈ 100
|
||||
~50k rows → lists ≈ 224
|
||||
~100k rows → lists ≈ 316
|
||||
IVFFlat builds faster but HNSW has better recall.
|
||||
To switch: DROP INDEX + CREATE INDEX ... USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (ef_construction=128, m=16)
|
||||
Config: vector_index_type setting in app/config.py (default 'hnsw', alternative 'ivfflat').
|
||||
|
||||
Revision ID: 0118
|
||||
Revises: 0117
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0118"
|
||||
down_revision = "0117"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Optimized HNSW parameters
|
||||
EF_CONSTRUCTION = 128
|
||||
M = 16
|
||||
|
||||
# Tables with HNSW indices (from migration 0104)
|
||||
# Format: (table_name, index_name)
|
||||
HNSW_TABLES = [
|
||||
("contacts", "ix_contacts_embedding"),
|
||||
("mails", "ix_mails_embedding"),
|
||||
("files", "ix_files_embedding"),
|
||||
("calendar_entries", "ix_calendar_entries_embedding"),
|
||||
("tags", "ix_tags_embedding"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
for table_name, index_name in HNSW_TABLES:
|
||||
# Check if table exists
|
||||
table_exists = conn.execute(sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :t)"
|
||||
), {"t": table_name}).scalar()
|
||||
|
||||
if not table_exists:
|
||||
continue
|
||||
|
||||
# Drop existing HNSW index (regardless of parameters)
|
||||
op.execute(f"DROP INDEX IF EXISTS {index_name}")
|
||||
|
||||
# Recreate with optimized parameters
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||
f"ON {table_name} USING hnsw (embedding vector_cosine_ops) "
|
||||
f"WITH (ef_construction={EF_CONSTRUCTION}, m={M})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Recreate HNSW indices with default parameters (no WITH clause)."""
|
||||
conn = op.get_bind()
|
||||
|
||||
for table_name, index_name in HNSW_TABLES:
|
||||
table_exists = conn.execute(sa.text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :t)"
|
||||
), {"t": table_name}).scalar()
|
||||
|
||||
if not table_exists:
|
||||
continue
|
||||
|
||||
# Drop optimized index
|
||||
op.execute(f"DROP INDEX IF EXISTS {index_name}")
|
||||
|
||||
# Recreate with default parameters (no WITH clause = pgvector defaults)
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||
f"ON {table_name} USING hnsw (embedding vector_cosine_ops)"
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add compliance metadata columns to ai_providers table (B-AIPROV-COMP).
|
||||
|
||||
Adds region, hosting_type, dpa_status, retention_policy,
|
||||
training_on_customer_data, transfer_notice, allowed_data_classes
|
||||
to support AI provider compliance checks.
|
||||
|
||||
Revision ID: 0119
|
||||
Revises: 0118
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0119"
|
||||
down_revision = "0118"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown"))
|
||||
op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud"))
|
||||
op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none"))
|
||||
op.add_column("ai_providers", sa.Column("retention_policy", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("ai_providers", sa.Column("training_on_customer_data", sa.Boolean(), nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("ai_providers", sa.Column("transfer_notice", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("ai_providers", sa.Column("allowed_data_classes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_providers", "allowed_data_classes")
|
||||
op.drop_column("ai_providers", "transfer_notice")
|
||||
op.drop_column("ai_providers", "training_on_customer_data")
|
||||
op.drop_column("ai_providers", "retention_policy")
|
||||
op.drop_column("ai_providers", "dpa_status")
|
||||
op.drop_column("ai_providers", "hosting_type")
|
||||
op.drop_column("ai_providers", "region")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Add is_system column to comm_conversations and migrate notifications to system channel (B-NOTIF-*).
|
||||
|
||||
Adds is_system boolean to comm_conversations for system channel support.
|
||||
Migrates existing notifications into the system channel as CommMessages.
|
||||
Creates a view notifications_legacy as a compatibility layer over the old notifications table.
|
||||
|
||||
Revision ID: 0120
|
||||
Revises: 0119
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0120"
|
||||
down_revision = "0119"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add is_system column to comm_conversations
|
||||
op.add_column(
|
||||
"comm_conversations",
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_comm_conversations_tenant_system",
|
||||
"comm_conversations",
|
||||
["tenant_id", "is_system"],
|
||||
)
|
||||
|
||||
# 2. Create system channel per tenant (for tenants that have notifications)
|
||||
op.execute("""
|
||||
INSERT INTO comm_conversations (id, tenant_id, title, is_pinned, is_locked, is_direct, is_archived, is_system, created_by, created_by_type, metadata, created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
n.tenant_id,
|
||||
'System Channel',
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
NULL,
|
||||
'system',
|
||||
'{}'::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL
|
||||
) n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM comm_conversations cc
|
||||
WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# 3. Insert notifications as CommMessages in the system channel
|
||||
op.execute("""
|
||||
INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
n.tenant_id,
|
||||
sc.id,
|
||||
n.user_id,
|
||||
'system',
|
||||
COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END,
|
||||
'text',
|
||||
jsonb_build_object(
|
||||
'notification_type', n.type,
|
||||
'severity', 'info',
|
||||
'entity_ref', CASE WHEN n.entity_type IS NOT NULL THEN jsonb_build_object('entity_type', n.entity_type, 'entity_id', n.entity_id::text) ELSE NULL END,
|
||||
'migrated_from_notification', true,
|
||||
'original_notification_id', n.id::text
|
||||
),
|
||||
n.created_at,
|
||||
COALESCE(n.read_at, n.created_at)
|
||||
FROM notifications n
|
||||
JOIN comm_conversations sc ON sc.tenant_id = n.tenant_id AND sc.is_system = true AND sc.deleted_at IS NULL
|
||||
WHERE n.deleted_at IS NULL;
|
||||
""")
|
||||
|
||||
# 4. Insert text blocks for each migrated message
|
||||
op.execute("""
|
||||
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cm.tenant_id,
|
||||
cm.id,
|
||||
'text',
|
||||
jsonb_build_object('text', cm.content),
|
||||
0
|
||||
FROM comm_messages cm
|
||||
WHERE cm.metadata->>'migrated_from_notification' = 'true';
|
||||
""")
|
||||
|
||||
# 5. Insert action_card blocks for messages with entity references
|
||||
op.execute("""
|
||||
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cm.tenant_id,
|
||||
cm.id,
|
||||
'action_card',
|
||||
jsonb_build_object(
|
||||
'label', 'Open',
|
||||
'entity_type', (cm.metadata->'entity_ref'->>'entity_type'),
|
||||
'entity_id', (cm.metadata->'entity_ref'->>'entity_id')
|
||||
),
|
||||
1
|
||||
FROM comm_messages cm
|
||||
WHERE cm.metadata->>'migrated_from_notification' = 'true'
|
||||
AND cm.metadata->'entity_ref' IS NOT NULL;
|
||||
""")
|
||||
|
||||
# 6. For read notifications, create CommMessageRead entries
|
||||
op.execute("""
|
||||
INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cm.tenant_id,
|
||||
cm.conversation_id,
|
||||
cm.sender_id,
|
||||
cm.id,
|
||||
COALESCE(n.read_at, n.created_at)
|
||||
FROM comm_messages cm
|
||||
JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id'
|
||||
WHERE cm.metadata->>'migrated_from_notification' = 'true'
|
||||
AND n.read_at IS NOT NULL
|
||||
AND n.deleted_at IS NULL;
|
||||
""")
|
||||
|
||||
# 7. Create legacy view over notifications table for backward compatibility
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy")
|
||||
op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy")
|
||||
op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')")
|
||||
op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'")
|
||||
op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'")
|
||||
op.drop_index("ix_comm_conversations_tenant_system", table_name="comm_conversations")
|
||||
op.drop_column("comm_conversations", "is_system")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Create automation_agent_run_steps table for ReAct loop step tracking.
|
||||
|
||||
Revision ID: 0121
|
||||
Revises: 0120
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0121"
|
||||
down_revision = "0120"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"automation_agent_run_steps",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"agent_run_id",
|
||||
PGUUID(as_uuid=True),
|
||||
sa.ForeignKey("automation_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("step_number", sa.Integer, nullable=False),
|
||||
sa.Column("thought", sa.Text, nullable=True),
|
||||
sa.Column("action", sa.String(255), nullable=True),
|
||||
sa.Column("action_input", JSONB, nullable=True),
|
||||
sa.Column("observation", sa.Text, nullable=True),
|
||||
sa.Column("cost_usd", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_run_steps_run",
|
||||
"automation_agent_run_steps",
|
||||
["tenant_id", "agent_run_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_run_steps_run", table_name="automation_agent_run_steps")
|
||||
op.drop_table("automation_agent_run_steps")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Add Phase F fields to automation_agent_definitions.
|
||||
|
||||
Adds temperature, max_tokens, max_steps, trace_mode, skill_ids,
|
||||
trigger_config, and ai_use_case_metadata to support the Phase F
|
||||
context-builder, SSE streaming, and AI-use-case features.
|
||||
|
||||
Revision ID: 0122
|
||||
Revises: 0121
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0122"
|
||||
down_revision = "0121"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("max_tokens", sa.Integer, nullable=False, server_default="1000"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("max_steps", sa.Integer, nullable=False, server_default="20"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column(
|
||||
"trace_mode", sa.String(20), nullable=False, server_default="standard"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("skill_ids", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("trigger_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column(
|
||||
"ai_use_case_metadata",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("automation_agent_definitions", "ai_use_case_metadata")
|
||||
op.drop_column("automation_agent_definitions", "trigger_config")
|
||||
op.drop_column("automation_agent_definitions", "skill_ids")
|
||||
op.drop_column("automation_agent_definitions", "trace_mode")
|
||||
op.drop_column("automation_agent_definitions", "max_steps")
|
||||
op.drop_column("automation_agent_definitions", "max_tokens")
|
||||
op.drop_column("automation_agent_definitions", "temperature")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Create approval_requests and ai_decision_records tables.
|
||||
|
||||
Adds the central approval-request table for agent action approval (F-APPR)
|
||||
and the AI decision-record table for the human-oversight audit trail
|
||||
(F-OVERSIGHT).
|
||||
|
||||
Revision ID: 0123
|
||||
Revises: 0122
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0123"
|
||||
down_revision = "0122"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"approval_requests",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("entity_type", sa.String(80), nullable=False),
|
||||
sa.Column("entity_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("action", sa.String(120), nullable=False),
|
||||
sa.Column("requested_by", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("requested_by_type", sa.String(20), nullable=False, server_default="agent"),
|
||||
sa.Column("approver_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("approver_group", sa.String(120), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_status", "approval_requests", ["tenant_id", "status"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_entity",
|
||||
"approval_requests",
|
||||
["tenant_id", "entity_type", "entity_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_approver",
|
||||
"approval_requests",
|
||||
["tenant_id", "approver_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ai_decision_records",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("agent_run_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("recommendation", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("reviewer_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("decision", sa.String(20), nullable=True),
|
||||
sa.Column("decision_timestamp", sa.String(40), nullable=True),
|
||||
sa.Column("deviation_note", 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()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_decision_records_tenant_run", "ai_decision_records", ["tenant_id", "agent_run_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_decision_records_tenant_run", table_name="ai_decision_records")
|
||||
op.drop_table("ai_decision_records")
|
||||
op.drop_index("ix_approval_requests_tenant_approver", table_name="approval_requests")
|
||||
op.drop_index("ix_approval_requests_tenant_entity", table_name="approval_requests")
|
||||
op.drop_index("ix_approval_requests_tenant_status", table_name="approval_requests")
|
||||
op.drop_table("approval_requests")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Unified Task System (F.14).
|
||||
|
||||
Adds polymorphic assignment/entity/creator fields, subtasks, dependencies,
|
||||
goals/milestones and agent-subtask support to the tasks table. Migrates
|
||||
legacy ``contact_id``/``assigned_to`` values into the polymorphic fields and
|
||||
migrates existing ``agent_subtasks`` rows into tasks with
|
||||
``task_type='agent_subtask'``.
|
||||
|
||||
Revision ID: 0124
|
||||
Revises: 0123
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0124"
|
||||
down_revision = "0123"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Add new columns to tasks ────────────────────────────────────────────
|
||||
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_type", sa.String(80), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("creator_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("parent_task_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("depends_on", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")))
|
||||
op.add_column("tasks", sa.Column("task_type", sa.String(30), nullable=False, server_default="todo"))
|
||||
op.add_column("tasks", sa.Column("success_criteria", JSONB, nullable=True))
|
||||
op.add_column("tasks", sa.Column("target_date", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("progress", sa.Integer(), nullable=False, server_default="0"))
|
||||
|
||||
# ── Migrate legacy data into polymorphic fields ─────────────────────────
|
||||
# contact_id → entity_type='contact' + entity_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET entity_type = 'contact', entity_id = contact_id
|
||||
WHERE contact_id IS NOT NULL AND entity_type IS NULL
|
||||
"""
|
||||
)
|
||||
# assigned_to → assignee_type='user' + assignee_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET assignee_type = 'user', assignee_id = assigned_to
|
||||
WHERE assigned_to IS NOT NULL AND assignee_id IS NULL
|
||||
"""
|
||||
)
|
||||
# created_by → creator_type='user' + creator_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET creator_type = 'user', creator_id = created_by
|
||||
WHERE created_by IS NOT NULL AND creator_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Migrate AgentSubtask rows into tasks ────────────────────────────────
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tasks (
|
||||
id, tenant_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, entity_type, entity_id,
|
||||
creator_type, creator_id, task_type, depends_on, progress,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
asub.id, asub.tenant_id,
|
||||
asub.task_description, asub.task_description, asub.status, 'medium',
|
||||
'agent', asub.child_agent_id, 'agent', asub.parent_agent_id,
|
||||
'agent', asub.parent_agent_id, 'agent_subtask', '[]'::jsonb, 0,
|
||||
asub.created_at, asub.updated_at
|
||||
FROM agent_subtasks asub
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tasks t WHERE t.id = asub.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Indexes ─────────────────────────────────────────────────────────────
|
||||
op.create_index("ix_tasks_tenant_entity", "tasks", ["tenant_id", "entity_type", "entity_id"])
|
||||
op.create_index("ix_tasks_tenant_assignee", "tasks", ["tenant_id", "assignee_type", "assignee_id"])
|
||||
op.create_index("ix_tasks_tenant_parent", "tasks", ["tenant_id", "parent_task_id"])
|
||||
op.create_index("ix_tasks_tenant_type", "tasks", ["tenant_id", "task_type"])
|
||||
op.create_foreign_key(
|
||||
"fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey")
|
||||
op.drop_index("ix_tasks_tenant_type", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_parent", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_assignee", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_entity", table_name="tasks")
|
||||
op.drop_column("tasks", "progress")
|
||||
op.drop_column("tasks", "target_date")
|
||||
op.drop_column("tasks", "success_criteria")
|
||||
op.drop_column("tasks", "task_type")
|
||||
op.drop_column("tasks", "depends_on")
|
||||
op.drop_column("tasks", "parent_task_id")
|
||||
op.drop_column("tasks", "creator_id")
|
||||
op.drop_column("tasks", "creator_type")
|
||||
op.drop_column("tasks", "entity_id")
|
||||
op.drop_column("tasks", "entity_type")
|
||||
op.drop_column("tasks", "assignee_id")
|
||||
op.drop_column("tasks", "assignee_type")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Durable WorkflowRun — resume semantics, step state, idempotency (G-RUN, G-CTX).
|
||||
|
||||
Extends workflow_instances with resume_at, resume_reason, step_state,
|
||||
idempotency_key, and lock_owner for durable/resumable workflow execution.
|
||||
|
||||
Revision ID: 0125
|
||||
Revises: 0124
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0125"
|
||||
down_revision = "0124"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Add durable/resumable columns to workflow_instances ──────────────
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("resume_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("resume_reason", sa.String(50), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("step_state", JSONB, nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("lock_owner", sa.String(100), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("lock_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"),
|
||||
)
|
||||
|
||||
# Index for finding workflows that need to be resumed
|
||||
op.create_index(
|
||||
"ix_wf_instances_resume",
|
||||
"workflow_instances",
|
||||
["tenant_id", "status", "resume_at"],
|
||||
)
|
||||
# Index for idempotency key lookup
|
||||
op.create_index(
|
||||
"ix_wf_instances_idempotency",
|
||||
"workflow_instances",
|
||||
["tenant_id", "idempotency_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_wf_instances_idempotency", table_name="workflow_instances")
|
||||
op.drop_index("ix_wf_instances_resume", table_name="workflow_instances")
|
||||
op.drop_column("workflow_instances", "max_retries")
|
||||
op.drop_column("workflow_instances", "retry_count")
|
||||
op.drop_column("workflow_instances", "error_message")
|
||||
op.drop_column("workflow_instances", "lock_expires_at")
|
||||
op.drop_column("workflow_instances", "lock_owner")
|
||||
op.drop_column("workflow_instances", "idempotency_key")
|
||||
op.drop_column("workflow_instances", "step_state")
|
||||
op.drop_column("workflow_instances", "resume_reason")
|
||||
op.drop_column("workflow_instances", "resume_at")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Wiki plugin — articles, categories, versions (H-WIKI, H-VER).
|
||||
|
||||
Revision ID: 0126
|
||||
Revises: 0125
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0126"
|
||||
down_revision = "0125"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"wiki_categories",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("slug", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_cat_parent", "wiki_categories", "wiki_categories", ["parent_id"], ["id"], ondelete="SET NULL")
|
||||
op.create_index("ix_wiki_cat_tenant", "wiki_categories", ["tenant_id"])
|
||||
op.create_index("ix_wiki_cat_tenant_slug", "wiki_categories", ["tenant_id", "slug"])
|
||||
|
||||
op.create_table(
|
||||
"wiki_articles",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("slug", sa.String(300), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("content_html", sa.Text, nullable=True),
|
||||
sa.Column("summary", sa.Text, nullable=True),
|
||||
sa.Column("category_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("entity_links", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_art_category", "wiki_articles", "wiki_categories", ["category_id"], ["id"], ondelete="SET NULL")
|
||||
op.create_index("ix_wiki_art_tenant", "wiki_articles", ["tenant_id"])
|
||||
op.create_index("ix_wiki_art_tenant_category", "wiki_articles", ["tenant_id", "category_id"])
|
||||
op.create_index("ix_wiki_art_tenant_slug", "wiki_articles", ["tenant_id", "slug"])
|
||||
op.create_index("ix_wiki_art_tenant_status", "wiki_articles", ["tenant_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"wiki_article_versions",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("article_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version", sa.Integer, nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("edited_by", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("edit_comment", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_ver_article", "wiki_article_versions", "wiki_articles", ["article_id"], ["id"], ondelete="CASCADE")
|
||||
op.create_index("ix_wiki_ver_tenant_article", "wiki_article_versions", ["tenant_id", "article_id"])
|
||||
op.create_index("ix_wiki_ver_tenant_version", "wiki_article_versions", ["tenant_id", "article_id", "version"])
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("wiki_article_versions")
|
||||
op.drop_table("wiki_articles")
|
||||
op.drop_table("wiki_categories")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Drop tasks_contact_id_fkey — contact_id is now derived from entity_id (ARCH-F-2).
|
||||
|
||||
The tasks table has both a contact_id FK column (referencing contacts) and
|
||||
polymorphic entity_type/entity_id columns. The code now derives contact_id
|
||||
from entity_id when entity_type='contact', and stores NULL in the FK column.
|
||||
The FK constraint is redundant and prevents creating tasks with arbitrary
|
||||
entity references. This migration drops the FK constraint but keeps the column
|
||||
for backward compatibility.
|
||||
|
||||
Revision ID: 0127
|
||||
Revises: 0126
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0127"
|
||||
down_revision = "0126"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the FK constraint on tasks.contact_id
|
||||
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-create the FK constraint (best-effort — may fail if orphaned rows exist)
|
||||
op.create_foreign_key(
|
||||
"tasks_contact_id_fkey",
|
||||
"tasks",
|
||||
"contacts",
|
||||
["contact_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Create ai_decision_records table for oversight.
|
||||
|
||||
Revision ID: 0128
|
||||
Revises: 0127
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0128"
|
||||
down_revision = "0127"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use IF NOT EXISTS to avoid DuplicateTableError if table was already
|
||||
# created by Base.metadata.create_all() in prestart.sh
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ai_decision_records (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
owner_id UUID,
|
||||
agent_run_id UUID NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
evidence JSONB NOT NULL DEFAULT '{}',
|
||||
reviewer_id UUID,
|
||||
decision VARCHAR(20),
|
||||
decision_timestamp VARCHAR(40),
|
||||
deviation_note TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ai_decision_records_tenant_id ON ai_decision_records(tenant_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ai_decision_records_agent_run_id ON ai_decision_records(agent_run_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("ai_decision_records")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Enable RLS for 8 tables that need tenant isolation.
|
||||
|
||||
Tables excluded (no tenant_id column):
|
||||
- outbox_deliveries: linked via event_outbox which has tenant_id
|
||||
- marketplace_listings: global plugin marketplace, not tenant-specific
|
||||
|
||||
Revision ID: 0129
|
||||
Revises: 0128
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0129"
|
||||
down_revision = "0128"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLES_NEEDING_RLS = [
|
||||
"ai_decision_records",
|
||||
"approval_requests",
|
||||
"automation_agent_run_steps",
|
||||
"roles",
|
||||
"sequences",
|
||||
"wiki_articles",
|
||||
"wiki_article_versions",
|
||||
"wiki_categories",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add backup_enabled column to system_settings table.
|
||||
|
||||
Revision ID: 0130
|
||||
Revises: 0129
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0130"
|
||||
down_revision = "0129"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column("backup_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_settings", "backup_enabled")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""knowledge extractions table
|
||||
|
||||
Revision ID: 0131
|
||||
Revises: 0130
|
||||
Create Date: 2026-08-20
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0131"
|
||||
down_revision = "0130"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"knowledge_extractions",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(50), nullable=False),
|
||||
sa.Column("source_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("source_title", sa.String(500), nullable=True),
|
||||
sa.Column("extracted_entities", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("extracted_relationships", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("status", sa.String(30), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("review_notes", sa.Text, nullable=True),
|
||||
sa.Column("llm_model", sa.String(100), nullable=True),
|
||||
sa.Column("llm_cost_usd", sa.Float, nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("reviewed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_knowledge_ext_tenant_status", "knowledge_extractions", ["tenant_id", "status"])
|
||||
op.create_index("ix_knowledge_ext_source", "knowledge_extractions", ["tenant_id", "source_type", "source_id"])
|
||||
# RLS
|
||||
op.execute("ALTER TABLE knowledge_extractions ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("CREATE POLICY knowledge_extractions_tenant_isolation ON knowledge_extractions USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("knowledge_extractions")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""self-improvement tables: signals, patterns, proposals, impact measurements
|
||||
|
||||
Revision ID: 0132
|
||||
Revises: 0131
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0132"
|
||||
down_revision = "0131"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. improvement_patterns (created first because signals has FK to it)
|
||||
op.create_table(
|
||||
"improvement_patterns",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pattern_kind", sa.String(40), nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_name", sa.String(200), nullable=False, server_default=sa.text("'unknown'")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("occurrence_count", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'detected'")),
|
||||
sa.Column("proposed_action", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_patterns_tenant_status", "improvement_patterns", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_patterns_tenant_target", "improvement_patterns", ["tenant_id", "target_type"])
|
||||
|
||||
# 2. improvement_signals
|
||||
op.create_table(
|
||||
"improvement_signals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(50), nullable=False),
|
||||
sa.Column("source_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("source_metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("summary", sa.Text, nullable=False),
|
||||
sa.Column("signal_kind", sa.String(30), nullable=False),
|
||||
sa.Column("severity", sa.String(20), nullable=False, server_default=sa.text("'info'")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_signals_tenant_kind", "improvement_signals", ["tenant_id", "signal_kind"])
|
||||
op.create_index("ix_impr_signals_tenant_source", "improvement_signals", ["tenant_id", "source_type"])
|
||||
op.create_index("ix_impr_signals_tenant_pattern", "improvement_signals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 3. improvement_proposals
|
||||
op.create_table(
|
||||
"improvement_proposals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("target_name", sa.String(200), nullable=True),
|
||||
sa.Column("version_number", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("proposed_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("previous_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("rationale", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("expected_benefit", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("risk_assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("evaluation_result", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("approval_request_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("approved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rollback_reason", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'draft'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_proposals_tenant_status", "improvement_proposals", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_proposals_tenant_target", "improvement_proposals", ["tenant_id", "target_type"])
|
||||
op.create_index("ix_impr_proposals_tenant_pattern", "improvement_proposals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 4. improvement_impact_measurements
|
||||
op.create_table(
|
||||
"improvement_impact_measurements",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("proposal_id", UUID(as_uuid=True), sa.ForeignKey("improvement_proposals.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pre_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("post_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("delta", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("is_positive", sa.String(20), nullable=False, server_default=sa.text("'neutral'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_impact_tenant_proposal", "improvement_impact_measurements", ["tenant_id", "proposal_id"])
|
||||
|
||||
# RLS for all 4 tables
|
||||
for table in ["improvement_patterns", "improvement_signals", "improvement_proposals", "improvement_impact_measurements"]:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(f"CREATE POLICY {table}_tenant_isolation ON {table} USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ["improvement_impact_measurements", "improvement_proposals", "improvement_signals", "improvement_patterns"]:
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""compliance_incidents table for AI/privacy/security incident register
|
||||
|
||||
Revision ID: 0133
|
||||
Revises: 0132
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0133"
|
||||
down_revision = "0132"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"compliance_incidents",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("incident_type", sa.String(30), nullable=False, server_default=sa.text("'ai'")),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("affected_use_cases", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("affected_versions", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("provider", sa.String(100), nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("measures_taken", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'open'")),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("resolved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_compliance_incidents_tenant_status", "compliance_incidents", ["tenant_id", "status"])
|
||||
op.create_index("ix_compliance_incidents_tenant_type", "compliance_incidents", ["tenant_id", "incident_type"])
|
||||
|
||||
# Add retention_config JSONB column to system_settings for compliance retention overrides
|
||||
op.add_column("system_settings", sa.Column("retention_config", JSONB, nullable=True, server_default=sa.text("'{}'::jsonb")))
|
||||
|
||||
# RLS
|
||||
op.execute("ALTER TABLE compliance_incidents ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("CREATE POLICY compliance_incidents_tenant_isolation ON compliance_incidents USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_settings", "retention_config")
|
||||
op.drop_table("compliance_incidents")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Fix notification_types column sizes — VARCHAR(20) too small for values.
|
||||
|
||||
Revision ID: 0134
|
||||
Revises: 0133
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0134"
|
||||
down_revision = "0133"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN type_key TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN plugin_name TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN category TYPE VARCHAR(50);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN label TYPE VARCHAR(200);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN label TYPE VARCHAR(200);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN category TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN plugin_name TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN type_key TYPE VARCHAR(20);")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Fix schema drifts — VARCHAR lengths + missing tables.
|
||||
|
||||
Revision ID: 0135
|
||||
Revises: 0134
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0135"
|
||||
down_revision = "0134"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Fix VARCHAR length mismatches (model defines longer than DB)
|
||||
# Drop notifications_legacy view first — it depends on notifications.type column
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy CASCADE;")
|
||||
op.execute("ALTER TABLE contacts ALTER COLUMN status TYPE VARCHAR(30);")
|
||||
op.execute("ALTER TABLE notifications ALTER COLUMN type TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_preferences ALTER COLUMN type_key TYPE VARCHAR(100);")
|
||||
|
||||
# 2. Create missing table: forgejo_reported_errors (only if not exists)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS forgejo_reported_errors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
dedup_key VARCHAR(64) NOT NULL UNIQUE,
|
||||
message TEXT NOT NULL,
|
||||
stack TEXT,
|
||||
forgejo_issue_number INTEGER,
|
||||
reported_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'reported'
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. Create missing table: pgp_keys (only if not exists)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pgp_keys (
|
||||
id UUID DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
key_id VARCHAR(255) NOT NULL,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
public_key_armored TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_pgp_keys_user ON pgp_keys (user_id);")
|
||||
op.execute("ALTER TABLE pgp_keys ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("DROP POLICY IF EXISTS pgp_keys_tenant_isolation ON pgp_keys;")
|
||||
op.execute("CREATE POLICY pgp_keys_tenant_isolation ON pgp_keys USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("pgp_keys")
|
||||
op.drop_table("forgejo_reported_errors")
|
||||
op.execute("ALTER TABLE notification_preferences ALTER COLUMN type_key TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notifications ALTER COLUMN type TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE contacts ALTER COLUMN status TYPE VARCHAR(20);")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Fix RLS policies — app.tenant_id → app.current_tenant_id.
|
||||
|
||||
8 RLS policies in production reference 'app.tenant_id' which doesn't exist
|
||||
as a PostgreSQL parameter. The code uses 'app.current_tenant_id'.
|
||||
This causes 500 errors on roles, sequences, wiki, approval_requests,
|
||||
ai_decision_records, and automation_agent_run_steps.
|
||||
|
||||
Revision ID: 0136
|
||||
Revises: 0135
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0136"
|
||||
down_revision = "0135"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# All 8 tables with broken RLS policies referencing app.tenant_id
|
||||
TABLES_WITH_BAD_RLS = [
|
||||
"ai_decision_records",
|
||||
"approval_requests",
|
||||
"automation_agent_run_steps",
|
||||
"roles",
|
||||
"sequences",
|
||||
"wiki_articles",
|
||||
"wiki_article_versions",
|
||||
"wiki_categories",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
# Drop old policy with app.tenant_id
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
# Create new policy with app.current_tenant_id
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"USING (tenant_id::text = current_setting('app.current_tenant_id', true));"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"USING (tenant_id::text = current_setting('app.tenant_id', true));"
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Drop AI chat tables (migrated to comm conversations)
|
||||
|
||||
Revision ID: 0137
|
||||
Revises: 0136
|
||||
Create Date: 2026-08-21
|
||||
|
||||
AI chat functionality is now handled by the kommunikation plugin's
|
||||
comm_conversations and comm_messages tables. The old AI-specific tables
|
||||
(ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_conversations,
|
||||
ai_messages) are no longer needed and are dropped.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0137"
|
||||
down_revision = "0136"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use IF EXISTS to avoid errors if tables are already gone
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_attachments CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_messages CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_sessions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_messages CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_conversations CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Tables cannot be restored — data was migrated or was empty.
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tags: parent_id, applicable_to, icon columns
|
||||
|
||||
Revision ID: 0138
|
||||
Revises: 0137
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds parent_id for tree structure, applicable_to for entity-type filtering,
|
||||
and icon for per-tag icon selection.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0138"
|
||||
down_revision = "0137"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# parent_id for tree structure (self-referencing FK)
|
||||
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_tags_parent_id", "tags", "tags", ["parent_id"], ["id"], ondelete="SET NULL"
|
||||
)
|
||||
op.create_index("ix_tags_parent", "tags", ["parent_id"])
|
||||
|
||||
# applicable_to: list of entity types where this tag can be applied
|
||||
op.add_column("tags", sa.Column("applicable_to", JSONB, nullable=True))
|
||||
|
||||
# icon: icon name for frontend display
|
||||
op.add_column("tags", sa.Column("icon", sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tags", "icon")
|
||||
op.drop_column("tags", "applicable_to")
|
||||
op.drop_index("ix_tags_parent", table_name="tags")
|
||||
op.drop_constraint("fk_tags_parent_id", "tags", type_="foreignkey")
|
||||
op.drop_column("tags", "parent_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Reports: folder_id column for folder-based sorting
|
||||
|
||||
Revision ID: 0139
|
||||
Revises: 0138
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds folder_id to report_templates for folder-based organization.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0139"
|
||||
down_revision = "0138"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_report_templates_folder", table_name="report_templates")
|
||||
op.drop_column("report_templates", "folder_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Communication: folder_id in comm_conversations for folder organization
|
||||
|
||||
Revision ID: 0140
|
||||
Revises: 0139
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds folder_id to comm_conversations for folder-based organization.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0140"
|
||||
down_revision = "0139"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
|
||||
op.drop_column("comm_conversations", "folder_id")
|
||||
@@ -6,9 +6,12 @@ Supports keyword-based intent detection for common CRM operations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Precompiled patterns for intent detection
|
||||
_PATTERNS = {
|
||||
"create_contact": re.compile(
|
||||
@@ -27,6 +30,37 @@ _PATTERNS = {
|
||||
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
# Plugin-contributed intents: pattern -> callable(query, context) -> list[dict] | None.
|
||||
# Registered via ``register_intent_pattern`` so plugins can extend the fallback
|
||||
# mapper without touching core code (Block H / HC-A).
|
||||
_CONTRIBUTED_INTENTS: list[tuple[re.Pattern[str], Any]] = []
|
||||
|
||||
|
||||
def register_intent_pattern(
|
||||
pattern: str | re.Pattern[str],
|
||||
handler: Any,
|
||||
*,
|
||||
owner: str = "",
|
||||
) -> None:
|
||||
"""Register a plugin-contributed intent for the fallback action mapper.
|
||||
|
||||
Args:
|
||||
pattern: Regex (compiled or raw string) matching the user query.
|
||||
handler: Callable ``(query, context) -> list[dict] | None`` producing
|
||||
proposed actions when the pattern matches.
|
||||
owner: Optional plugin name, used by ``unregister_intent_patterns``.
|
||||
"""
|
||||
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
||||
_CONTRIBUTED_INTENTS.append((compiled, handler))
|
||||
|
||||
|
||||
def unregister_intent_patterns(owner: str) -> None:
|
||||
"""Remove all intents contributed by ``owner`` (plugin deactivation)."""
|
||||
global _CONTRIBUTED_INTENTS
|
||||
_CONTRIBUTED_INTENTS = [
|
||||
entry for entry in _CONTRIBUTED_INTENTS if getattr(entry[1], "owner_tag", None) != owner
|
||||
]
|
||||
|
||||
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||
_NAME_PATTERNS = [
|
||||
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
@@ -147,6 +181,16 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
||||
}
|
||||
)
|
||||
|
||||
# --- Plugin-contributed intents (Block H / HC-A) ---
|
||||
for pattern, handler in _CONTRIBUTED_INTENTS:
|
||||
try:
|
||||
if pattern.search(q):
|
||||
contributed = handler(query, context)
|
||||
if contributed:
|
||||
actions.extend(contributed)
|
||||
except Exception:
|
||||
logger.warning("Contributed intent handler failed", exc_info=True)
|
||||
|
||||
# --- Generic fallback ---
|
||||
if not actions:
|
||||
if _PATTERNS["help"].search(q):
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Core ReAct (Reasoning + Acting) loop for AI agents.
|
||||
|
||||
Implements a true ReAct loop that alternates between LLM reasoning and tool
|
||||
execution. Each step records the thought (LLM content), action (tool name),
|
||||
action_input (tool arguments), and observation (tool result).
|
||||
|
||||
The loop terminates when:
|
||||
- The LLM returns a final response without tool calls (completed)
|
||||
- max_steps is reached (stopped_max_steps)
|
||||
- timeout is exceeded (stopped_timeout)
|
||||
- A permanent error occurs (stopped_error)
|
||||
|
||||
Error handling uses ``ErrorCategory`` from ``app.core.error_codes``:
|
||||
- TRANSIENT → retry the LLM call (up to 3 retries per step)
|
||||
- PERMANENT → stop the loop immediately
|
||||
- PARTIAL → continue with partial results
|
||||
|
||||
Usage::
|
||||
|
||||
from app.ai.agent_loop import run_react_loop
|
||||
|
||||
result = await run_react_loop(
|
||||
agent_definition=agent,
|
||||
messages=[{"role": "user", "content": "Summarize recent emails"}],
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=db_session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
print(result.final_content, result.total_cost_usd, result.steps_taken)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.core.error_codes import ErrorCategory, classify_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum retries for transient errors per LLM step
|
||||
_MAX_TRANSIENT_RETRIES = 3
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Data structures
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReActStep:
|
||||
"""A single step in the ReAct loop (Thought → Action → Observation)."""
|
||||
|
||||
step_number: int
|
||||
thought: str # LLM content before tool calls
|
||||
action: str | None # Tool name (None if final response)
|
||||
action_input: dict[str, Any] | None # Tool arguments
|
||||
observation: str | None # Tool result
|
||||
cost_usd: float
|
||||
timestamp: str # ISO format
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReActResult:
|
||||
"""Final result of the ReAct loop."""
|
||||
|
||||
final_content: str
|
||||
steps: list[ReActStep] = field(default_factory=list)
|
||||
total_cost_usd: float = 0.0
|
||||
steps_taken: int = 0
|
||||
status: str = "completed" # completed | stopped_max_steps | stopped_timeout | stopped_error
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Core loop
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_tool_calls(raw_response: Any) -> list[dict[str, Any]]:
|
||||
"""Extract tool calls from a LiteLLM raw response.
|
||||
|
||||
Returns a list of dicts with keys: ``id``, ``name``, ``arguments``.
|
||||
"""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
msg = raw_response.choices[0].message
|
||||
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
tool_calls.append({
|
||||
"id": tc.id or "",
|
||||
"name": tc.function.name if tc.function else "",
|
||||
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "{}",
|
||||
})
|
||||
except (AttributeError, IndexError, TypeError) as exc:
|
||||
logger.debug("Failed to extract tool calls from response: %s", exc)
|
||||
return tool_calls
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
tool_registry: ToolRegistry,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> str:
|
||||
"""Execute a single tool call via the registry.
|
||||
|
||||
Returns the tool result as a string, or an error message.
|
||||
"""
|
||||
tool = tool_registry.get(tool_name)
|
||||
if tool is None:
|
||||
return f"Error: Tool '{tool_name}' not found"
|
||||
|
||||
try:
|
||||
result = await tool.handler(arguments=arguments, context=context)
|
||||
return result if isinstance(result, str) else json.dumps(result)
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' execution failed", tool_name)
|
||||
return f"Error: {exc}"
|
||||
|
||||
|
||||
async def run_react_loop(
|
||||
agent_definition: Any, # AgentDefinition from automation models
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_registry: ToolRegistry,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID | None = None,
|
||||
max_steps: int = 20,
|
||||
timeout_seconds: int = 300,
|
||||
trace_id: str | None = None,
|
||||
on_step: Callable | None = None,
|
||||
dry_run: bool = False,
|
||||
require_approval: bool = False,
|
||||
approval_tools: list[str] | None = None,
|
||||
) -> ReActResult:
|
||||
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with llm_model, system_prompt, etc.
|
||||
messages: Initial chat messages (without system prompt).
|
||||
tools: OpenAI-format tool schemas for function calling.
|
||||
tool_registry: ToolRegistry instance for tool execution.
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: User ID for permission context.
|
||||
agent_run_id: Optional AgentRun ID for step persistence.
|
||||
max_steps: Maximum loop iterations (default 20).
|
||||
timeout_seconds: Overall timeout (default 300).
|
||||
trace_id: Optional trace ID for correlation.
|
||||
on_step: Optional async callback fired after each step.
|
||||
dry_run: When True, tool execution is simulated — tool handlers are
|
||||
NOT called. A mock result is returned instead and steps are still
|
||||
logged with real LLM cost.
|
||||
|
||||
Returns:
|
||||
ReActResult with final content, steps, cost, and status.
|
||||
"""
|
||||
from app.core.hooks import do_action
|
||||
|
||||
result = ReActResult(final_content="", status="completed")
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Build LLM parameters from agent definition
|
||||
litellm_model = getattr(agent_definition, "llm_model", None) or "gpt-4o"
|
||||
system_prompt = getattr(agent_definition, "system_prompt", "") or "You are a helpful AI assistant."
|
||||
api_key = getattr(agent_definition, "api_key", None)
|
||||
api_base = getattr(agent_definition, "api_base", None)
|
||||
provider = getattr(agent_definition, "provider", None)
|
||||
max_tokens = getattr(agent_definition, "max_tokens", None) or 1000
|
||||
|
||||
# Build the full message list with system prompt prepended
|
||||
full_messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*messages,
|
||||
]
|
||||
|
||||
tool_context: dict[str, Any] = {
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"db": db,
|
||||
}
|
||||
|
||||
# Audit helper — records every tool call in the audit log.
|
||||
async def _audit_tool_call(
|
||||
step_number: int,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
result: str,
|
||||
cost_usd: float,
|
||||
) -> None:
|
||||
"""Create an audit log entry for a single tool call."""
|
||||
try:
|
||||
from app.core.audit import log_audit
|
||||
|
||||
await log_audit(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="agent.tool_call",
|
||||
entity_type="agent_run",
|
||||
entity_id=agent_run_id,
|
||||
details={
|
||||
"agent_run_id": str(agent_run_id) if agent_run_id else None,
|
||||
"step_number": step_number,
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"result": result[:2000],
|
||||
"cost_usd": cost_usd,
|
||||
"dry_run": dry_run,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to audit tool call '%s'", tool_name)
|
||||
|
||||
for step_num in range(1, max_steps + 1):
|
||||
# ── Timeout check ──
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= timeout_seconds:
|
||||
result.status = "stopped_timeout"
|
||||
result.error = f"Timeout after {elapsed:.1f}s (limit {timeout_seconds}s)"
|
||||
logger.warning("ReAct loop timed out at step %d: %s", step_num, result.error)
|
||||
break
|
||||
|
||||
# ── LLM call with transient retry ──
|
||||
llm_result: dict[str, Any] | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
for retry in range(_MAX_TRANSIENT_RETRIES + 1):
|
||||
try:
|
||||
llm_result = await llm_complete(
|
||||
model=litellm_model,
|
||||
messages=full_messages,
|
||||
tools=tools if tools else None,
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
provider=provider,
|
||||
trace_id=trace_id,
|
||||
tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
category = classify_exception(exc)
|
||||
|
||||
if category == ErrorCategory.PERMANENT:
|
||||
result.status = "stopped_error"
|
||||
result.error = f"Permanent error at step {step_num}: {exc}"
|
||||
logger.error("ReAct loop permanent error: %s", result.error)
|
||||
return result
|
||||
|
||||
if category == ErrorCategory.TRANSIENT and retry < _MAX_TRANSIENT_RETRIES:
|
||||
backoff = 2 ** retry
|
||||
logger.warning(
|
||||
"Transient error at step %d (retry %d/%d): %s — retrying in %ds",
|
||||
step_num, retry + 1, _MAX_TRANSIENT_RETRIES, exc, backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
# PARTIAL or exhausted retries
|
||||
if category == ErrorCategory.PARTIAL:
|
||||
logger.warning("Partial error at step %d: %s — continuing", step_num, exc)
|
||||
last_error = str(exc)
|
||||
break
|
||||
|
||||
# Exhausted transient retries
|
||||
result.status = "stopped_error"
|
||||
result.error = f"Error after {retry + 1} retries at step {step_num}: {exc}"
|
||||
logger.error("ReAct loop error: %s", result.error)
|
||||
return result
|
||||
|
||||
if llm_result is None:
|
||||
result.status = "stopped_error"
|
||||
result.error = f"LLM call failed at step {step_num}: {last_error}"
|
||||
return result
|
||||
|
||||
# ── Extract response data ──
|
||||
content = llm_result.get("content", "")
|
||||
cost_usd = llm_result.get("cost_usd", 0.0)
|
||||
result.total_cost_usd += cost_usd
|
||||
|
||||
tool_calls = _extract_tool_calls(llm_result.get("raw_response"))
|
||||
|
||||
# ── No tool calls → final response ──
|
||||
if not tool_calls:
|
||||
step = ReActStep(
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=None,
|
||||
action_input=None,
|
||||
observation=None,
|
||||
cost_usd=cost_usd,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
result.steps.append(step)
|
||||
result.final_content = content
|
||||
result.steps_taken = step_num
|
||||
|
||||
# Fire hook
|
||||
await do_action(
|
||||
"agent.step",
|
||||
agent_id=str(getattr(agent_definition, "id", "")),
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=None,
|
||||
observation=None,
|
||||
cost_usd=cost_usd,
|
||||
agent_run_id=str(agent_run_id) if agent_run_id else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
# Callback
|
||||
if on_step:
|
||||
try:
|
||||
await on_step(step)
|
||||
except Exception:
|
||||
logger.debug("on_step callback failed", exc_info=True)
|
||||
|
||||
break
|
||||
|
||||
# ── Execute tool calls ──
|
||||
# Append assistant message with tool calls to conversation
|
||||
full_messages.append({
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
})
|
||||
|
||||
# Execute each tool call and collect observations
|
||||
observations: list[str] = []
|
||||
for tc in tool_calls:
|
||||
tool_name = tc["name"]
|
||||
try:
|
||||
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"])
|
||||
|
||||
if dry_run:
|
||||
observation = json.dumps(
|
||||
{
|
||||
"dry_run": True,
|
||||
"would_execute": tool_name,
|
||||
"arguments": args,
|
||||
}
|
||||
)
|
||||
elif require_approval and (approval_tools is None or tool_name in (approval_tools or [])):
|
||||
# I-APPR-LOOP: Human-in-the-Loop Approval
|
||||
# Create an ApprovalRequest and pause the loop
|
||||
try:
|
||||
from app.core.approval import create_approval_request
|
||||
pass # agent_workstream removed
|
||||
|
||||
approval = await create_approval_request(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="agent_run",
|
||||
entity_id=agent_run_id or uuid.uuid4(),
|
||||
action=f"tool:{tool_name}",
|
||||
requested_by=user_id,
|
||||
requested_by_type="agent",
|
||||
)
|
||||
|
||||
# Post approval request to Communication (I-WORK-HANDOFF)
|
||||
if agent_run_id:
|
||||
try:
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
||||
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
||||
conv_id = await komm.find_locked_room_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
plugin_name="automation",
|
||||
title=room_title,
|
||||
)
|
||||
if conv_id:
|
||||
await komm.send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conv_id,
|
||||
sender_id=agent_id,
|
||||
sender_type="agent",
|
||||
content=f"Approval required for tool '{tool_name}'",
|
||||
content_format="text",
|
||||
blocks=[
|
||||
{
|
||||
"block_type": "approval_request",
|
||||
"block_data": {
|
||||
"title": f"Approval: {tool_name}",
|
||||
"description": f"Agent wants to execute tool '{tool_name}' with arguments: {json.dumps(args)[:300]}",
|
||||
"approval_id": str(approval.id),
|
||||
"status": "pending",
|
||||
},
|
||||
"sort_order": 0,
|
||||
}
|
||||
],
|
||||
metadata={"approval_id": str(approval.id), "agent_run_id": str(agent_run_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to post approval request to communication", exc_info=True)
|
||||
|
||||
# Pause the loop — return with waiting_for_approval status
|
||||
result.status = "waiting_for_approval"
|
||||
result.error = f"Tool '{tool_name}' requires human approval (request_id: {approval.id})"
|
||||
result.steps_taken = step_num
|
||||
result.final_content = f"I need approval to execute tool '{tool_name}'. Approval request {approval.id} has been created."
|
||||
logger.info("Agent loop paused for approval on tool '%s' (request: %s)", tool_name, approval.id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create approval request for tool '%s': %s", tool_name, e)
|
||||
observation = json.dumps({"error": f"Approval required but failed to create request: {e}"})
|
||||
else:
|
||||
observation = await _execute_tool(tool_registry, tool_name, args, tool_context)
|
||||
observations.append(observation)
|
||||
|
||||
# Audit every tool call (real or simulated)
|
||||
await _audit_tool_call(
|
||||
step_number=step_num,
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
result=observation,
|
||||
cost_usd=cost_usd / len(tool_calls) if tool_calls else cost_usd,
|
||||
)
|
||||
|
||||
# Feed tool result back into conversation
|
||||
full_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": observation,
|
||||
})
|
||||
|
||||
# Record step
|
||||
step = ReActStep(
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=tool_name,
|
||||
action_input=args,
|
||||
observation=observation,
|
||||
cost_usd=cost_usd / len(tool_calls) if tool_calls else cost_usd,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
result.steps.append(step)
|
||||
|
||||
# Fire hook
|
||||
await do_action(
|
||||
"agent.step",
|
||||
agent_id=str(getattr(agent_definition, "id", "")),
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=tool_name,
|
||||
observation=observation,
|
||||
cost_usd=cost_usd,
|
||||
agent_run_id=str(agent_run_id) if agent_run_id else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
# Callback
|
||||
if on_step:
|
||||
try:
|
||||
await on_step(step)
|
||||
except Exception:
|
||||
logger.debug("on_step callback failed", exc_info=True)
|
||||
|
||||
result.steps_taken = step_num
|
||||
|
||||
# If this was the last allowed step, stop gracefully
|
||||
if step_num >= max_steps:
|
||||
result.status = "stopped_max_steps"
|
||||
result.error = f"Reached max_steps limit ({max_steps})"
|
||||
result.final_content = content
|
||||
logger.warning("ReAct loop stopped at max_steps=%d", max_steps)
|
||||
break
|
||||
|
||||
# If loop completed without a final response (e.g. all steps had tool calls)
|
||||
if not result.final_content and result.steps:
|
||||
result.final_content = result.steps[-1].thought or ""
|
||||
|
||||
if result.status == "completed" and not result.final_content:
|
||||
result.final_content = ""
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Agent permission context resolution for AI agents.
|
||||
|
||||
Resolves the effective permissions available to an agent run as the
|
||||
intersection of the user's (or run-as user's) RBAC permissions, the agent's
|
||||
configured tools/skills, and the tools each skill is allowed to use.
|
||||
|
||||
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool
|
||||
|
||||
Key principles:
|
||||
- Skills orchestrate tools but NEVER grant additional permissions.
|
||||
- Every tool/service call re-checks permissions — rights are NOT frozen
|
||||
for a run.
|
||||
- System admins get all tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.error_codes import ApiError
|
||||
from app.core.permissions import check_permission, resolve_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentPermissionContext:
|
||||
"""Effective permission context for a single agent run."""
|
||||
|
||||
user_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
run_as_user_id: uuid.UUID | None
|
||||
user_permissions: dict[str, Any] # RBAC permissions from Role
|
||||
agent_tool_ids: list[str]
|
||||
agent_skill_ids: list[str]
|
||||
effective_tool_ids: list[str] # After intersection
|
||||
is_system_admin: bool = False
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Check whether the run-as user has the given RBAC permission."""
|
||||
return check_permission(self.user_permissions, permission)
|
||||
|
||||
def can_use_tool(self, tool_id: str) -> bool:
|
||||
"""Check whether the agent may call the given tool."""
|
||||
return tool_id in self.effective_tool_ids
|
||||
|
||||
|
||||
def _resolve_effective_tool_ids(
|
||||
agent_definition: Any,
|
||||
user_permissions: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Compute the effective tool IDs after User ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Mirrors the semantics of ``app.ai.agent_tools.get_agent_tools``: skills
|
||||
orchestrate tools but never grant additional permissions.
|
||||
"""
|
||||
from app.ai.skill_registry import get_skill_registry
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
skill_registry = get_skill_registry()
|
||||
skills = skill_registry.get_by_names(agent_skill_ids)
|
||||
|
||||
direct_tool_ids = set(agent_tool_ids)
|
||||
skill_tool_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
skill_tool_ids.update(skill.allowed_tool_ids or [])
|
||||
|
||||
# Tools directly on the agent, plus tools reachable via skills that are
|
||||
# also directly on the agent (skills never widen the agent's tool set).
|
||||
available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids)
|
||||
|
||||
tool_registry = get_tool_registry()
|
||||
tools = tool_registry.get_by_names(sorted(available_tool_ids))
|
||||
|
||||
permitted = [
|
||||
tool
|
||||
for tool in tools
|
||||
if not getattr(tool, "required_permission", None)
|
||||
or check_permission(user_permissions, tool.required_permission)
|
||||
]
|
||||
return [tool.name for tool in permitted]
|
||||
|
||||
|
||||
async def resolve_agent_permissions(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_definition: Any,
|
||||
run_as_user_id: uuid.UUID | None = None,
|
||||
) -> AgentPermissionContext:
|
||||
"""Resolve effective permissions for an agent run.
|
||||
|
||||
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Args:
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: The user requesting the run (permission source).
|
||||
agent_definition: AgentDefinition with tool_ids and skill_ids.
|
||||
run_as_user_id: Optional user the agent runs as. When provided, the
|
||||
run-as user's permissions are used instead of the requester's.
|
||||
|
||||
Returns:
|
||||
AgentPermissionContext with the resolved effective tool IDs.
|
||||
"""
|
||||
effective_user_id = run_as_user_id or user_id
|
||||
user_permissions = await resolve_permissions(db, effective_user_id, tenant_id)
|
||||
is_system_admin = bool(user_permissions.get("is_system_admin", False))
|
||||
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
if is_system_admin:
|
||||
effective_tool_ids = list(agent_tool_ids)
|
||||
else:
|
||||
effective_tool_ids = _resolve_effective_tool_ids(agent_definition, user_permissions)
|
||||
|
||||
return AgentPermissionContext(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
run_as_user_id=run_as_user_id,
|
||||
user_permissions=user_permissions,
|
||||
agent_tool_ids=agent_tool_ids,
|
||||
agent_skill_ids=agent_skill_ids,
|
||||
effective_tool_ids=effective_tool_ids,
|
||||
is_system_admin=is_system_admin,
|
||||
)
|
||||
|
||||
|
||||
async def check_entity_lock(
|
||||
db: AsyncSession,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
expected_version: int,
|
||||
) -> bool:
|
||||
"""Optimistic-lock check: raise ApiError('conflict') on version mismatch.
|
||||
|
||||
Loads the entity's ``version`` column. If the current version differs from
|
||||
``expected_version``, raises ``ApiError`` with code ``conflict``. Models
|
||||
without a ``version`` column are treated as unlocked (no-op).
|
||||
|
||||
Returns True when the lock check passes.
|
||||
"""
|
||||
from app.services.entity_permission_service import ENTITY_MODELS
|
||||
|
||||
model = ENTITY_MODELS.get(entity_type)
|
||||
if model is None or not hasattr(model, "version"):
|
||||
return True
|
||||
|
||||
result = await db.execute(select(model.version).where(model.id == entity_id))
|
||||
current_version = result.scalar_one_or_none()
|
||||
if current_version is None:
|
||||
raise ApiError(code="not_found", detail=f"{entity_type} not found")
|
||||
|
||||
if int(current_version) != int(expected_version):
|
||||
raise ApiError(
|
||||
code="conflict",
|
||||
detail=(
|
||||
f"{entity_type} {entity_id} was modified concurrently "
|
||||
f"(expected version {expected_version}, current {current_version})"
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def filter_visible_agents(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agents: list,
|
||||
) -> list:
|
||||
"""Filter agents visible to a user based on agents:read + EntityPermission.
|
||||
|
||||
A user sees an agent when they have the ``agents:read`` permission AND the
|
||||
agent is visible via ownership, tenant-ownership, or entity_permissions.
|
||||
System admins see all agents.
|
||||
"""
|
||||
user_permissions = await resolve_permissions(db, user_id, tenant_id)
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return list(agents)
|
||||
if not check_permission(user_permissions, "agents:read"):
|
||||
return []
|
||||
|
||||
from app.services.permission_resolver import get_visible_ids
|
||||
|
||||
visible_ids, _ = await get_visible_ids(db, tenant_id, user_id, "agent_definition")
|
||||
return [a for a in agents if a.id in visible_ids]
|
||||
|
||||
|
||||
async def check_agent_execute_permission(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Check if a user has agents:execute permission for a specific agent.
|
||||
|
||||
Requires the ``agents:execute`` RBAC permission AND entity-level access
|
||||
(owner, tenant-owned, or shared via entity_permissions). System admins
|
||||
always pass.
|
||||
"""
|
||||
user_permissions = await resolve_permissions(db, user_id, tenant_id)
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return True
|
||||
if not check_permission(user_permissions, "agents:execute"):
|
||||
return False
|
||||
|
||||
from app.services.permission_resolver import check_entity_access
|
||||
|
||||
return await check_entity_access(
|
||||
db, tenant_id, user_id, "agent_definition", agent_id, required_level="read"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentPermissionContext",
|
||||
"resolve_agent_permissions",
|
||||
"check_entity_lock",
|
||||
"filter_visible_agents",
|
||||
"check_agent_execute_permission",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""SSE streaming for the ReAct agent loop.
|
||||
|
||||
Wraps ``run_react_loop`` from ``app.ai.agent_loop`` and emits Server-Sent
|
||||
Events (SSE) for each step, plus a final ``done`` or ``error`` event.
|
||||
|
||||
Events emitted:
|
||||
- ``event: step`` — JSON {step_number, thought, action, action_input, observation, cost_usd}
|
||||
- ``event: status`` — JSON {status: "running", step: N}
|
||||
- ``event: done`` — JSON {status, total_cost, steps_taken, final_content}
|
||||
- ``event: error`` — JSON {error, trace_id}
|
||||
|
||||
Trace modes:
|
||||
- ``standard`` — step events include action + result only (no thought)
|
||||
- ``extended`` — step events also include the thought/reasoning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||
|
||||
from app.ai.agent_loop import ReActStep, run_react_loop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# SSE helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||
"""Format a single SSE event as ``event: <name>\ndata: <json>\n\n``."""
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _step_event(step: ReActStep, trace_mode: str) -> str:
|
||||
"""Build the SSE ``step`` event for a ReAct step."""
|
||||
data: dict[str, Any] = {
|
||||
"step_number": step.step_number,
|
||||
"action": step.action,
|
||||
"action_input": step.action_input,
|
||||
"observation": step.observation,
|
||||
"cost_usd": step.cost_usd,
|
||||
}
|
||||
if trace_mode == "extended":
|
||||
data["thought"] = step.thought
|
||||
return _sse("step", data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Streaming loop
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def stream_react_loop(
|
||||
agent_definition: Any,
|
||||
user_message: str,
|
||||
tools: list[dict],
|
||||
tool_registry: Any,
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID | None = None,
|
||||
max_steps: int = 20,
|
||||
timeout_seconds: int = 300,
|
||||
trace_id: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Run the ReAct loop and yield SSE-formatted events.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with llm_model, system_prompt, etc.
|
||||
user_message: The user's message to the agent.
|
||||
tools: OpenAI-format tool schemas for function calling.
|
||||
tool_registry: ToolRegistry instance for tool execution.
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: User ID for permission context.
|
||||
agent_run_id: Optional AgentRun ID for step persistence.
|
||||
max_steps: Maximum loop iterations (default 20).
|
||||
timeout_seconds: Overall timeout (default 300).
|
||||
trace_id: Optional trace ID for correlation.
|
||||
|
||||
Yields:
|
||||
SSE-formatted event strings.
|
||||
"""
|
||||
trace_mode = getattr(agent_definition, "trace_mode", "standard") or "standard"
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
|
||||
async def on_step(step: ReActStep) -> None:
|
||||
"""Push step + status events into the queue."""
|
||||
await queue.put(_step_event(step, trace_mode))
|
||||
await queue.put(
|
||||
_sse("status", {"status": "running", "step": step.step_number})
|
||||
)
|
||||
|
||||
async def _producer() -> None:
|
||||
"""Run the loop and push the final done/error event."""
|
||||
try:
|
||||
result = await run_react_loop(
|
||||
agent_definition=agent_definition,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
tools=tools,
|
||||
tool_registry=tool_registry,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_run_id=agent_run_id,
|
||||
max_steps=max_steps,
|
||||
timeout_seconds=timeout_seconds,
|
||||
trace_id=trace_id,
|
||||
on_step=on_step,
|
||||
)
|
||||
await queue.put(
|
||||
_sse(
|
||||
"done",
|
||||
{
|
||||
"status": result.status,
|
||||
"total_cost": result.total_cost_usd,
|
||||
"steps_taken": result.steps_taken,
|
||||
"final_content": result.final_content,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — stream must not crash the consumer
|
||||
logger.exception("ReAct streaming loop failed")
|
||||
await queue.put(
|
||||
_sse("error", {"error": str(exc), "trace_id": trace_id})
|
||||
)
|
||||
finally:
|
||||
await queue.put(None) # sentinel
|
||||
|
||||
producer_task = asyncio.create_task(_producer())
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
finally:
|
||||
if not producer_task.done():
|
||||
producer_task.cancel()
|
||||
try:
|
||||
await producer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["stream_react_loop"]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tool-/Skill-Binding for AI agents.
|
||||
|
||||
Resolves the effective capabilities available to an agent as the intersection
|
||||
of the user's permissions, the agent's configured tools/skills, and the tools
|
||||
that each skill is allowed to use.
|
||||
|
||||
Key principle: Skills orchestrate tools but NEVER grant additional permissions.
|
||||
If a user does not have ``mail:read``, no skill can give them access to a
|
||||
mail-reading tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.skill_registry import SkillDefinition, SkillRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_has_permission(
|
||||
user_permissions: dict[str, Any],
|
||||
required_permission: str | None,
|
||||
) -> bool:
|
||||
"""Check whether the user has the required permission for a tool.
|
||||
|
||||
A tool without a required permission is always allowed. The check uses the
|
||||
same semantics as ``app.core.permissions.check_permission``: system admins
|
||||
pass, denied permissions block, and wildcards are supported.
|
||||
"""
|
||||
if not required_permission:
|
||||
return True
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return True
|
||||
|
||||
denied = set(user_permissions.get("denied_permissions", []) or [])
|
||||
if any(_permission_matches(denied_perm, required_permission) for denied_perm in denied):
|
||||
return False
|
||||
|
||||
granted = set(user_permissions.get("permissions", []) or [])
|
||||
return any(_permission_matches(granted_perm, required_permission) for granted_perm in granted)
|
||||
|
||||
|
||||
def _permission_matches(granted: str, required: str) -> bool:
|
||||
"""Match a granted permission against a required one, supporting wildcards."""
|
||||
if granted == required:
|
||||
return True
|
||||
g_parts = granted.split(":")
|
||||
r_parts = required.split(":")
|
||||
if len(g_parts) != len(r_parts):
|
||||
return False
|
||||
for g_part, r_part in zip(g_parts, r_parts, strict=False):
|
||||
if g_part == "*":
|
||||
continue
|
||||
if g_part != r_part:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_agent_tools(
|
||||
agent_definition: Any,
|
||||
tool_registry: Any,
|
||||
skill_registry: SkillRegistry,
|
||||
user_permissions: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[SkillDefinition]]:
|
||||
"""Get the tools and skills available to this agent.
|
||||
|
||||
Effective capabilities = User/Run-as ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with ``tool_ids`` and ``skill_ids``.
|
||||
tool_registry: ToolRegistry with ``get_by_names`` and ``get``.
|
||||
skill_registry: SkillRegistry used to resolve skill names.
|
||||
user_permissions: Resolved permission dict (``permissions``,
|
||||
``denied_permissions``, ``is_system_admin``).
|
||||
|
||||
Returns:
|
||||
A tuple of (tool_schemas, skills). ``tool_schemas`` is the list of
|
||||
OpenAI-format tool schemas the agent may actually call. ``skills`` is
|
||||
the list of resolved SkillDefinitions the agent may use.
|
||||
"""
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
# 1. Resolve the agent's skills to SkillDefinitions.
|
||||
skills = skill_registry.get_by_names(agent_skill_ids)
|
||||
|
||||
# 2. Collect the tool IDs available directly on the agent.
|
||||
direct_tool_ids = set(agent_tool_ids)
|
||||
|
||||
# 3. For each skill, collect its allowed tool IDs.
|
||||
skill_tool_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
skill_tool_ids.update(skill.allowed_tool_ids or [])
|
||||
|
||||
# 4. Intersect: agent.tool_ids ∩ skill.allowed_tool_ids → tools via skills.
|
||||
# Tools directly in agent.tool_ids (not via skills) are also available.
|
||||
available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids)
|
||||
|
||||
# 5. Resolve the available tools from the registry.
|
||||
tools = tool_registry.get_by_names(sorted(available_tool_ids))
|
||||
|
||||
# 6. Filter by user permissions: only tools where the user has the
|
||||
# required permission. Skills never grant additional permissions.
|
||||
permitted_tools = [
|
||||
tool
|
||||
for tool in tools
|
||||
if _user_has_permission(user_permissions, getattr(tool, "required_permission", None))
|
||||
]
|
||||
|
||||
# 7. Build OpenAI-format schemas and return.
|
||||
tool_schemas = [tool.to_openai_schema() for tool in permitted_tools]
|
||||
return tool_schemas, skills
|
||||
|
||||
|
||||
__all__ = ["get_agent_tools"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""AI use-case metadata and validation.
|
||||
|
||||
Defines the structured metadata that describes *why* and *how* an AI agent
|
||||
may process data. This is the governance contract for an agent definition:
|
||||
which data categories it may touch, which providers/models/actions are
|
||||
allowed, and whether human oversight is required.
|
||||
|
||||
Used by:
|
||||
- ``app/ai/data_policy.py`` — runtime enforcement of allowed data categories
|
||||
- ``app/ai/oversight.py`` — human-review policy (``oversight_policy``)
|
||||
- ``app/plugins/builtins/automation/agent_routes.py`` — PATCH/GET endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Known data categories an agent may declare it processes.
|
||||
KNOWN_DATA_CATEGORIES = (
|
||||
"contact_data",
|
||||
"email_content",
|
||||
"calendar",
|
||||
"tasks",
|
||||
"dms",
|
||||
"communication",
|
||||
"financial",
|
||||
"public",
|
||||
)
|
||||
|
||||
# Valid oversight policies.
|
||||
OVERSIGHT_POLICIES = ("always_required", "on_high_risk", "never")
|
||||
|
||||
# Valid risk classes.
|
||||
RISK_CLASSES = ("low", "medium", "high")
|
||||
|
||||
# Valid allowed actions.
|
||||
KNOWN_ACTIONS = ("read", "summarize", "draft", "send", "create", "update", "delete")
|
||||
|
||||
|
||||
class AIUseCaseMetadata(BaseModel):
|
||||
"""Structured metadata describing an AI agent's intended use case.
|
||||
|
||||
Attributes:
|
||||
intended_purpose: Human-readable description of the use case.
|
||||
owner: User ID or email responsible for the use case.
|
||||
data_categories: Data categories the agent may process.
|
||||
allowed_providers: Provider IDs the agent may use (empty = any).
|
||||
allowed_models: Model names the agent may use (empty = any).
|
||||
allowed_actions: Actions the agent may perform (empty = any).
|
||||
oversight_policy: When human review is required.
|
||||
risk_class: Risk classification of the use case.
|
||||
human_review_required: Whether a human must review outputs.
|
||||
"""
|
||||
|
||||
intended_purpose: str = Field(default="", max_length=1000)
|
||||
owner: str = Field(default="", max_length=255)
|
||||
data_categories: list[str] = Field(default_factory=list)
|
||||
allowed_providers: list[str] = Field(default_factory=list)
|
||||
allowed_models: list[str] = Field(default_factory=list)
|
||||
allowed_actions: list[str] = Field(default_factory=list)
|
||||
oversight_policy: str = Field(default="never")
|
||||
risk_class: str = Field(default="low")
|
||||
human_review_required: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "AIUseCaseMetadata":
|
||||
"""Build metadata from a raw dict (e.g. the agent's JSONB column)."""
|
||||
if not data:
|
||||
return cls()
|
||||
# Only pass known fields so unknown keys don't break validation.
|
||||
known = {k: v for k, v in data.items() if k in cls.model_fields}
|
||||
return cls(**known)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to a plain dict for JSONB storage."""
|
||||
return self.model_dump()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Validation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_ai_use_case(metadata: AIUseCaseMetadata, agent_definition: Any) -> list[str]:
|
||||
"""Validate metadata against an agent configuration.
|
||||
|
||||
Returns a list of human-readable warnings. An empty list means the
|
||||
metadata is consistent with the agent definition.
|
||||
|
||||
Checks performed:
|
||||
- ``intended_purpose`` and ``owner`` are set.
|
||||
- ``data_categories`` are known values.
|
||||
- ``oversight_policy`` and ``risk_class`` are valid.
|
||||
- ``allowed_models`` (if non-empty) include the agent's configured model.
|
||||
- ``allowed_providers`` (if non-empty) include the agent's provider.
|
||||
- ``human_review_required`` is consistent with ``oversight_policy``.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
if not metadata.intended_purpose.strip():
|
||||
warnings.append("intended_purpose is empty — describe the AI use case")
|
||||
|
||||
if not metadata.owner.strip():
|
||||
warnings.append("owner is empty — set a responsible user or email")
|
||||
|
||||
for cat in metadata.data_categories:
|
||||
if cat not in KNOWN_DATA_CATEGORIES:
|
||||
warnings.append(f"data_category '{cat}' is not a known category")
|
||||
|
||||
if metadata.oversight_policy not in OVERSIGHT_POLICIES:
|
||||
warnings.append(
|
||||
f"oversight_policy '{metadata.oversight_policy}' is invalid "
|
||||
f"(expected one of {OVERSIGHT_POLICIES})"
|
||||
)
|
||||
|
||||
if metadata.risk_class not in RISK_CLASSES:
|
||||
warnings.append(
|
||||
f"risk_class '{metadata.risk_class}' is invalid "
|
||||
f"(expected one of {RISK_CLASSES})"
|
||||
)
|
||||
|
||||
# Model / provider consistency (only if the agent pins allowed values).
|
||||
agent_model = getattr(agent_definition, "llm_model", None)
|
||||
if metadata.allowed_models and agent_model:
|
||||
# Strip provider prefix for comparison (e.g. "openai/gpt-4o" -> "gpt-4o").
|
||||
bare_model = agent_model.split("/", 1)[-1]
|
||||
if agent_model not in metadata.allowed_models and bare_model not in metadata.allowed_models:
|
||||
warnings.append(
|
||||
f"agent model '{agent_model}' is not in allowed_models {metadata.allowed_models}"
|
||||
)
|
||||
|
||||
agent_provider = getattr(agent_definition, "provider", None)
|
||||
if metadata.allowed_providers and agent_provider:
|
||||
if agent_provider not in metadata.allowed_providers:
|
||||
warnings.append(
|
||||
f"agent provider '{agent_provider}' is not in allowed_providers "
|
||||
f"{metadata.allowed_providers}"
|
||||
)
|
||||
|
||||
# Oversight consistency.
|
||||
if metadata.oversight_policy == "always_required" and not metadata.human_review_required:
|
||||
warnings.append(
|
||||
"oversight_policy is 'always_required' but human_review_required is False"
|
||||
)
|
||||
if metadata.oversight_policy == "never" and metadata.human_review_required:
|
||||
warnings.append(
|
||||
"oversight_policy is 'never' but human_review_required is True"
|
||||
)
|
||||
|
||||
return warnings
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Context builder — assembles the message list for an AI agent run.
|
||||
|
||||
Builds the full chat context (system prompt + user message) for a ReAct agent
|
||||
from its ``AgentDefinition`` plus runtime context (user, tenant, memory,
|
||||
tools). Sensitive fields are never included in the context — the builder
|
||||
respects ``SENSITIVE_FIELDS`` from ``app.core.sensitive_data``.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.ai.context_builder import build_agent_context
|
||||
|
||||
messages = await build_agent_context(
|
||||
agent_definition=agent,
|
||||
user_message="Summarize recent emails",
|
||||
db=db_session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
memory_items=[{"content": "...", "metadata": {...}}],
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.core.sensitive_data import sanitize_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default ReAct instruction prefix appended to the agent's system prompt.
|
||||
_REACT_PREFIX = (
|
||||
"You operate in a ReAct (Reasoning + Acting) loop. For each step you must "
|
||||
"produce a Thought, then an Action (a tool call), then observe the result "
|
||||
"and continue. When you have enough information to answer the user, stop "
|
||||
"calling tools and provide your final answer directly.\n\n"
|
||||
"Format:\n"
|
||||
"Thought: <your reasoning>\n"
|
||||
"Action: <tool name>\n"
|
||||
"Action Input: <JSON arguments>\n"
|
||||
"Observation: <tool result>\n"
|
||||
"... (repeat as needed) ...\n"
|
||||
"Final Answer: <your response to the user>\n"
|
||||
)
|
||||
|
||||
|
||||
class ReActSystemPromptBuilder:
|
||||
"""Builds the ReAct system prompt for an agent definition.
|
||||
|
||||
Sections:
|
||||
- Agent identity (name, description, capabilities)
|
||||
- Available tools (name + description only — schemas come via the tools param)
|
||||
- ReAct format instructions
|
||||
- Constraints (max_steps, budget, what the agent can/cannot do)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_definition: Any,
|
||||
tool_descriptions: list[dict[str, str]] | None = None,
|
||||
max_steps: int | None = None,
|
||||
budget_limit_usd: float | None = None,
|
||||
) -> None:
|
||||
self.agent_definition = agent_definition
|
||||
self.tool_descriptions = tool_descriptions or []
|
||||
self.max_steps = max_steps
|
||||
self.budget_limit_usd = budget_limit_usd
|
||||
|
||||
def build(self) -> str:
|
||||
"""Return the full system prompt string."""
|
||||
sections: list[str] = []
|
||||
|
||||
# 1. Agent identity
|
||||
sections.append(self._identity_section())
|
||||
|
||||
# 2. Available tools
|
||||
sections.append(self._tools_section())
|
||||
|
||||
# 3. ReAct format instructions
|
||||
sections.append(_REACT_PREFIX)
|
||||
|
||||
# 4. Constraints
|
||||
sections.append(self._constraints_section())
|
||||
|
||||
# 5. Base system prompt from the agent definition
|
||||
base_prompt = getattr(self.agent_definition, "system_prompt", "") or ""
|
||||
if base_prompt:
|
||||
sections.append(base_prompt)
|
||||
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
def _identity_section(self) -> str:
|
||||
"""Agent identity: name, description, capabilities."""
|
||||
name = getattr(self.agent_definition, "name", "") or "AI Agent"
|
||||
description = getattr(self.agent_definition, "description", "") or ""
|
||||
capabilities = getattr(self.agent_definition, "capabilities", None) or []
|
||||
|
||||
lines = [f"You are {name}."]
|
||||
if description:
|
||||
lines.append(f"Description: {description}")
|
||||
if capabilities:
|
||||
caps = ", ".join(str(c) for c in capabilities)
|
||||
lines.append(f"Capabilities: {caps}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tools_section(self) -> str:
|
||||
"""Available tools — name + description only (no full schema)."""
|
||||
if not self.tool_descriptions:
|
||||
return "You have no tools available. Answer from your own knowledge."
|
||||
lines = ["Available tools:"]
|
||||
for tool in self.tool_descriptions:
|
||||
name = tool.get("name", "")
|
||||
description = tool.get("description", "")
|
||||
if name:
|
||||
lines.append(f"- {name}: {description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _constraints_section(self) -> str:
|
||||
"""Constraints: max_steps, budget, and behavioral limits."""
|
||||
constraints: list[str] = []
|
||||
|
||||
max_steps = self.max_steps or getattr(
|
||||
self.agent_definition, "max_steps", None
|
||||
) or 20
|
||||
constraints.append(f"- Maximum {max_steps} reasoning steps per run.")
|
||||
|
||||
budget = self.budget_limit_usd
|
||||
if budget is None:
|
||||
budget = getattr(self.agent_definition, "budget_limit_usd", None)
|
||||
if budget is not None and budget > 0:
|
||||
constraints.append(f"- Budget limit: ${float(budget):.2f} per run.")
|
||||
|
||||
constraints.append(
|
||||
"- Only call tools that are listed as available. Do not invent tools."
|
||||
)
|
||||
constraints.append(
|
||||
"- Never expose or request passwords, API keys, tokens, or other "
|
||||
"sensitive credentials."
|
||||
)
|
||||
constraints.append(
|
||||
"- Respect tenant data boundaries. Do not access data outside the "
|
||||
"current tenant."
|
||||
)
|
||||
|
||||
return "Constraints:\n" + "\n".join(constraints)
|
||||
|
||||
|
||||
async def _load_user_context(
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
run_as_user_id: uuid.UUID | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load user + tenant context from the DB with graceful fallbacks."""
|
||||
context: dict[str, Any] = {
|
||||
"user_name": None,
|
||||
"tenant_name": None,
|
||||
"role": None,
|
||||
}
|
||||
if db is None:
|
||||
return context
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
effective_user_id = run_as_user_id or user_id
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == effective_user_id).limit(1)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
context["user_name"] = user.name or user.email
|
||||
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id).limit(1)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is not None:
|
||||
context["tenant_name"] = tenant.name
|
||||
|
||||
role_result = await db.execute(
|
||||
select(UserTenant.role)
|
||||
.where(UserTenant.user_id == effective_user_id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
role = role_result.scalar_one_or_none()
|
||||
if role:
|
||||
context["role"] = role
|
||||
except Exception:
|
||||
logger.debug("Failed to load user/tenant context", exc_info=True)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def build_agent_context(
|
||||
agent_definition: Any, # AgentDefinition from automation models
|
||||
user_message: str | None,
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
run_as_user_id: uuid.UUID | None = None,
|
||||
memory_items: list[dict] | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the full message list for an agent run.
|
||||
|
||||
Returns a list of chat messages: a system message (built by
|
||||
``ReActSystemPromptBuilder``) followed by the user message. Sensitive
|
||||
fields are redacted from any injected context.
|
||||
"""
|
||||
# 1. Resolve the tools the agent has access to (filtered by tool_ids).
|
||||
tool_descriptions: list[dict[str, str]] = []
|
||||
tool_ids = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
try:
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
if tool_ids:
|
||||
tools = registry.get_by_names(tool_ids)
|
||||
else:
|
||||
tools = registry.get_all()
|
||||
tool_descriptions = [
|
||||
{"name": t.name, "description": t.description} for t in tools
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("Failed to load tool descriptions", exc_info=True)
|
||||
|
||||
# 2. Build the system prompt.
|
||||
builder = ReActSystemPromptBuilder(
|
||||
agent_definition=agent_definition,
|
||||
tool_descriptions=tool_descriptions,
|
||||
)
|
||||
system_prompt = builder.build()
|
||||
|
||||
# 3. Load user/tenant context.
|
||||
user_ctx = await _load_user_context(
|
||||
db, tenant_id, user_id, run_as_user_id
|
||||
)
|
||||
|
||||
# 4. Assemble the context block (redacting sensitive fields).
|
||||
context_lines: list[str] = []
|
||||
if user_ctx.get("user_name"):
|
||||
context_lines.append(f"Current user: {user_ctx['user_name']}")
|
||||
if user_ctx.get("tenant_name"):
|
||||
context_lines.append(f"Current tenant: {user_ctx['tenant_name']}")
|
||||
if user_ctx.get("role"):
|
||||
context_lines.append(f"Current user role: {user_ctx['role']}")
|
||||
|
||||
if memory_items:
|
||||
context_lines.append("Relevant memory items:")
|
||||
for item in memory_items:
|
||||
content = item.get("content", "") if isinstance(item, dict) else str(item)
|
||||
if content:
|
||||
context_lines.append(f"- {content}")
|
||||
|
||||
# 5. Build the final message list.
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
if context_lines:
|
||||
context_block = "\n".join(context_lines)
|
||||
messages.append(
|
||||
{"role": "system", "content": f"Context:\n{context_block}"}
|
||||
)
|
||||
|
||||
if user_message:
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
__all__ = ["ReActSystemPromptBuilder", "build_agent_context"]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Runtime provider / data policy enforcement for AI agents.
|
||||
|
||||
Filters messages and context before they reach the LLM based on:
|
||||
- Sensitive fields (``app.core.sensitive_data.SENSITIVE_FIELDS``)
|
||||
- AI use-case metadata (allowed data categories)
|
||||
- Provider compliance (data residency / allowed data classes)
|
||||
|
||||
This is the enforcement layer that guarantees an agent never sends data it
|
||||
is not permitted to process to a provider that is not approved for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.ai_use_case import AIUseCaseMetadata
|
||||
from app.core.sensitive_data import (
|
||||
SENSITIVE_FIELDS,
|
||||
filter_for_llm_context,
|
||||
get_data_class_for_field,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data categories that map to entity types for sensitive-field filtering.
|
||||
_CATEGORY_ENTITY_MAP = {
|
||||
"contact_data": "contact",
|
||||
"email_content": "mail_account",
|
||||
"communication": "mail_account",
|
||||
}
|
||||
|
||||
|
||||
async def enforce_data_policy(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
messages: list[dict[str, Any]],
|
||||
agent_definition: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter messages/context based on the data policy.
|
||||
|
||||
Steps:
|
||||
1. Remove sensitive fields from any dict content in the messages.
|
||||
2. Check AI use-case metadata for allowed data categories.
|
||||
3. Check provider compliance for data residency requirements.
|
||||
|
||||
Args:
|
||||
db: Async DB session (may be ``None`` in tests / mock mode).
|
||||
tenant_id: Tenant ID for provider lookup.
|
||||
messages: The chat messages to filter.
|
||||
agent_definition: AgentDefinition with ``ai_use_case_metadata``.
|
||||
|
||||
Returns:
|
||||
A new list of messages with disallowed data removed.
|
||||
"""
|
||||
metadata = AIUseCaseMetadata.from_dict(
|
||||
getattr(agent_definition, "ai_use_case_metadata", None)
|
||||
)
|
||||
|
||||
# Provider compliance (data residency / allowed data classes).
|
||||
compliance: dict[str, Any] | None = None
|
||||
if db is not None and tenant_id is not None:
|
||||
try:
|
||||
from app.ai.llm_client import get_provider_compliance
|
||||
|
||||
compliance = await get_provider_compliance(db, tenant_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to load provider compliance — skipping residency check")
|
||||
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, dict):
|
||||
content = _filter_dict_content(
|
||||
content, metadata, compliance, agent_definition
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
content = [
|
||||
_filter_dict_content(c, metadata, compliance, agent_definition)
|
||||
if isinstance(c, dict)
|
||||
else c
|
||||
for c in content
|
||||
]
|
||||
new_msg = dict(msg)
|
||||
new_msg["content"] = content
|
||||
filtered.append(new_msg)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _filter_dict_content(
|
||||
data: dict[str, Any],
|
||||
metadata: AIUseCaseMetadata,
|
||||
compliance: dict[str, Any] | None,
|
||||
agent_definition: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Filter a single dict (entity payload) against the data policy."""
|
||||
# 1. Remove sensitive fields (always blocked from LLM context).
|
||||
result = _strip_sensitive_fields(data)
|
||||
|
||||
# 2. Enforce allowed data categories from AI use-case metadata.
|
||||
if metadata.data_categories:
|
||||
result = _filter_by_allowed_categories(result, metadata.data_categories)
|
||||
|
||||
# 3. Provider compliance — block fields whose data class the provider
|
||||
# is not approved to process.
|
||||
if compliance is not None:
|
||||
result = _filter_by_provider_compliance(result, compliance)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _strip_sensitive_fields(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively remove any key that matches a sensitive field name."""
|
||||
sensitive_names = set()
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
sensitive_names |= fields
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in sensitive_names:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = _strip_sensitive_fields(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_strip_sensitive_fields(v) if isinstance(v, dict) else v
|
||||
for v in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _filter_by_allowed_categories(
|
||||
data: dict[str, Any], allowed_categories: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove entity-type payloads whose category is not allowed.
|
||||
|
||||
Uses the category→entity mapping to decide whether a dict represents a
|
||||
disallowed entity type. Unknown dicts are kept (fail-open for generic
|
||||
context that has no clear entity type).
|
||||
"""
|
||||
# Determine the entity type of this dict by checking for known keys.
|
||||
entity_type = _guess_entity_type(data)
|
||||
if entity_type is None:
|
||||
return data
|
||||
|
||||
category = _entity_to_category(entity_type)
|
||||
if category is not None and category not in allowed_categories:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _filter_by_provider_compliance(
|
||||
data: dict[str, Any], compliance: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove fields whose data class the provider may not process."""
|
||||
allowed_classes = compliance.get("allowed_data_classes") or []
|
||||
if not allowed_classes:
|
||||
return data # No restriction configured (fail-open).
|
||||
|
||||
from app.core.sensitive_data import check_provider_compliance
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _filter_by_provider_compliance(value, compliance)
|
||||
continue
|
||||
# Determine data class for this field (best-effort).
|
||||
data_class = _guess_data_class(key, value)
|
||||
if check_provider_compliance(allowed_classes, data_class):
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _guess_entity_type(data: dict[str, Any]) -> str | None:
|
||||
"""Best-effort guess of the entity type from dict keys."""
|
||||
if any(k in data for k in ("email", "smtp_password", "imap_password")):
|
||||
return "mail_account"
|
||||
if any(k in data for k in ("first_name", "last_name", "company_id")):
|
||||
return "contact"
|
||||
if any(k in data for k in ("secret_key", "encryption_key")):
|
||||
return "system_settings"
|
||||
return None
|
||||
|
||||
|
||||
def _entity_to_category(entity_type: str) -> str | None:
|
||||
"""Map an entity type to a data category."""
|
||||
for category, entity in _CATEGORY_ENTITY_MAP.items():
|
||||
if entity == entity_type:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
def _guess_data_class(key: str, value: Any) -> str:
|
||||
"""Best-effort data class for a field (defaults to 'internal')."""
|
||||
# Sensitive field names are always critical.
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
if key in fields:
|
||||
return "critical"
|
||||
# Heuristic: values that look like credentials/tokens are critical.
|
||||
if isinstance(value, str) and any(
|
||||
marker in key.lower() for marker in ("password", "token", "secret", "key")
|
||||
):
|
||||
return "critical"
|
||||
return "internal"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user