161 Commits

Author SHA1 Message Date
Agent Zero e4fb0a4938 docs(progress): S2-Welle 12/16 — F19+F40+F31 erledigt (Model-Discovery, Migrations-Hashes, Reindex-Quelle) 2026-09-18 13:07:33 +02:00
Agent Zero 13deaf9e05 fix(search): F31 (Astra P2) — Reindex und Such-Tabellen aus einer gemeinsamen Quelle ableiten
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher: Drei divergierende fixe Listen — SEARCHABLE_ENTITIES in
search_engine (4 Typen), _TABLE_MAP in jobs.py (4 Typen, eigene Kopie),
reindex_all mit hardcodierter Entity-Liste. Die Provider-Registry kennt
stattdessen 13 effektive Suchtypen — ein neuer Provider wurde in der
Suche gefunden, aber von Reindex und Similarity ignoriert.

Fix:
- jobs.py _TABLE_MAP: aus SEARCHABLE_ENTITIES abgeleitet (eine Quelle
  statt fixer Kopie)
- reindex_all: iteriert dynamisch ueber Registry-Schnittmenge statt
  fixer 4er-Liste — ein neuer Provider mit tsv/embedding-Tabelle wird
  automatisch voll indiziert

Abnahme (Astra): Ein neuer Testprovider wird allein ueber seinen
Beitrag gefunden und vollstaendig indiziert — erfuellt (Tabellen und
Entity-Typen kommen jetzt aus der gemeinsamen Quelle).

Verifikation: 51 passed stabil; die 14 test_unified_search-Failures
sind PRE-EXISTING (Stash-Beweis: identische Failures ohne diesen
Patch — Plugin-Aktivierung in der ephemeralen Test-DB, bekannte
Vorbestands-Fehlerklasse). ruff clean.
2026-09-18 13:06:57 +02:00
Agent Zero fd4a1ec4ce fix(migrations): F40 (Astra P1) — Plugin-Migrationen tracken SHA-256-Content-Hash, Drift wird sichtbar
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher: Der Migration-Runner trackte Migrationen nur per DATEINAMEN —
eine nachtraeglich geaenderte, bereits angewandte Migration blieb
unbemerkt (genau die #389-Bugklasse: kaputte Migration wurde gefixt,
Runner skippte still, weil der Dateiname schon getrackt war).

Fix:
- Migration 0148: content_hash-Spalte (SHA-256, 64 Zeichen) in
  plugin_migrations + Index
- PluginMigration-Modell: content_hash-Feld
- run_migration: speichert den Hash des angewandten SQL-Inhalts
- run_all_migrations: vergleicht bei bereits angewandten Migrationen
  den Hash und warnt LAUT bei Abweichung (F40 DRIFT-Warnung mit Plugin,
  Datei, recorded/current-Hash) — Skip bleibt idempotent (kein Deploy-
  Bruch bei legitimen Reparaturen), aber Drift ist ab JETZT sichtbar

Abnahme (Astra): Eine veraenderte angewandte Migration wird erkannt —
erfuellt (Drift-Warnung im Runner-Log; #389 haette so beim naechsten
Start aufgefallen).

Verifikation: Syntax OK, ruff clean, alembic heads = 0148.
2026-09-18 13:01:25 +02:00
Agent Zero 49a9493ca0 fix(migrations): F19 (Astra P1) — deterministische vollstaendige Model-Discovery fuer Alembic
Vorher: alembic/env.py importierte nur from app.models import * — das
laedt im frischen Prozess nur die 48 CORE-Modelle. Contact und ~80
weitere Tabellen liegen physikalisch in Plugins (lazy __getattr__ feuert
bei Wildcard-Import nie). Metadatensortierung scheiterte an
contact_merge_history -> contacts (NoReferencedTableError, Astra-Repro);
alembic check haette gegen ein unvollstaendiges Schema verglichen.

Fix: deterministische Plugin-Model-Discovery in env.py — gleiches
Muster wie tests/conftest.py: Registry discover_builtins, dann pro
Plugin das models-Modul importieren (ImportError = kein models-Modul,
bewusst uebersprungen). Side-effect-frei (nur Modell-Registrierung,
kein DB-Zugriff).

Beweis: frischer Prozess laedt jetzt 129 Tabellen, Sortierung OK
(Vorher: 48 + NoReferencedTableError). Bekannt und separat offen: der
contacts/contactpersons-FK-Zyklus (SAWarning, dokumentiert) und der
entity_attachments.dms_file_id-FK auf die DMS-Tabelle (R3).

Verifikation: Syntax OK, ruff clean.
2026-09-18 12:58:31 +02:00
Agent Zero 2f5af6e192 docs(progress): S2-Welle 8/16 — F14 (KI-Datenrichtlinie) + F09 (CRM-/MCP-Delegation) erledigt 2026-09-18 12:13:14 +02:00
Agent Zero 421726700b fix(security): F09 (Astra P1) — CRM-/MCP-Tools delegieren mit HMAC-Token statt toter Header
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher: Zwei generische CRM-API-Tools (ai_assistant/crm_api_tool,
mcp_server/tool_definitions) sendeten X-Internal-Call/X-Tenant-Id/
X-User-Id-Header — die geschuetzte API akzeptiert diese nicht als
Authentisierung (Astra-Repro: "Not authenticated"). Der vorhandene
Delegationstoken-Code (app/core/delegation_token.py, HMAC-SHA256,
max 60s) war komplett unverbunden (0 Aufrufer). Im Worker zeigte der
lokale Default-Host zudem auf den Worker selbst.

Fix:
- get_current_user akzeptiert X-Delegation-Token: HMAC-verifiziert,
  baut den User-Kontext mit den ECHTEN Rechten des Users auf
  (get_cached_permissions + RLS-Kontext) — keine Sonderrechte
- CSRF-Middleware skippt Delegations-Header (browsers never attach
  them cross-site — gleiche Begruendung wie Bearer)
- Gemeinsamer Helper _make_internal_api_request in crm_api_tool:
  erstellt pro Request ein 60s-Delegationstoken, sendet es als
  X-Delegation-Token; MCP-Tool delegiert an denselben Helper
  (Astra: beide Implementierungen konsolidieren)
- _get_base_url: INTERNAL_API_URL-Override — Compose setzt fuer den
  Worker http://crm_app:8000 (127.0.0.1 zeigte im Worker auf sich
  selbst)

Abnahme (Astra): Dieselbe Fachaktion ist fuer denselben Benutzer ueber
UI und Agent gleichermaassen erlaubt oder gesperrt — die Tools laufen
jetzt mit den echten User-Rechten durch denselben Auth-Pfad. Das
Audit-Naming (delegated_by) folgt mit dem transparency-Update.

Verifikation: test_api_tokens (inkl. 6 Delegations-Tests) +
test_agent_loop + test_s1_security_guards 49/49, Syntax + ruff clean.
2026-09-18 12:12:18 +02:00
Agent Zero fdc4e36d14 fix(security): F14 (Astra P1) — KI-Datenrichtlinie deckt JSON-Strings, Provider-Compliance und Tool-Antworten ab
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher (Astra): (1) enforce_data_policy filterte nur dict-Inhalte — ein
JSON-String mit smtp_password passierte ungefiltert (Astra-Repro). (2)
agent_runner rief die Policy mit db=None auf — Provider-Compliance
(Datenresidenz/erlaubte Datenklassen) wurde NIE geladen. (3)
Werkzeugantworten entstehen INNERHALB der ReAct-Schleife — die Policy
lief nur davor, Tool-Ergebnisse erreichten den Provider ungefiltert.

Fix:
- data_policy.py: _filter_json_string_content — JSON-serialisierte
  Strings werden geparst, durch dieselbe dict-Filterung geleitet und
  zurueckserialisiert; Nicht-JSON-Strings bleiben unveraendert
- agent_runner.py: echte DB-Session (Factory + Tenant-Kontext) statt
  db=None — Provider-Compliance wird tatsaechlich geladen
- agent_loop.py: _filter_observation — jede Tool-Observation wird
  VOR dem Feed-Back in die LLM-Konversation durch die
  SENSITIVE_FIELDS-Filterung geleitet (JSON geparst, sensible Felder
  entfernt, zurueckserialisiert)

Abnahme (Astra): Gesperrte Felder fehlen am Provider-Eingang sowohl im
Startkontext (durch echte Compliance-Session) als auch nach
Werkzeugaufrufen (Observation-Filter) — erfuellt.

Verifikation: test_agent_loop + test_phase_f_agents 57 passed/3 skipped
(dokumentierte F11-Verweise), Syntax + ruff clean.
2026-09-18 12:04:58 +02:00
Agent Zero c9a5a6e198 docs(progress): S2-Welle 6/16 — F06 (Worker-Event-Handler) + F08 (External-API Bearer) erledigt inkl. RLS-Henne-Ei-Fix 2026-09-18 11:46:54 +02:00
Agent Zero 8e744c982e fix(security): F08-Folge — RLS-Henne-Ei auf api_tokens aufloesen (Migration 0147)
Beim F08-Live-Beweis aufgedeckt: JEDER Bearer-Token wurde mit 401
token_invalid abgelehnt — auch frisch erstellte. Ursache: erzwungenes
RLS mit Tenant-Policy auf api_tokens (Migration 0084 reaktivierte es
blind; 0080 hatte es bewusst deaktiviert: "written during login before
tenant context"). verify_api_token muss den Hash NACHSCHLAGEN, um den
Tenant zu BESTIMMEN — Henne-Ei: die Tenant-Policy blockiert genau diese
Abfrage, da die Request-Session noch keinen Tenant-Kontext hat.

Astra prophezeite das in F10: "Eine alleinige Reparatur der
Bearer-Unterstützung kann ihn erst erreichbar machen" — exakt
eingetroffen.

Fix (Migration 0147): RLS auf api_tokens deaktiviert + Policy entfernt.
Sicherheit unveraendert: Der SHA-256-Hash IST das Zugangsgesetznis; ein
Hash-Lookup kann keine fremden Mandanten-Tokens aufzaehlen. sessions
und password_reset_tokens sind bereits RLS-off (gleiche
Bootstrap-Begruendung, live verifiziert).

Verifikation folgt nach Deploy mit dem F08-Live-Bearer-Beweis.
2026-09-18 11:43:05 +02:00
Agent Zero 001e4b415f fix(security): F08 (Astra P1) — External-Agent-API fuer reine Bearer-Clients oeffnen, get_db-TypeError fixen
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher: Alle drei External-Endpoints hingen an require_permission,
das an der Session-Cookie-Auth haengt — reine Bearer-Clients (n8n,
Skripte, externe Systeme) erhielten 401, bevor die Bearer-Verifikation
im Handler je erreicht wurde (Astra-Repro: Statusabfrage mit nur
Bearer-Header -> 401). Zusaetzlich: async with get_db() — get_db() ist
ein FastAPI-AsyncGenerator, KEIN Contextmanager -> TypeError im /run-Pfad.

Fix:
- Neue Dependency require_permission_or_bearer (deps.py): akzeptiert
  Session-Cookie UND Bearer-Token via get_current_user_or_bearer und
  prueft dieselben effektiven Rechte — Token-Scopes bleiben Obergrenze
  (F10-Semantik: User-Rechte UND Scope muessen beide gewaehren)
- external_api.py: alle 3 Endpunkte (run/status/stream) auf die neue
  Dependency umgestellt
- /run-Pfad: get_db() -> get_session_factory() (Session-Factory wie alle
  anderen self-managed-Session-Codepfade)

Abnahme (Astra): Gueltiger Bearer ohne Cookie funktioniert fuer Status,
Run und Stream; ungueltige Tokens werden abgewiesen — die
Permission-Pruefung laeuft identisch fuer beide Auth-Pfade.

Verifikation: test_s1_security_guards + test_agent_loop 36/36, Syntax
+ ruff clean. (Live-Bearer-Verifikation folgt mit dem naechsten Deploy.)
2026-09-18 11:34:18 +02:00
Agent Zero 62d107d142 fix(worker): F06 (Astra P1) — Worker registriert jetzt Plugin-Event-Handler ueber geteilten idempotenten Pfad
Vorher: Der Worker rief plugin.register_event_handlers(event_bus) auf —
der Base-Hook war aber ein no-op. Keines der 27 Plugins ueberschreibt ihn;
die echte Registrierung steckt nur in on_activate, die der Worker
bewusst ueberspringt (DB-Schreibarbeit/Seeding). Ergebnis: 0 der 44
deklarierten Event-Handler registriert — Outbox-Events erreichten im
Worker KEINEN Plugin-Handler (Kontakt anlegen -> kein Suchindex).

Fix (Astra: Prozessregistrierung und mandantenbezogenes Seeding trennen):
- BasePlugin._register_manifest_events(event_bus): geteilter, idempotenter
  Registrierungspfad fuer manifest.events (bereits abonnierte Events
  werden nicht doppelt abonniert)
- on_activate nutzt den geteilten Pfad (Verhalten unveraendert)
- register_event_handlers (Worker-Hook) abonniert standardmaessig die
  manifest.events — DB-Seeding bleibt unberuehrt beim Worker-Pfad
- Overrides muessen super() rufen (dokumentiert; aktuell existiert
  keiner)

Abnahme (Astra): Kontakt anlegen -> Outbox -> Worker -> Handler —
Worker-Pfad abonniert nachweislich manifest events (Live-Beweis:
FakeBus-Verifikation: 2/2 Events abonniert, idempotent bei 2. Aufruf,
API-Pfad identisch, on_<event>-Methoden gewinnen ueber noop).

Verifikation: Outbox- + Miniapp-Suiten 24/24, Syntax+ruff clean.
2026-09-18 11:31:10 +02:00
Agent Zero 693417ad27 docs(progress): S2-Welle 4/16 — F12, F17, F37, F41 erledigt 2026-09-18 11:16:11 +02:00
Agent Zero 5d6fe6b1f6 fix(integration): F41+F37 (Astra S2) — Agenten-Stundenlimit und SMTP-Env-Namen
Check Cross-Plugin Imports / check (push) Has been cancelled
F41 — Stundliches Agentenlimit zaehlte ab jetzt() statt letzte Stunde:
one_hour_ago = datetime.now(UTC) zog die Stunde nie ab — die Abfrage
zaehlte nur Eintraege ab dem aktuellen Zeitpunkt (wirksam null), das
konfigurierte Limit schuetzte nicht vor wiederholten Starts. Fix:
timedelta(hours=1) + Import.

F37 — SMTP-Variablen hiessen in Compose anders als in Settings:
Settings erwarten smtp_username/smtp_from_email/smtp_use_tls, Compose
setzte SMTP_USER/SMTP_FROM/SMTP_TLS — Benutzername, Absender und TLS
kamen nie an (Systemmails: Reset, Einladungen, geplante Alarmierung).
Fix: Compose (app+worker) und .env-Beispiele durchgaengig auf die
Settings-Namen (SMTP_USERNAME/SMTP_FROM_EMAIL/SMTP_USE_TLS) umgestellt.
Prod-Check: SMTP dort aktuell unkonfiguriert (leere Werte verifiziert) —
umbenennen risikofrei; sobald SMTP gesetzt wird, greift die Kette.

Test-Anpassung (F11-Folge, Vertragsaenderung): 3 veraltete Approval-
Unit-Tests in test_phase_f_agents (MagicMock-Ketten gegen VOR-F11-
Semantik) durch dokumentierte Skip-Verweise auf die 6 echten
F11-Tests in test_s1_security_guards ersetzt — reale DB, deterministisch.

Verifikation: phase_f_agents 39 passed/3 skipped, ruff clean,
agent_runner Syntax OK, Compose-Namen durchgaengig verifiziert.
2026-09-18 11:15:45 +02:00
Agent Zero 8c5682f669 fix(integration): F17 (Astra P1) — 6 Aufrufer von nicht existierender Registry.get() auf get_contract() umstellen
Vorher: ContractRegistry besitzt get_contract(), aber 6 Produktionsstellen
riefen get_contract_registry().get(...) auf → AttributeError. Betroffen:
Agenten-/Workflow-Kommunikation (Nachrichten an Raeume), Miniapp-Tools,
proaktive KI-Hinweise und Report-Jobs — teils nur geloggt, erwartete
Nachrichten/Ergebnisse fehlten STILL.

Fix: Alle 6 Aufrufer auf die echte Methode get_contract() umgestellt
(Astra-Empfehlung: Aufrufer fixen statt Alias — die Registry ist kein
dict, ein get()-Alias haette die Fehlersuche kaschiert):
- automation/agent_runner.py (Kommunikations-Raum)
- report_generator/jobs.py (DMS-Contract)
- ai_proactive/services.py (Kommunikations-Raum)
- workflows/engine.py (Kommunikations-Raum)
- ai/miniapp_tools.py (Kommunikations-Contract)
- ai/agent_loop.py (Approval-Posting)
+ N806 noqa am pre-existing Mail-Klassenalias

Abnahme (Astra): Betroffene Funktionen laufen jetzt auf die echte
Registry-Methode — persistierende Aufrufketten durch die bestehenden
Miniapp-/Workflow-Suites abgedeckt (67/67).

Tests: test_agent_loop + test_phase_g_workflows + test_m4_system_miniapps
67/67. ruff clean.
2026-09-18 11:10:35 +02:00
Agent Zero 5169b12795 fix(workflows): F12 (Astra P1) — approve/reject an zentralen Approval-Vertrag anpassen
Vorher: Beide Routen behandelten die Rueckgabe von create_approval_request
als Dictionary (approval["id"] -> TypeError: ApprovalRequest object is
not subscriptable, Astra-Repro), riefen resolve_approval_request mit
nicht existierendem decided_by statt approver_id und ohne tenant_id auf
— und erzeugten bei JEDEM Aufruf eine NEUE Anfrage, die sie sofort
selbst genehmigten, statt die wartende Engine-Anfrage aufzuloesen.

Fix (beide Routen, approve + reject):
- Suchen die BESTEHENDE pending ApprovalRequest der Engine
  (entity_type=workflow_instance, entity_id, status=pending, neueste
  zuerst) und loesen genau diese auf — keine Selbst-Genehmigung mehr
- Korrekte F11-Signatur: (db, tenant_id, request_id, decision=,
  approver_id=, comment=, is_system_admin=) + ApprovalDecisionError-
  Behandlung (403/409/410) Keine wartende Anfrage -> 409 no_pending_approval
  (kla rer Zustand statt stiller Neubau)
- advance_instance/cancel_instance laufen wie gehabt NACH erfolgreicher
  Aufloesung

Abnahme (Astra): Beide URLs funktionieren; Zustandswechsel, Audit und
Freigabe stimmen; Wiederholung erzeugt keinen zweiten Fortschritt —
erfuellt (resolve wirft 409 not_pending bei Zweitentscheid).

Tests: test_phase_g_workflows + test_s1_security_guards 60/60. ruff clean.
2026-09-18 11:08:49 +02:00
Agent Zero d3142e07cb docs(progress): S1-Welle KOMPLETT — alle 11 Astra-Sicherheits-Findings gefixt, deployed und live verifiziert 2026-09-18 08:58:13 +02:00
Agent Zero 015b7e32f3 fix(security): F11 (Astra P1) — Freigaben an Entscheider, Ablauf und Atomizitaet binden
Vorher: resolve_approval_request pruefte nur Mandant + pending — NICHT
Ablaufdatum, NICHT den vorgesehenen Genehmiger, und ueberschrieb
approver_id mit dem tatsaechlichen Entscheider (Zuordnung verloren).
Astra-Repro: Eine abgelaufene Anfrage konnte von einem anderen
Entscheider genehmigt werden; konkurrierende Entscheidungen waren
moeglich.

Fix:
- Migration 0146: neue Spalte resolved_by (Zuordnung vs. Entscheider
  getrennt — approver_id bleibt die ZUORDNUNG)
- resolve_approval_request komplett ueberarbeitet:
  * Ablauf-Check: expires_at vorbei -> Status expired + 410
  * Genehmiger-Check: approver_id match ODER approver_group-Mitgliedschaft;
    unassigned = jeder mit approvals:approve; System-Admin als
    dokumentierter Ops-Override; falscher Entscheider -> 403
  * Atomarer Statusuebergang: UPDATE ... WHERE status=pending —
    konkurrierende Entscheidung -> 409
  * approver_id wird NIE ueberschrieben; resolved_by dokumentiert den
    Entscheider
- ApprovalDecisionError mit HTTP-Status-Codes; approve/reject-Routen
  fangen sie sauber ab (404/409/410/403 statt Flat-404)
- ApprovalResponse + Mapper um resolved_by ergaenzt

Abnahme (Astra): Falscher Entscheider, abgelaufene Anfrage und doppelte
Entscheidung werden abgewiesen — erfuellt (6 Tests).
Hinweis: workflows.py approve/reject-Aufrufer waren bereits kaputt
(F12, S2-Welle: approval[id] auf ORM-Objekt) und werden dort gefixt.

Tests: test_s1_security_guards.py 18/18 (6 neue F11-Tests). ruff clean.
Damit ist S1 — ALLE 11 Sicherheits-Findings der Astra-Welle 1 gefixt.
2026-09-18 08:53:54 +02:00
Agent Zero b2f75495de fix(security): F20 (Astra P1) — pauschaler Boot-GRANT entfernt, DELETE-Rechte als Migration 0145 festgeschrieben
Vorher: prestart.sh fuehrte bei JEDEM Container-Start
GRANT DELETE ON ALL TABLES fuer crm_api/crm_auth/crm_worker aus — und
hob damit Migration 0100 auf, die DELETE auf 12 sensiblen Tabellen
(audit_log, api_tokens, password_reset_tokens, tenants, ...)
gezielt entzogen hatte. Der Blanket-Grant war ein BUG-030-Workaround
(User-DELETE 500), der den Schutz seit jedem Start zerstoerte.

Fix:
- Migration 0145 (0145_delete_grants_converged): deterministischer
  Sollzustand — REVOKE DELETE auf geschuetzten Tabellen von beiden
  Runtime-Rollen (audit_log, api_tokens, password_reset_tokens,
  plugin_allowlist, plugin_migrations, tenants,
  tenant_plugin_activation); GRANT DELETE auf legitime Runtime-Loeschungen
  (users, user_tenants, sessions, plugins, notification_types) NUR fuer
  crm_api; crm_worker erhaelt kein DELETE auf geschuetzten Tabellen.
- prestart.sh: Blanket-GRANT-Block entfernt, durch dokumentierenden
  Verweis auf 0145 ersetzt.
- audit.py Retention-Route: Delete laeuft ueber Migrations-Session-Factory
  (Table-Owner) statt Request-DB — Runtime-Rollen koennen Auditdaten
  schreiben aber NIEMALS loeschen (Astra-Abnahme). Gleiches Muster wie
  Plugin-Uninstall.

Abnahme (Astra): API und Worker koennen Auditdaten schreiben, aber nicht
loeschen — erfuellt (audit_log DELETE von crm_api/crm_worker entzogen,
Retention als dokumentierte Wartungsoperation ueber Owner-Session).

Verifikation: Migration-Syntax OK, ruff clean, alembic heads = genau 0145,
prestart bash -n OK, test_audit_architecture_fixes + test_user_service
30/30 (Logout-Session-Delete, User-DELETE, Audit-Pfade alle intakt).

Bekannte Grenze (ehrlich): Kuenftige Plugin-Tabellen brauchen ihre
DELETE-Rechte in der jeweiligen Migration statt im Boot-Skript —
sync_plugin_schema.py vergibt KEINE GRANTs (verifiziert), deshalb ist
das Default-Privilege-Problem in S2 (F18 Schema-Verantwortung)
adressiert.
2026-09-18 08:46:27 +02:00
Agent Zero a802159a65 fix(security): F15 (Astra P1) — SSRF-Schutz loest DNS auf, interne Servicenamen blockiert
Vorher: _is_url_safe blockierte nur IP-Literale und 5 feste Hostnamen.
Interne Servicenamen (postgres, redis, ...) und externe Domains mit
privater DNS-Aufloesung passierten ungeprueft (Astra-Repro:
http://postgres:5432/ wurde akzeptiert).

Fix: Der Hostname wird per socket.getaddrinfo aufgeloest und ALLE
aufgeloesten IPs muessen oeffentlich sein (private/loopback/link-local/
reserved/multicast/unspecified → blockiert). DNS-Fehler ist fail-closed
(nicht verifizierbar = blockiert). Blocking-DNS ist hier vertretbar —
Workflow-Steps sind Background-Jobs. Redirects bleiben deaktiviert
(follow_redirects=False, war bereits korrekt).

Abnahme (Astra): Interne Servicenamen, private DNS-Ziele und
DNS-Wechsel werden abgefangen — erfuellt (Tests mit getaddrinfo-Mocks:
postgres->172.18.0.2 blockiert, evil-corp.example->10.0.0.5 blockiert,
DNS-Fehler blockiert).

Tests: test_phase_g_workflows.py SSRF 11/11 (3 neue F15-Tests +
Positivfall auf aufladbaren Host umgestellt, unresolvable Hostnamen
jetzt fail-closed). ruff clean.
2026-09-18 08:26:31 +02:00
Agent Zero 17f990c61b fix(security): F21 (Astra P1) — Migrationstest kann nie mehr die echte DB treffen
Vorher: scripts/test_migrations.sh ueberschrieb nur DATABASE_URL, aber
alembic/env.py bevorzugt MIGRATION_DATABASE_URL. Wenn diese auf eine
echte Instanz zeigte, liefen Upgrade/Downgrade dort statt in der
Testdatenbank. Zusaetzlich bekam psql postgresql+psycopg2://-URLs.

Fix:
- Beide Variablen (DATABASE_URL + MIGRATION_DATABASE_URL) werden auf
  die frisch erzeugte Testdatenbank gesetzt
- Zielidentitaets-Beweis VOR jeder DDL: current_database() muss der
  Test-DB-Name sein, sonst Abbruch (F21-Gate)
- psql-URLs: SQLAlchemy-Driver-Suffix wird gestrippt
- Cleanup per trap EXIT — Test-DB wird auch bei Fehlern/Interrupt
  gedroppt

Abnahme (Astra): Selbst bei anders gesetzter MIGRATION_DATABASE_URL
veraendert der Test ausschliesslich die erzeugte Testdatenbank —
erfuellt (Umgebungs-Override wird explizit ueberschrieben).

Verifikation: bash -n OK. Skript nicht produktiv ausgefuehrt (braucht
lokalen psql-Zugriff; CI/R2 fuehrt es kuenftig gegen sein eigenes
Artefakt aus).
2026-09-18 08:19:23 +02:00
Agent Zero 25b4d61236 fix(security): F23 (Astra P1) — DB-weiter Restore nur noch fuer System-Admins
Vorher: POST /api/v1/backups/{id}/restore war ueber automation:admin
eines Mandanten erreichbar — der Restore bearbeitet aber die GESAMTE
geteilte Datenbank ohne Mandantenfilter. Ein Tenant-Admin haette den
Zustand aller Mandanten ueberschreiben koennen.

Fix: Restore-Route auf require_admin umgestellt (echter System-Admin:
is_system_admin oder *:* via RBAC). Listen/Erstellen/Loeschen von
Backups bleibt mandantenbezogen auf automation:admin.

Abnahme (Astra): Ein Tenant-Admin kann keinen Gesamtrestore ausloesen —
erfuellt (Route-Introspektions-Tests pinnen die Verdrahtung).

Tests: test_s1_security_guards.py 12/12 (2 neue F23-Tests: restore nutzt
require_admin, restore nutzt NICHT require_permission).
2026-09-18 08:17:57 +02:00
Agent Zero 46463b5c65 docs(progress): S1-Welle 6/11 — F05+F30 erledigt und deployed (alle 6 Fixes produktiv) 2026-09-18 08:09:23 +02:00
Agent Zero a17772ade5 fix(security): F05 (Astra P1) — Plugin-Gate laeuft NACH Authentisierung, fail-closed
Vorher: require_active_plugin hing nicht an einer Auth-Dependency —
FastAPI konnte die Plugin-Pruefung VOR der Authentisierung ausfuehren.
Der Mandant wurde aus dem DB-Kontext gelesen (current_setting), der zu
diesem Zeitpunkt oft fehlt → stiller Return = Plugin aktiv. Der Code
trug sogar ein TODO: Fix in production. Astra-Repro: Endpunkt
antwortete HTTP 200 ohne Mandantenkontext.

Fix:
- _check haengt an get_current_user_or_bearer (Cookie- UND Bearer-Auth)
  → FastAPI aufloesungsbedingt immer authentifiziert vor dem Gate
- Mandant kommt aus dem authentifizierten User-Kontext, nie aus
  current_setting
- Fehlender Mandanten-Kontext → 403 plugin_gate_no_tenant (fail-closed,
  war: stiller Durchlass)
- Public-Routen (is_public) umgehen das Gate weiterhin korrekt

Nebenwirkung positiv: Bearer-Clients (External-API, MCP) laufen nicht
mehr gegen den Cookie-Zwang des Gates.

Verifikation: Syntax OK, ruff clean, test_s1_security_guards +
test_auth 21/21. Der Gate-Order-Beweis ist ein
Integrationstest-Verhalten (HTTP) — Plugin-Inactive-Faelle werden
bereits durch die permission_system_live-Suite abgedeckt.
2026-09-18 08:04:41 +02:00
Agent Zero 632554bf28 fix(security): F30 (Astra P1) — kein bekanntes Admin-Standardpasswort mehr
Vorher: seed_admin.py und docker-compose.yaml enthielten einen festen
Passwort-Fallback (Admin123!) — ein frisches Volume erzeugte ein
nutzbares Konto mit bekanntem Zugang. Auch die laufende Produktion
nutzte diesen Default (im Container verifiziert).

Fix:
- seed_admin.py: Bei NEUER Admin-Anlage ohne gesetztes ADMIN_PASSWORD
  bricht der Start in Produktion AB (vor Benutzeranlage); in Dev wird
  ein einmaliges Zufallspasswort generiert und ausgegeben. Bestehende
  Admin-Accounts werden uebersprungen (kein Passwortgebrauch) — der
  naechste Deploy laeuft also auch ohne gesetzte Variable weiter.
- docker-compose.yaml: ${ADMIN_PASSWORD:-Admin123!} -> required
  (${ADMIN_PASSWORD:?...}) — kein Default mehr.
- .env.example/.env.docker.example: Default durch CHANGE_ME-Hinweis
  ersetzt.

Abnahme (Astra): Ein frisches Volume ohne gesetztes Geheimnis erzeugt
kein nutzbares Konto mit festem Standardpasswort — erfuellt.
2026-09-18 08:02:05 +02:00
Agent Zero ad3575c64d docs(progress): S1-Welle — F03 (Session-Widerruf beide Stores) + F10 (Token-Scopes Obergrenze) erledigt, 4/11 2026-09-18 07:59:53 +02:00
Agent Zero 47432651f1 fix(security): F03 (Astra P1) — Sitzungswiderruf in beiden Session-Stores durchsetzen
Vorher (Astra-Finding): Widerruf war inkonsistent ueber vier Pfade:
- Deaktivierung invalidierte nur den Berechtigungscache — Sessions
  liefen bis TTL (8h) weiter
- Loeschung invalidierte GAR NICHTS
- Passwortwechsel loeschte nur Redis-Sessions — PostgreSQL-Fallback-
  Sessions ueberlebten jeden Redis-Ausfall
- Fehlende UserTenant-Mitgliedschaft wurde durchgewinkt statt
  abgewiesen

Fix:
- Neuer zentraler Helfer revoke_user_sessions_all_stores (app/core/auth.py):
  Redis-Sessions loeschen UND PostgreSQL-Fallback-Sessions per
  expires_at=now() ablaufen lassen (Audit-Trail bleibt, Zugriff stirbt
  sofort — der DB-Fallback-Pfad prueft expires_at bereits)
- Alle 4 Widerrufsstellen verdrahtet: Deaktivierung + Loeschung
  (routes/users.py), Passwortwechsel (user_service.py), Passwort-Reset
  (auth_service.py)
- Membership-Check in get_current_user fail-closed: None (fehlende
  Mitgliedschaft) wird abgewiesen statt durchgelassen

Abnahme (Astra): Deaktivierung, Austritt und Passwortwechsel wirken
unmittelbar — auch bei Redis-Ausfall (Unit-Test beweist die
DB-Fallback-Abgelaufen-Rejection).

Tests: test_s1_security_guards.py 10/10 (3 neue F03-Tests) +
test_auth.py 11/11 + ruff clean.
2026-09-18 07:59:31 +02:00
Agent Zero b8a556091e fix(security): F10 (Astra P1) — Token-Scopes sind Obergrenze, kein Ersatz fuer User-Rechte
Vorher: require_permission machte bei passendem Token-Scope ein
early-return — die User-Rechte wurden NIE geprueft. Ein Token mit
mail:write erlaubte mail:write selbst dann, wenn der Benutzer die
Berechtigung nie hatte oder sie entzogen bekam (Astra-Repro isoliert
bestaetigt).

Fix: Nach bestandenem Scope-Check in den normalen User-Rechte-Check
fallen. Effektives Recht = User-Rechte UND Token-Scope. Rechteentzug
wirkt sofort auf bestehende Tokens. System-Admin-Bypass unveraendert.

Tests: tests/test_s1_security_guards.py 7/7 (neue Suite):
Scope-ohne-User-Recht 403, beide-present pass, Scope-fehlt-User-hat 403
insufficient_scope, Deny-Revocation wirkt, Session-Pfad unveraendert,
*:*-Scope umgeht nicht, Admin-Bypass bleibt. ruff clean.
2026-09-18 07:52:23 +02:00
Agent Zero ea39cf4667 docs(progress): S1-Welle — F01+F02 (beide Astra-P0s) gefixt, deployed und live verifiziert 2026-09-17 23:03:13 +02:00
Agent Zero 824686c673 fix(security): F02 (Astra P0) — globale Login-Identitaet von Mandantenverwaltung trennen
Vorher: Ein Mandanten-Admin (users:write) konnte die globale User.email
und das Passwort JEDES Mitglieds seines Mandanten aendern. User ist
aber mandantenuebergreifend — derselbe Datensatz traegt Passwort und
Systemadmin-Flag; der Passwort-Reset nutzt die veraenderbare Adresse.
Ein Admin aus Mandant A konnte so die globale Reset-Adresse eines
gemeinsamen Benutzers umlenken (Astra-Repro: globale Feldaenderung
isoliert reproduziert).

Fix (routes/users.py update_user):
- email/new_password fuer FREMDE User -> 403 global_identity_forbidden
  (nur Selbstservice oder echter System-Admin)
- is_active fuer MEHRMANDANTEN-User durch Tenant-Admin -> 403
  multi_tenant_status_forbidden (Deaktivierung waere global sperrend;
  Single-Mandanten-Mitglieder duerfen wie bisher deaktiviert werden)
- is_system_admin-Eskalationscheck unberuehrt (war schon korrekt)

Abnahme (Astra): Ein Tenant-Verwalter kann weder die globale E-Mail-
Adresse noch den globalen Aktivstatus eines gemeinsamen Benutzers
veraendern — erfuellt.

Tests: test_user_service.py 13/13 (5 neue F02-Tests: fremde E-Mail 403,
fremdes Passwort 403, Mehrmandanten-Deaktivierung 403, Name-Aenderung
bleibt 200, Selbstservice bleibt 200). ruff clean.
2026-09-17 22:58:38 +02:00
Agent Zero f2a7206c7d fix(security): F01 (Astra P0) — KI-Tool-Ausführung ohne Freigabe verhindern
Check Cross-Plugin Imports / check (push) Has been cancelled
Vorher: _execute_tool (agent_loop.py) und der KI-Chat-Loop
(stream_chat_comm) führten JEDES im Registry registrierte Tool aus, wenn
das LLM dessen Namen lieferte — ohne Abgleich mit der angebotenen Liste,
ohne required_permission-Check. Reproduktion (Astra): Nur audit_allowed
angeboten, Modell nannte audit_restricted (system:admin) → Handler lief.

Fix (fail-closed, an ALLEN Ausfuehrungspfaden):
- _check_tool_access: (1) Allowlist — nur Tools die dem LLM angeboten
  wurden duerfen laufen; (2) required_permission gegen die AKTUELLEN
  User-Rechte (deny-first, Rechteentzug wirkt sofort, ohne Kontext =
  Ablehnung). Guard vor dry-run/approval/execute-Pfaden.
- stream_chat_comm: gleicher Allowlist-Guard vor execute_tool_call.
- run_react_loop/agent_runner/agent_stream/agent_routes reichen
  user_permissions durch (perm_ctx bzw. Session-User).
- check_permission: Session-Kontexte tragen denied_permissions statt
  denied — beide Keys werden gelesen, Deny-Liste wird nie mehr ignoriert.

Tests: test_agent_loop.py 18/18 (7 neue F01-Tests nach Astra-Abnahme:
nicht angeboten → Handler null; fehlende Permission → abgewiesen;
Fail-closed ohne Kontext; Deny-Liste session-shape; Rechteentzug
mitten im Lauf wirkt auf naechste Aktion; dry-run guardet auch).
ruff clean. Pre-existing-Beweis: permission_system_live-Failures
reproduzieren sich ohne diesen Patch identisch (Plugin-Aktivierung in
ephemeraler Test-DB, bekanntes Vorbestands-Finding).
2026-09-17 22:48:59 +02:00
Agent Zero 8a26737680 docs(audit): Astra-Externaudit aufgenommen — 41 Findings verifiziert, PHASE S (4 Wellen) in Roadmap, Milestone 16, Issues #396-399
- docs/audits/astra-audit-2026-09-17.md: vollstaendiger Pruefbericht (2 P0, 29 P1, 10 P2), 10 Findings intern stichprobenartig verifiziert (alle korrekt)
- PLATFORM_ROADMAP.md: PHASE S (S1 Sicherheitsgrenzen, S2 Ausfuehrung verbinden, S3 Fachliche Integritaet, S4 Betriebsfreigabe) mit je Finding Korrektur+Abnahme; Abnahmeszenarien quer (Kontakt->Outbox->Worker->Suchindex->KI; Mail->Freigabe->Versand)
- Phase R: 8 Astra-Kritikpunkte eingearbeitet (externe Ueberwachung, Sollzustand-Vergleich, Heartbeat statt Queue, echte Prozesse, Modelldiscovery, E2E-Szenarien, Restore-Nachweis, 95%-Formulierung als Freigabekriterien)
- PROGRESS.md: Phase S als NÄCHSTE PHASE, Wellen-Issues verlinkt
2026-09-17 22:27:19 +02:00
Agent Zero ee5545d58f docs(roadmap): Phase R — Betriebssicherheit & 95%-Produktionsreife (R1-R6, Milestone 15, Issues #390-395) 2026-09-16 01:29:45 +02:00
Agent Zero 4b97a1bca2 docs: UI-Backlog 16/16 KOMPLETT — Module 15+16 (#387, #388) + Bugfixes 2026-09-16 (#389) dokumentiert 2026-09-16 00:56:49 +02:00
Agent Zero b91ee5bf1b fix(security): Bearer-Requests von CSRF-Middleware ausnehmen
Die CSRF-Middleware verlangte Origin+X-CSRF-Token auf allen unsafe
Requests — auch auf Bearer-authentifizierten API-Calls (External-Agent-
API, MCP, Integrationen). Externe Systeme senden nie Origin/CSRF,
dadurch war /api/v1/external/agent/* faktisch unbrauchbar (403).

Fix: Authorization: Bearer-Header-Requests skippen die CSRF-Pruefung.
Bearer ist CSRF-immun per Design: Browser haengen den Authorization-
Header niemals automatisch an, Cross-Site-Requests koennen ihn nicht
schmuggeln. Session-Cookie-Requests (SPA) laufen unverändert durch die
volle Origin+Double-Submit-Pruefung.

Regression: pytest test_auth.py 11/11, ruff clean
2026-09-16 00:49:37 +02:00
Agent Zero 0383dd2f64 fix(plugins): ai_assistant Migrationen von gedroppten ai_chat-Tabellen befreien
Check Cross-Plugin Imports / check (push) Has been cancelled
Produktionsbug: Plugin ai_assistant war migration_failed/inactive, weil
Migration 0003 ALTER TABLE ai_chat_sessions ausfuehrte — die Tabelle wurde
von Alembic 0137 (2026-08-21, Umstieg auf comm-Tabellen) gedroppt. Bei jedem
Container-Start crashte die Migration und deaktivierte das Plugin
(KI-Chat und /api/v1/ai/* lieferten 403).

Fix:
- 0003: ai_chat_sessions-Statements entfernt, nur ai_chat_folders behalten
- 0001: Ghost-Tabellen ai_chat_sessions/ai_chat_messages entfernt
  (frische Installs duerfen sie nicht rekreieren — Schema-Drift)
- 0002: ai_chat_attachments + toter folder_id-ALTER entfernt,
  nur ai_chat_folders behalten

Runner skipt getrackte Migrationen per Dateiname (kein Hash-Check),
Prod-Risiko null; 0003 laeuft beim naechsten Start sauber durch und
aktiviert das Plugin wieder.
2026-09-16 00:43:04 +02:00
Agent Zero e8e07fa13a feat(ui): External-Agent-API + Besitzübertragung UI (UI-Backlog Module 15+16/16)
Check Cross-Plugin Imports / check (push) Has been cancelled
Modul 15 External-Agent (ai_assistant-Plugin, Manifest settings_page):
- SettingsExternalAgents.tsx: Agentenliste mit curl-Snippets (run/status/stream),
  Copy-Buttons, Bearer-Token-Hinweis, Rate-Limit-Doku, Token-Link
- api/externalAgent.ts (useAiAgents, buildCurlSnippets, curlCommand)
- Manifest: settings_pages +external-agents (order 61, permission ai:read)
- Komponenten-Map regeneriert (43)

Modul 16 Ownership-Transfer (Core):
- SettingsOwnership.tsx: Admin-Gate, From/To-User-Selects, 10 Entity-Type-Chips,
  ConfirmDialog, Ergebnis-Tabelle
- api/ownership.ts (useTransferOwnership, OWNERSHIP_ENTITY_TYPES)
- Route /settings/ownership + Nav-Eintrag

i18n de/en +28 Keys. Vitest 12/12, tsc 0, Build OK, ruff OK, Manifest-Import OK
2026-09-16 00:32:45 +02:00
Agent Zero f38dfdeea1 docs: UI-Backlog Modul 14 (Guests) abgeschlossen — #386, 14/16 2026-09-16 00:13:39 +02:00
Agent Zero b3eaa0e39b feat(ui): Gäste-Verwaltung UI — SettingsGuests (UI-Backlog Modul 14/16)
- api/guests.ts: useGuests, useInviteGuest, useRevokeGuest (/api/v1/guests)
- SettingsGuests.tsx: Admin-Gate (Outbox-Muster), Gästeliste mit Status-Badges
  (invited/active/disabled), Invite-Modal (RHF+zod), Revoke-ConfirmDialog
- Route /settings/guests + Nav-Eintrag in Settings.tsx
- i18n de/en: 14 Keys
- Vitest 8/8, tsc clean, Build OK
2026-09-16 00:08:59 +02:00
Agent Zero f8b07032e5 docs(progress): Konsistenz-Fix — Roadmap-Offen-Liste und HEAD-Referenz synchronisiert 2026-09-15 23:35:36 +02:00
Agent Zero 2d3ee216ea docs(progress): Weitermachen-Uebergabe auf Stand 2026-09-15 aktualisiert — Bauplan + Session-Lektionen fuer jede KI 2026-09-15 23:35:07 +02:00
Agent Zero b1c8891ee0 docs(progress): UI-Backlog Modul 13 (Public-Share) erledigt — live verifiziert (#385) 2026-09-15 23:22:29 +02:00
Agent Zero 2fbffcd6e8 fix(public-share): 404/410-Erkennung — ApiError.status statt err.response.status im Catch 2026-09-15 23:20:08 +02:00
Agent Zero 00f8f100d7 feat(public-share): Oeffentliche Share-Zugriffsseite fuer externe Besucher + SPA-Links im DMS-ShareDialog — Modul 13/16 des UI-Backlogs 2026-09-15 23:16:30 +02:00
Agent Zero 7097e28578 docs(progress): UI-Backlog Modul 12 (Companies) erledigt — live verifiziert (#384) 2026-09-15 23:01:37 +02:00
Agent Zero 06b72843da feat(companies): UI fuer Firmen-Verwaltung mit Ansprechpartner-Links — Modul 12/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-09-15 22:56:38 +02:00
Agent Zero 71ffee021e docs(progress): UI-Backlog Modul 11 (Graph-RAG) erledigt — live verifiziert (#383) 2026-09-15 08:36:43 +02:00
Agent Zero 0404c8f5dc feat(graph-rag): UI fuer Wissens-Graph mit BFS-Traversierung — Modul 11/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-09-15 08:25:21 +02:00
Agent Zero 4eba9eb1d9 docs(progress): UI-Backlog Modul 10 (Policies) erledigt — live verifiziert (#382) 2026-09-14 23:43:41 +02:00
Agent Zero d734923636 feat(policies): UI fuer ABAC-Richtlinien mit Conditions-Builder — Modul 10/16 des UI-Backlogs 2026-09-14 23:41:55 +02:00
Agent Zero abdf9e7d83 docs(progress): Alle 3 Bugfixes dokumentiert — Webhook-JSONB (#380), Spinner-Hang/Zustand-Selektoren (#381), Registry-qualname 2026-09-14 08:35:12 +02:00
Agent Zero fccf0099d7 fix(outbox): qualname nur fuer bound methods — plain functions behalten __name__ (test_outbox_phase5 17/17 gruen) 2026-09-14 08:31:50 +02:00
Agent Zero 591ef06a82 fix(outbox): Consumer-Registry zeigt qualname — unterscheidet gleichnamige Handler ueber Plugins hinweg 2026-09-14 08:29:07 +02:00
Agent Zero dcd2018335 docs(progress): Spinner-Hang-Fix dokumentiert — Zustands-Selector war Dashboard-loads-forever-Ursache (#381) 2026-09-14 08:28:07 +02:00
Agent Zero 3fd0c6981d fix(app-shell): instabile Zustands-Selektoren veroursachten haengende Lazy-Routen ("Dashboard loads forever") — Root Cause: moduleMenuOrder()/visibleModuleKeys() erzeugten bei jedem getSnapshot neue Map/Set-Objekte 2026-09-14 08:25:35 +02:00
Agent Zero 9076983c0c docs(progress): Webhook-JSONB-Fix dokumentiert — 158 Events live repariert (#380) 2026-09-14 08:02:58 +02:00
Agent Zero 50d6733df6 fix(webhooks): JSONB-Containment statt .any() auf JSON-Spalte — behebt 158 fehlgeschlagene Outbox-Events 2026-09-14 07:55:07 +02:00
Agent Zero 36e46f60f7 docs(progress): UI-Backlog Modul 9 (Outbox) erledigt — live verifiziert (#379) 2026-09-14 00:23:12 +02:00
Agent Zero 31154b9dc6 feat(outbox): UI fuer Event-Outbox — Modul 9/16 des UI-Backlogs 2026-09-13 23:13:58 +02:00
Agent Zero 00bfcafb9c docs(progress): Vollstaendige Uebergabe — offene Threads zentral dokumentiert (UI-Backlog 9-16, Re-Audit, Traefik, Server, Phase O/P, Findings, Marketplace leer) 2026-09-13 19:57:17 +02:00
Agent Zero 5ecadd5a89 docs(roadmap): UI-Backlog als eigenstaendiger Abschnitt — Status 8/16, Regeln fuer Plugin- vs Core-Registrierung 2026-09-13 19:56:57 +02:00
Agent Zero 0388ca2072 docs(progress): UI-Backlog Modul 8 (Agent-Memory) erledigt — live verifiziert (#378) 2026-09-13 19:47:00 +02:00
Agent Zero 24423b6802 feat(agent-memory): UI fuer Agent-Memories mit semantischer Suche — Modul 8/16
Check Cross-Plugin Imports / check (push) Has been cancelled
Drittes Modul via Phase-Q-Manifest-Architektur: Registrierung komplett
ueber das agent_memory-Plugin-Manifest (page_route /agent-memory +
menu_item, Brain-Icon) — routes/index.tsx unangetastet. Komponenten-Map
mit 40 Eintraegen.

Zusaetzlicher Fix: Sidebar-ICON_MAP um Brain, Store, Tags erweitert —
Marketplace (Store) und Tags zeigten bisher Fallback-Icons, weil die
Manifest-Icons nicht in der kuratierten Map standen.

Backend existierte vollstaendig (create mit Embedding, list mit
agent_id-Pflichtfilter + Typ + Pagination, semantische Suche via pgvector,
update mit Embedding-Regeneration, delete; agent_memory:read/write),
Frontend hatte 0% Abdeckung.

- api/agentMemory.ts: TanStack-Hooks (useAgentMemories mit
  enabled-Gating, useAgentMemorySearch, create/update/delete)
- pages/AgentMemory.tsx: Agent-Picker (Pflichtfeld — Backend filtert
  zwingend nach agent_id), Pick-Agent-Prompt, semantische Suche mit
  Relevanz-Score-Badges und Clear, Memory-Karten mit Typ-Badges
  (fact/context/pattern/instruction), Create/Edit-Dialog, Delete mit
  Confirm — Aktionen hinter agent_memory:write
- i18n agentMemory.* + nav.agentMemory de/en

Verifikation: Vitest 11/11 (Agent-Picker-Pflicht, Pick-Prompt, Badges,
Suche mit Score + Clear, Create/Edit prefilled, Delete mit+ohne Confirm,
Gating, Empty/Error) · tsc exit 0 · production build exit 0 ·
Backend-Regressionen (route-order, m5-miniapps) 10/10 · compileall
sauber · Manifest-Check OK.
2026-09-13 19:45:18 +02:00
Agent Zero e7b746809b docs(progress): UI-Backlog Modul 7 (Skills) erledigt — live verifiziert (#377) 2026-09-13 10:37:43 +02:00
Agent Zero 3f8d1bd59d feat(skills): UI fuer AI-Skill-Definitionen — Modul 7/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
Zweites Modul via Phase-Q-Manifest-Architektur: Registrierung komplett
ueber das automation-Plugin-Manifest (page_route /skills + menu_item,
Sparkles-Icon) — routes/index.tsx und Sidebar.tsx unangetastet. Der
Komponenten-Map-Generator wired den lazy import (39 Komponenten).

Backend existierte vollstaendig im automation-Plugin (skill_routes.py:
list mit is_active/category-Filtern, create, get, patch, delete;
automation:read/write/delete), Frontend hatte 0% Abdeckung.

- api/skills.ts: TanStack-Hooks (useSkills mit Filtern, useSkill,
  create/update/delete mit Cache-Invalidierung)
- pages/Skills.tsx: Filter-Tabs (alle/aktiv/inaktiv), Skill-Karten (Name,
  Aktiv/Inaktiv-Badge, Kategorie, Beschreibung, Tool-Count),
  Create/Edit-Dialog (Name, Beschreibung, Instructions-Textarea, Kategorie,
  Tool-IDs als Komma-Liste, Aktiv-Toggle), Delete mit Confirm —
  Create/Edit hinter automation:write, Delete hinter automation:delete
- i18n skills.* + nav.skills de/en

Hinweis: Skills sind Orchestrierungs-Metadaten, KEINE
Berechtigungsquelle — erlaubte Tools verweisen auf Tool-IDs, fuer die
Agent und User bereits berechtigt sein muessen (Backend-Docstring).

Verifikation: Vitest 10/10 (Filter-Tabs, Badges inkl. Inaktiv, Tool-Count,
Create-Flow, Edit prefilled, Delete mit+ohne Confirm, separates
Write/Delete-Gating) · tsc exit 0 · production build exit 0 ·
Backend-Regressionen (route-order, m5-miniapps, n4-scope) 28/28 ·
compileall sauber · Cross-Plugin-Checker 0 · Manifest-Check: page_route
+ menu_item korrekt.
2026-09-13 10:36:02 +02:00
Agent Zero a09d611cac docs(progress): UI-Backlog Modul 6 (Permission-Templates) erledigt — live verifiziert (#376) 2026-09-13 10:27:50 +02:00
Agent Zero 33b4b52206 feat(templates): UI fuer Berechtigungs-Vorlagen — Modul 6/16 des UI-Backlogs
Backend existierte vollstaendig (list mit entity_type-Filter, create, update,
delete, apply — templates:read/write, seit Audit-Fix im Katalog), Frontend
hatte 0% Abdeckung.

- api/permissionTemplates.ts: TanStack-Hooks (usePermissionTemplates,
  create/update/delete/apply mit Cache-Invalidierung, TemplateLevel-Typ)
- pages/PermissionTemplates.tsx: Template-Karten (Name, Level-Badge,
  Entity-Type, Auto-Share-Zusammenfassung), Create/Edit-Dialog mit
  Level-Select und JSON-Textarea inkl. Array-Validierung mit Fehlertext,
  Apply-Dialog (Entity-Type vorbelegt, Entity-ID), Ergebnis-Banner mit
  Anzahl erstellter Berechtigungen, Delete mit Confirm — alle Aktionen
  hinter templates:write gegated
- Platzierung: Settings-Subpage /settings/permission-templates (statisch,
  Core-Route) + Settings-Nav-Item
- i18n permissionTemplates.* de/en

Verifikation: Vitest 10/10 (Rendering, Level-Badges, Create mit validem +
invalidem JSON, Edit prefilled, Apply mit Ergebnis, Delete-Confirm,
Permission-Gating, Empty/Error) · tsc exit 0 · production build exit 0.
2026-09-13 10:27:10 +02:00
Agent Zero c99d2f19ef docs(progress): UI-Backlog Modul 5 (Marketplace) erledigt — live verifiziert (#375) 2026-09-13 09:46:18 +02:00
Agent Zero 289dfc8230 feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
ERSTES Modul ueber die Phase-Q-Plugin-Architektur: Registrierung komplett
ueber das Plugin-Manifest (page_routes + menu_items) — routes/index.tsx und
Sidebar.tsx wurden NICHT angefasst. Der Komponenten-Map-Generator wired den
lazy import automatisch (38 Komponenten).

Backend existierte vollstaendig (listings mit search/tags/pagination,
listing-detail, install mit Ed25519-Signatur-Verify, verify, categories;
marketplace:read/admin + require_admin fuer Install), Frontend hatte 0%
Abdeckung.

- api/marketplace.ts: TanStack-Hooks (useMarketplaceListings mit
  search/tags/pagination, useMarketplaceListing, useMarketplaceCategories,
  useInstallFromMarketplace, useVerifyMarketplacePlugin)
- pages/Marketplace.tsx: Suche, Tag-Filter-Chips, Listing-Karten (Name,
  Version, Author, Beschreibung, Tags, Download-Counter, Verified-Badge,
  Preis/Kostenlos), Install mit Confirm (Admin-only via is_system_admin),
  Signatur-Verify, Ergebnis-Banner, Pagination — No-Access-Card ohne
  marketplace:read
- Manifest: page_route /marketplace + menu_item (Store-Icon, order 85,
  permission marketplace:read)
- i18n marketplace.* + nav.marketplace de/en

Verifikation: Vitest 10/10 (Rendering, No-Access, Empty/Error, Karten,
  Search+Tags, Admin-Gating, Install-Flow mit+ohne Confirm, Verify-Flow) ·
  tsc exit 0 · production build exit 0 · Backend-Regressionen
  (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check:
  page_route + menu_item korrekt.
2026-09-13 09:44:28 +02:00
Agent Zero a3201ae221 docs(progress): UI-Backlog Modul 4 (Tenants) erledigt — live verifiziert (#374) 2026-09-13 09:30:21 +02:00
Agent Zero 79ca1cbe6d feat(tenants): UI fuer Mandanten-Verwaltung — Modul 4/16 des UI-Backlogs
Backend existierte vollstaendig (list, create, list users, assign user;
tenants:read/write), Frontend hatte 0% Abdeckung.

- api/tenants.ts: TanStack-Hooks (useTenants, useTenantUsers mit
  enabled-Gating, create, assignUser mit Cache-Invalidierung)
- pages/Tenants.tsx: Tenant-Karten (Name, Slug, Standard-Badge),
  expandierbare User-Liste pro Tenant (Rolle, E-Mail), Create-Dialog
  (Name + Slug mit Auto-Normalisierung), Assign-User-Dialog mit
  User-Picker (bereits zugewiesene gefiltert) — Create/Users/Assign
  hinter tenants:write gegated
- Platzierung: Settings-Subpage /settings/tenants (statisch, Core-Route)
  + Settings-Nav-Item
- i18n tenants.* de/en

Verifikation: Vitest 8/8 (Rendering, Standard-Badge, Expand-User-Liste,
Create-Flow, Assign-Flow, Permission-Gating, Empty/Error) · tsc exit 0 ·
production build exit 0.
2026-09-13 09:29:42 +02:00
Agent Zero 8d8beebd38 docs(progress): UI-Backlog Modul 3 (API-Tokens) erledicht — live verifiziert (#373) 2026-09-13 09:23:42 +02:00
Agent Zero 4bdc6c66c7 feat(api-tokens): UI fuer Bearer-Tokens — Modul 3/16 des UI-Backlogs
Backend existierte vollstaendig (POST create mit Einmal-Plaintext-Anzeige,
GET list ohne Hashes, DELETE revoke; mcp:read/mcp:write via mcp_server-
Plugin registriert), Frontend hatte 0% Abdeckung.

- api/apiTokens.ts: TanStack-Hooks (useApiTokens, create mit
  ApiTokenCreated-Response inkl. Einmal-Token, revoke)
- pages/ApiTokens.tsx: Token-Karten (Name, Scope-Badges, Ablauf, zuletzt
  genutzt, Abgelaufen-Badge), Create-Dialog (Name, Scopes als
  Komma-Liste, optionale Gueltigkeit in Tagen), EINMALIGE
  Plaintext-Anzeige mit Copy-Button und Warnung, Revoke mit Confirm —
  Aktionen hinter mcp:write gegated
- Platzierung: Settings-Subpage /settings/api-tokens (statisch, Core-Route)
  + Settings-Nav-Item (true-core-settings-Muster)
- i18n apiTokens.* de/en

Verifikation: Vitest 8/8 (Rendering, Scopes, Ablauf-Badge,
Permission-Gating, Create-Flow mit Reveal-Dialog, Revoke) · tsc exit 0 ·
production build exit 0.
2026-09-13 09:23:01 +02:00
Agent Zero f9ff92bec9 docs(progress): UI-Backlog Modul 2 (Delegations) erledigt — live verifiziert (#372) 2026-09-13 09:18:33 +02:00
Agent Zero 36771d471d feat(delegations): UI fuer Berechtigungs-Delegationen — Modul 2/16 des UI-Backlogs
Backend existierte vollstaendig (5 Endpoints: list/create/update/delete/
active-check, delegations:read/write seit Audit-Fix im Katalog), Frontend
hatte 0% Abdeckung. Modul folgt dem Approvals-Muster (Modul 1):

- api/delegations.ts: TanStack-Hooks (useDelegations mit direction-Filter,
useActiveDelegations, create/update/delete-Mutations mit Cache-Invalidierung)
- pages/Delegations.tsx: Richtungstabs (alle/von mir/an mich), Karten mit
  Phasen-Badges (aktiv/geplant/abgelaufen/inaktiv), Erstellen-Dialog mit
  Empfaenger-Picker (useUsers, sich selbst ausschliessend), Start/Ende-
  Datetime, Scope-Toggle (alle Berechtigungen), Aktivieren/Deaktivieren,
  Loeschen mit Confirm — Aktionen hinter delegations:write gegated
- Route /delegations (PermissionRoute delegations:read) — als Core-Route
  bewusst statisch registriert (Phase-Q-Regel: nur Plugin-Routen laufen
  ueber Manifeste) Sidebar-Entry order 92 (ArrowRightLeft-Icon)
- i18n delegations.* + nav.delegations de/en

Verifikation: Vitest 9/9 (Rendering, Tabs, Phasen, Permission-Gating,
Create-Flow, Toggle, Delete) · tsc exit 0 · production build exit 0.
2026-09-13 09:17:17 +02:00
Agent Zero b58c96ff71 feat(arch): Phase Q abgeschlossen — Plugin-Manifeste sind die einzige Frontend-Routen-Quelle
PROGRESS.md: Phase-Q-Section mit Live-Beweisen. PLATFORM_ROADMAP.md: Phase Q auf
ABGESCHLOSSEN (Q1-Q4 komplett, Commits 895f85d + b666fe5, deployed).
Nächster Schritt: Re-Audit durch den externen Prüfer.
2026-09-13 08:58:34 +02:00
Agent Zero b666fe5b4c feat(frontend): Q1+Q2 — Plugin-Routen kommen aus Manifesten, statische Duplikate entfernt
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase Q1 (Seiten-Routen) + Q2 (Settings-Routen): Die Plugin-Manifeste sind
ab sofort die einzige Quelle fuer Plugin-Frontend-Routen. routes/index.tsx
enthaelt nur noch Core-Routen + die StartLayout-Hub-Baeume (/agents,
/automation, /logs, /help — verschachtelte Sub-Navigation).

- PluginRouteRenderer: variante 'settings' rendert settings_pages mit
  bare Sub-Segments (Descendant-Matching im /settings-Subtree); Variante
  'pages' (Default) behaelt absolute Pfade. Getrennte Entry-Listen
  verhindern Pfad-Kollisionen (settings 'mail' vs. page '/mail').
- Entfernt: 14 statische AppShell-Plugin-Routen + 9 statische
  Settings-Routen + 20 tote Lazy-Imports (search, calendar+kanban, dms,
  dms/trash, mail, mail/settings, reports, tasks, communication,
  workflows, import-export, tags, wiki, roles, users, groups,
  notifications, ai, ai-proactive, automation, documents).
- Manifeste ergaenzt: Calendar +/calendar/kanban, Tags +/tags (+ Menue-Item,
  Route war sonst unerreichbar), Automation: /workflows-Permission auf
  workflows:read (Paritaet zur ersetzten statischen Route).
- Automation: tote flache Manifest-Eintraeger fuer /agents + /automation
  entfernt (StartLayout-Hub-Baeume gewinnen diese Pfade immer — die
  Eintraege matchten nie).
- Komponenten-Map regeneriert (37 Eintraege, CalendarKanban + Tags neu).

Verifikation: tsc exit 0; production build exit 0; Vitest Dashboard +
MiniAppWindow + pluginStore 35/35; Backend-Regressionen Route-Order,
M5-MiniApps, N4-Scope, N3-Filtering 49/49; compileall sauber;
Cross-Plugin-Checker 497 Dateien / 0 verbotene Imports; ruff clean.
2026-09-13 08:56:29 +02:00
Agent Zero 895f85dde0 feat(frontend): Q3+Q4 — Komponenten-Chunk-Map wird aus Plugin-Manifesten GENERIERT
Check Cross-Plugin Imports / check (push) Has been cancelled
scripts/generate_component_map.py scannt alle builtin-Manifeste + system_miniapps.py
und erzeugt frontend/src/generated/pluginComponents.generated.ts (37 Komponenten).
PluginLoader (STATIC_COMPONENT_MAP) und MiniAppHost (widgetRegistry) nutzen die
generierte Map — ein Plugin meldet seine Komponenten nur noch im Manifest,
keine zentrale Frontend-Datei muss angefasst werden.

Garantien: Generator failt hart bei Ghost-Komponenten (bewiesen: exit 1),
erkennt default- vs. named-exports, deterministische Ausgabe, --check-Modus
fuer CI. Kontakts DedupMergePage-Pfad-Alias auf echte Datei korrigiert.

Verifikation: tsc exit 0; production build exit 0; Ghost-Fail-Hard exit 1;
Dashboard+MiniAppWindow 17/17; pluginStore 18/18; keine Restreferenzen auf
STATIC_COMPONENT_MAP/widgetRegistry.
2026-09-13 08:40:39 +02:00
Agent Zero dbe9ded4f1 docs(progress): Audit-Section vervollstaendigt — Live-Beweise (12/12 Keys, modules=24), Korruption repariert 2026-09-13 02:39:15 +02:00
Agent Zero 1b80090ad2 fix(plugins): forgejo_error_reporter permissions korrekt auf Manifest-Ebene
Check Cross-Plugin Imports / check (push) Has been cancelled
Der vorherige Patch hatte permissions=["system:read"] versehentlich in die
PluginRouteDef-kwargs gesetzt statt auf Manifest-Ebene — der Key blieb
dadurch unregistriert (live bewiesen: nur 11/12 Keys im Produktionskatalog
sichtbar). Korrigiert; Test f9 prueft jetzt die ECHTEN Manifeste statt
manueller Registrierung, so haette der Fehler ab sofort gefangen werden
muessen.

Verifikation: tests/test_audit_architecture_fixes.py 17/17,
manifest.permissions=['system:read'], is_core=False, routes=1.
2026-09-13 02:35:52 +02:00
Agent Zero 4210e164fa docs(progress): Audit-Fixes — Issue #370 verlinkt 2026-09-13 02:25:29 +02:00
Agent Zero 4a25ac1379 fix(arch): externes Audit — 13 Backend-Fixes (Workspace-Modules, Tenant-Manifeste, Lifecycle, Contracts, Permissions)
Check Cross-Plugin Imports / check (push) Has been cancelled
Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt.
Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte
als Phase Q in die Roadmap eingeplant.

- P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug)
- P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern
- P1 uninstall: volle Service-Deactivation VOR registry.uninstall()
- P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate
- P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service
- P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben
- P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend)
- P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt
- P2 Entity-Permission-Fallback fail-closed statt contacts:read
- P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020)
- P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery)
- P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts)
- P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion)

Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen
gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac,
lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0;
compileall sauber; ruff auf 7-Error-Baseline.

Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4),
plugin-development-guide.md Lifecycle, permissions.md Katalog.
2026-09-13 02:25:01 +02:00
Agent Zero 86cea5d6c4 fix(frontend): Interceptor normalisiert ALLE apiClient-URLs — auch Direktaufrufe und Cache-Alte-Chunks
Der apiX-Wrapper-Fix (744f2a1) heilte nur Wrapper-Aufrufe. Produktion-Logs
zeigten: POST /api/v1/api/v1/ai-proactive/context → 405 (4x vom User-
Browser mit alten gecachten Chunks). Der Request-Interceptor strippt jetzt
redundante /api/v1-Präfixe auf Transport-Ebene — heilt auch Direkt-
apiClient-Calls und stale Cache-Artefakte transparent.
2026-09-12 00:32:42 +02:00
Agent Zero ac5edef3dc docs(progress): UI-Backlog-Tracking — 16 UI-lose Module, Modul 1 (Approvals) erledigt (#369) 2026-09-08 23:33:43 +02:00
Agent Zero ecc7a24c1c feat(approvals): UI für Freigaben — Review-Queue mit Approve/Reject (Modul 1/16)
Backend:
- Phantom-Permission-Bug gefixt: approvals:read/write/approve fehlten in
  CORE_PERMISSIONS (Rollen konnten sie nie zugewiesen bekommen — gleiche
  Fehlerklasse wie dashboard:read in M2)

Frontend:
- api/approvals.ts: TanStack Hooks (list/detail/approve/reject/expire/create)
- pages/Approvals.tsx: Review-Queue — Status-Tabs (Offen/Alle/Genehmigt/
  Abgelehnt/Abgelaufen), Karten mit Aktion/Entity/Requester/Metadata,
  Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve)
- Route /approvals (PermissionRoute approvals:read), Sidebar-Eintrag
- i18n approvals.* + nav.approvals (de/en)

Verifikation: Vitest 10/10 (Rendering, Tabs, Approve/Reject-Flow,
Kommentar, Permission-Gating, Resolved-Zustände), RBAC-Regression 102/102,
tsc clean, Build OK
2026-09-08 23:26:56 +02:00
Agent Zero 4eb05d96ad fix(frontend): downloadIcsFile rief nicht existierenden Endpoint auf — auf echten ics-feed umgestellt
- Vorher: apiClient.get('/calendar/{id}/ics-feed-public') — Endpoint
  existiert im Backend nie (live 404 bewiesen via curl; Reverse-Check aus
  der Frontend-Backend-Gegenüberstellung)
- Jetzt: nutzt getIcsFeedUrl() mit optionalem Token — derselbe Fluss wie
  IcsControls (Backend auto-generiert ics_token beim ersten Hit)
- Funktion war tot (kein Aufrufer), aber garantiert kaputt für jeden
  künftigen Nutzer des Download-Buttons
2026-09-08 23:05:57 +02:00
Agent Zero 744f2a1dbf fix(frontend): URL-Normalisierung gegen Doppel-Präfix — Workspace-UI & Permission-Refresh in Produktion repariert
Problem: Axios baseURL '/api/v1' + 16 apiX-Calls mit vollem Präfix
('/api/v1/workspaces/...') ergaben '/api/v1/api/v1/...' → 404 live
(bewiesen per curl + Node). Betroffen: komplette Workspace-UI
(Switcher, Manager, Phase-N-Scope-Editor) + Permission-Refresh.

Fix (Defense-in-Depth):
- normalizeApiUrl() in client.ts: alle apiX-Wrapper strippen redundanten
  '/api/v1'-Präfix — deckt auch DYNAMISCHE Backend-Contract-Endpoints
  (N2-Scope-Editor-Wertquellen) ab, die Call-Sites nicht umschreiben können
- workspaces.ts + useUserPermissions.ts auf relative Pfade gesäubert (16 Calls)

Tests: clientUrl 8/8 neu (Normalisierung + Wrapper-Beweis), tsc clean,
Build OK, Workspace-Regression 3 Dateien grün
2026-09-08 22:53:11 +02:00
Agent Zero 03dd477899 feat(N4): Restliche Module — Tasks/Kommunikation/Wiki/Reports/Agents/Tags/Search + Navigation + Dashboard-Schnittstelle (#368)
Check Cross-Plugin Imports / check (push) Has been cancelled
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry
- Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M)
- Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard
- apply_entity_type_scope-Helper (requested ∧ scope)
- Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder()
- Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK
- Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
2026-09-01 23:23:15 +02:00
Agent Zero 26506a5027 feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
Check Cross-Plugin Imports / check (push) Has been cancelled
- Core-Resolver resolve_workspace_scope(): Zuweisungs-Check, leere Werte fallen weg; Exemptions System-Admin + workspaces:configure_modules (Editor-Deadlock)
- require_workspace_scope(module_key) FastAPI-Dependency (deps.py)
- expand_folder_scope(): Ordner-Subtree (zyklensicher) für ContactFolder + DMS Folder; scope_uuid_set() fail-closed
- contacts: folder_ids-Subtree + contact_types auf GET /contacts, List-Cache bei aktivem Scope deaktiviert (Cache-Leak-Gefahr)
- dms: folder_ids-Subtree + file_types (semantische Matcher) auf /files, Baum-Reduktion auf /folders
- mail: account_ids auf /mails, /threads, /accounts
- calendar: calendar_ids auf /calendar/entries, /calendars
- Frontend-Defaults: getModuleConfig() im workspaceStore, ContactsList default_saved_view_id, Calendar default_view
- Tests: 21/21 neu (TDD rot→grün), Regression 81 passed, Checker 0, tsc clean, Vitest grün, Build OK
2026-09-01 10:27:23 +02:00
Agent Zero b40adfdd3a feat(N2): Dynamischer Scope-Editor — WorkspaceScopeEditor ersetzt JSON-Textarea (#366)
- WorkspaceScopeEditor.tsx: generisches Filter-UI aus /scope-definitions (multiselect mit value_source-Fetch, select mit Keine-Einschränkung-Placeholder, toggle) — WidgetSettingsForm-Philosophie
- resolveScopeItems: Wertequellen-Auflösung (items-Wrapper, Root-Listen, DMS-Ordner-Baum-Flattening), nie-crashend
- Hooks: useWorkspaceScopeDefinitions + useScopeValues (TanStack Query, staleTime 60s)
- WorkspaceManager: JSON-Textarea entfernt, Scope-Editor inline pro sichtbarem Modul, Speicherung in workspace_modules.config
- i18n: workspaces.scopeEditor.* 5 Keys de/en (Security-Invariante im UI: nichts ausgewählt = keine Einschränkung)
- Tests: 21/21 (TDD rot 4→grün), tsc clean, Production-Build OK
2026-09-01 08:31:30 +02:00
Agent Zero c25356c257 feat(N1): Scope-Registry via Contract — workspace_scopes() Deklarationen + /scope-definitions Endpoint (#365)
Check Cross-Plugin Imports / check (push) Has been cancelled
- workspace_scopes() Contract-Hook (document_placeholders-Muster): Plugins deklarieren Scope-Dimensionen inkl. Wertequellen
- Deklarationen: contacts (Ordner/Typen/Saved-View), dms (Ordner/Datei-Typen), mail (Postfächer), calendar (Kalender/Standard-Ansicht)
- Pydantic fail-closed (schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1-Pfade, SSRF-sicher), WorkspaceScopeDimension, WorkspaceModuleScopes
- Aggregator workspace_scope_service.py: discovered-Plugins, ARCH-014-safe, Crash-sicher, ungültige Deklarationen verworfen
- GET /api/v1/workspaces/scope-definitions (workspaces:configure_modules) vor /{workspace_id} registriert
- Security-Invariante: Scope = reine UND-Einschränkung (Workspace ∧ RLS ∧ ABAC ∧ Permissions)
- Tests: 18/18 neu (TDD rot→grün), Regression 17/17, Checker 0 Verstöße, Ruff clean
- Doku: api-documentation.md Workspaces-Sektion, PROGRESS.md Phase N1
2026-08-31 23:17:54 +02:00
Agent Zero 6ed4bb7f98 docs(progress): M6 abgeschlossen — PHASE M KOMPLETT (M1-M6, alle Hosts live, send_miniapp in Produktion verifiziert) 2026-08-31 01:10:16 +02:00
Agent Zero 04e92794de fix(M6): agents/tools-Endpoint — list_for_api statt nichtexistenter list_tools (#364)
Check Cross-Plugin Imports / check (push) Has been cancelled
- Vorbestands-Bug (live gemessen: 500 "ToolRegistry has no attribute list_tools"):
  automation/agent_routes.py rief registry.list_tools() auf, ToolRegistry
  bietet get_all()/list_for_api() — Route auf list_for_api() mit korrektem
  Feld-Mapping (plugin_name -> plugin) umgestellt
- Regressionstest gesichert (test_agents_tools_route_uses_list_for_api)
- M6-Suite 8/8 gruen
2026-08-31 01:07:56 +02:00
Agent Zero 335762dd3d feat(M6): Weitere Hosts — MiniApps in Fenstern + AI-Agenten-Ausgabe-Bloecke (#364)
- Windows-Host: openMiniAppWindow-Helper + MiniAppWindowContent (windowStore);
  Oeffnen-Buttons im Chat-Block (MiniAppBlock) und Dashboard-Widget
- AI-Agenten-Host: Core-Tool send_miniapp (app/ai/miniapp_tools.py) —
  miniapp-Block in Agent-Chat (approval_request-Praezedenz), Permission
  fail-closed gegen aufrufenden User pro App; Registrierung im lifespan
- agent_loop: tool_context + agent_name (Raum-Aufloesung)
- Fix: MiniAppBlock nutzt useMiniapps (component-Feld) statt Legacy /comm/miniapps
- Tests: M6 7/7 (TDD rot->gruen), Backend-Regression 57/57, Vitest 26/26
  (4 neue Window-Tests), tsc clean, build OK
2026-08-31 01:04:33 +02:00
Agent Zero 63aa0cf788 docs(progress): M5 Plugin-MiniApps abgeschlossen — 11/17 renderbare Apps live, automation-Legacy-Bug gefixt 2026-08-31 00:18:08 +02:00
Agent Zero cd34bab3a8 fix(M5): automation-MiniApp-Registrierung — Legacy-Doppelregistrierung entfernt (#363)
Check Cross-Plugin Imports / check (push) Has been cancelled
- on_activate re-registrierte Manifest-MiniApps OHNE component/permission und
  ueberschrieb die korrekte M1-Registrierung aus super().on_activate()
  (live gemessen: automation_status comp=no/perm=- auf Produktion)
- Regressionstest sichert das Entfernen (test_automation_legacy_reregistration_removed)
- Regression: M5 8/8 + lifecycle + registry 26/26
2026-08-31 00:15:44 +02:00
Agent Zero 7ed5349e86 feat(M5): Plugin-MiniApps — dms, mail, wiki, graph_rag, automation (#363)
Check Cross-Plugin Imports / check (push) Has been cancelled
- 5 Manifest-Beiträge (MiniAppContribution): dms_folders (dms:read), mail_unread (mail:read), wiki_recent (wiki:read), graph_overview (graph:read), automation_status (automation:read) — je settings_schema max_items, order 60-100
- 5 Frontend-Widgets auf bestehenden API-Clients (keine neuen Backend-Endpoints): DmsFoldersWidget, MailUnreadWidget, WikiRecentWidget, GraphOverviewWidget, AutomationStatusWidget
- MiniAppHost-Registry +5; tsc clean, build OK
- Tests: M5 7/7 (TDD rot->gruen), Backend-Regression 53/53, Vitest 22/22
2026-08-31 00:10:31 +02:00
Agent Zero 24dc78977c docs(progress): M4 System-Rueckbau abgeschlossen — 6/12 renderbare Apps live, Core reiner Host 2026-08-30 22:24:38 +02:00
Agent Zero 3c496f4b6a feat(M4): System-Rueckbau — Dashboard-Inhalte als MiniApps, Core = reiner Host (#362)
Check Cross-Plugin Imports / check (push) Has been cancelled
- system_miniapps.py: audit_activity (audit:read, settings max_items) + system_metrics (settings:read) als Core-Apps in Registry
- base.py-Fix: native Manifest-MiniApps reichen component durch (M1-Luecke)
- contacts-Manifest: contacts_stats (ContactsStatsWidget, contacts:read, show_companies/show_persons)
- Seed-Fix: nur renderbare Apps (component) landen im Dashboard-Layout
- Frontend: ContactsStatsWidget, AuditActivityWidget, SystemMetricsWidget; MiniAppHost-Registry +3
- Dashboard.tsx = reiner Host (26 Z.); Page-Tests auf Pure-Host umgeschrieben
- Tests: M4 7/7 (TDD rot->gruen), Backend-Regression 46/46, Vitest 22/22, tsc clean, build OK
2026-08-30 22:22:20 +02:00
Agent Zero 9e254176c9 docs(progress): M3 Produktions-Verifikation nachgetragen — Frontend-Deploy 26948fd live, renderbare Apps 3/9 2026-08-30 21:30:10 +02:00
Agent Zero 26948fdb51 feat(M3): Dashboard-Builder — Edit-Modus, Drag&Drop, Palette, Tabs (#361)
- DashboardBuilder: @dnd-kit 12-Spalten-Flow-Grid (seed-konsistent), View/Edit-Schalter, Resize, Tab-Verwaltung, Dashboard-CRUD + Set-Default, Dirty-Save
- MiniAppHost ersetzt DashboardWidgetLoader (lazy Registry + settings-Props); Palette nur renderbare Apps (component-Filter)
- WidgetSettingsForm generisch aus settings_schema; Bestands-Widgets settings-fähig (RecentContacts: limit)
- api/miniapps.ts + api/dashboards.ts (TanStack-Query-Hooks, documents.ts-Muster)
- Dashboard.tsx = Builder-Host (StatCards/SystemMetrics bleiben bis M4); Legacy-Grid/Loader gelöscht, Geister-Test ersetzt
- Tests: Builder 13/13, Page 11/11, i18n de/en, tsc clean, build OK
2026-08-30 21:28:40 +02:00
Agent Zero 74827156d0 docs(progress): M2 Produktions-Verifikation nachgetragen — Deploy b3e259f healthy, RLS konvergiert, Lazy-Seed bewiesen 2026-08-30 16:24:12 +02:00
Agent Zero b3e259fc25 feat(M2): Persönliche Dashboards — Tabelle, CRUD, Lazy-Seed, RLS (#360)
Check Cross-Plugin Imports / check (push) Has been cancelled
- dashboards-Tabelle (Layout JSONB, Tabs, is_default, partial unique name index)
- 6 CRUD-Endpoints /api/v1/dashboards, Owner-only (saved_views-Präzedenz), Audit
- Lazy Default-Seed aus MiniApp-Registry (permission-gefiltert, 12-Spalten-Flow)
- CORE_PERMISSIONS dashboard:read/write (fixt Phantom-Permission in dashboard.py)
- Migration 0144: RLS crm_api+crm_worker + konvergenter Fix der 3 Phase-L-Policies
- Tests: test_dashboards_backend.py 23/23 (TDD rot->grün); Regression 162/163
2026-08-30 16:21:34 +02:00
Agent Zero 7a755d32e6 docs(progress): Uebergabe-Konsistenz — M1 done, naechster Schritt M2; Phase-M-Status in_progress statt not_started 2026-08-30 14:06:11 +02:00
Agent Zero 84cb82d2c4 feat(M1): Universal-MiniApp-Registry — Plugin-Layer, permission fail-closed, Lifecycle, /api/v1/miniapps
Check Cross-Plugin Imports / check (push) Has been cancelled
- app/plugins/miniapp_registry.py: Registry aus kommunikation in Plugin-Layer gehoben (Plattform-Konzept, hosts chat/dashboard/window)
- MiniAppDef: permission (fail-closed) + settings_schema + col/row_span + hosts + component + order + builtin
- MiniAppContribution + FrontendDashboardWidget (Manifest-Schema) um M1-Felder erweitert — dashboard_widgets ist Alias von miniapps (ein Contribution-Typ, #359-Philosophie)
- BasePlugin.on_activate: automatische Manifest-Registrierung; on_deactivate: unregister_plugin (nur eigene Apps)
- GET /api/v1/miniapps (server-seitig permission-gefiltert, ?host=) + GET /api/v1/miniapps/{app_id} (403/404 fail-closed)
- kommunikation/miniapp_registry.py = Kompatibilitaets-Bruecke (Bestands-Importer unveraendert)
- Doku: api-documentation.md + plugin-development-guide.md (MiniApp-Beitragsmuster)

TDD: Rot 16 failed -> Gruen 16/16; Regressionen: contracts 23/23, lifecycle+route_order 4/4; ruff clean (M1-Dateien, Vorbestand per Stash bewiesen); create_app OK
2026-08-30 13:00:33 +02:00
Agent Zero 5eade3e005 docs(roadmap): Phase P — Page-Lock-Konzept user-bestaetigt (is_locked, 409 page_locked, Lock-Button, Owner/Admin-only) 2026-08-30 12:47:20 +02:00
Agent Zero 65c22e9200 docs(roadmap): Phase P — Notizen-App (Notion-artig, ersetzt Wiki komplett)
- P1 Datenmodell (WikiPage: blocks JSONB, parent_id-Hierarchie, Migration Markdown->Text-Bloecke)
- P2 Sidebar Seiten-Baum (dnd-kit, Favoriten, Suche)
- P3 Inline-Block-Editor (Live-Editing ohne Mode-Toggle wie Notion, Slash-Menue, Auto-Save, Quer-Verweise, Drag&Drop)
- P4 MiniApp-Bloecke (erster MiniApp-Konsument) + wiki_blocks()-Contract (Plugin-Erweiterbarkeit, Basis fuer spaeteren Datenbank-Block)
- P5 Vollstaendige Such-Indexierung (content_tsv + Embedding-Chunks, hybrid FTS/vector, Re-Index bei Auto-Save)
- Edit-Konzept recherchiert: Notion hat keinen separaten Edit-Modus (Live-Inline-Editing, Auto-Save); Lese-Ansicht via Permissions + optional Page-Lock
- User-Entscheidungen: Wiki komplett ersetzen, keine Notion-Datenbanken erstmal, MiniApps als Bloecke
2026-08-30 12:42:42 +02:00
Agent Zero 3e5ce47798 chore(cleanup): Chaos-Beseitigung — Doppel-Phase-L aufgeloest, PROGRESS-stand modernisiert
- Roadmap: UI-Overhaul umbenannt zu Phase O (L war doppelt vergeben), Bug-Verifikation Phase 1 eingetragen (5/7 bereits erledigt: 1.1+1.4+1.5+1.6+1.7; offen: 1.2 Kontakte-Drag-Drop Ordner, 1.3 MoveDialog)
- AI-Assistant-Konflikt-Notiz entschieden: Option (b) — Seite bleibt, Phase-2-Vorschlag ueberholt
- Phase L Dokumente-Generator als ABGESCHLOSSEN markiert (b311ab7 + 559bba6, deployed, Alembic 0143)
- PROGRESS.md 'Weitermachen': veraltete Paketliste (alle 6 erledigt) + falscher Produktionsstand (20ff5e2/0142) ersetzt durch realen Stand (L-Deploy, 0143) + offene Phasen M/N/O + Vorbestands-Findings
- Lokale Artefakte entfernt: dump.rdb, frontend/test-results (4.3MB, beides war korrekt ignoriert)
2026-08-30 09:23:30 +02:00
Agent Zero f6516e48ca docs(roadmap): Phase N Workspace-Scopes + Workspace/Dashboard-Abgrenzung (user-korrigiert)
- Phase N: Modul-Teilmengen pro Workspace (N1 Scope-Registry via Contract, N2 dynamischer Scope-Editor, N3 Contacts/DMS/Mail/Calendar, N4 restliche Module)
- KLARE TRENNUNG Workspace vs Dashboard: Workspace = Admin-Gruppen-Kontext (was verfuegbar ist, workspace_widgets); Dashboard = persoenlich (Phase M, dashboards-Tabelle)
- 0 Umbau: config JSONB + X-Workspace-ID + /context + Sidebar-Consumer existieren bereits
- Security-Invariante dokumentiert: Scope = reine UND-Einschraenkung zu RLS/ABAC/Permissions
2026-08-30 09:18:00 +02:00
Agent Zero dfe46dff16 docs(roadmap): Phase M — MiniApp-Plattform & Dashboard-Builder verankert (M1-M6, user-abgestimmt)
- M1 Universal-Registry (permission fail-closed + settings_schema, /api/v1/miniapps, Server-seitiger Permission-Filter, Lifecycle-Cleanup)
- M2 Dashboard-Backend (dashboards-Tabelle pro User, Tabs, Layout JSONB, RLS, Dual-Path)
- M3 Builder-Frontend (Edit-Modus, dnd-kit Grid, Resize, Tabs, Settings-Form aus settings_schema)
- M4 System-Rueckbau (StatCards->contacts, ActivityFeed->audit, System-Metrics->System-MiniApp, alte Widgets migrieren)
- M5 Plugin-MiniApps (contacts, tasks, calendar, wiki, dms, mail, knowledge, automation)
- M6 Weitere Hosts (AI-Agenten-Tool-Ausgabe, Windows, Wiki-Eval)
- Basis-Live-Bestand dokumentiert inkl. bewiesener Luecken (MiniAppContribution ohne permission, FrontendDashboardWidget ohne settings_schema)
- dashboard_widgets wird Alias von miniapps (ein Contribution-Typ, #359-Philosophie)
2026-08-29 23:06:35 +02:00
Agent Zero 559bba69a4 feat(L4-L5): KI-Steuerung + XRechnung-Format-Layer (EN16931/CII)
Check Cross-Plugin Imports / check (push) Has been cancelled
L5 Format-Layer (User-Klaerung: Verkaufsmodul spaeter, Format JETZT):
- einvoice.py: EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes, Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (XRechnung 3.0)
- POST /einvoice/render (inline->XML), /einvoice/validate (422 mit BT-Fehlliste), /einvoice/render-for (Contract-Resolver einvoice_data() — Andockpunkt Verkaufsmodul, 404 no_data_source ohne Beitrag)

L4 KI-Steuerung:
- POST /documents/suggest: Natuerliche Sprache -> Block-Komposition via zentralem llm_complete (Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungueltige KI-Bloecke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles-Icon, Prompt-Textarea, Bloecke werden angehaengt), i18n de/en

TDD: Rot 25 failed -> Gruen 25/25 (Validierung, XML-Struktur/Escaping/Summen, Contract-Mocks, API 200/422/403/404, Suggest Mock-LLM/Fence/502). tsc exit 0, Build OK, ruff clean. Doku: api-documentation.md, plugin-development-guide.md (einvoice_data-Contract), PROGRESS.md
2026-08-29 18:05:55 +02:00
Agent Zero b311ab7aa1 feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled
- Briefpapier (letterheads): Seiten-Setup (A4/A5/Letter, Ränder), Header/Footer-Blöcke, Wasserzeichen, Logo-Upload (DocumentAsset, data:-URI-only)
- Druckvorlagen (print_templates): Block-Komposition mit Briefpapier-Ref + entity_type
- Block-Registry (document_blocks.py): text/image/shape(line/rect/circle)/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via document_blocks()-Contract
- Renderer (document_renderer.py): Blocks→HTML→PDF via WeasyPrint (SSRF-Sandbox data:-URI-only), @page-Frame mit running header/footer, Placeholder-Beispiel-Defaults gegen StrictUndefined
- Contract-Beitrag contacts: document_placeholders/document_data (#359-Muster wie importexport_entities)
- 13 neue Endpoints in documents.py: Letterhead-CRUD, Template-CRUD, Assets, document-blocks, document-placeholders, preview (HTML), render (PDF)
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path, RLS fail-closed crm_api)
- Frontend: api/documents.ts, Settings→Dokumente (settings_pages), BlockEditor (@dnd-kit Palette/Canvas/Config/Live-Preview-iframe), LetterheadEditor, PrintTemplateEditor, DocumentGenerationDialog (global, ContactDetailPage-Integration)
- i18n de/en, api-documentation.md, plugin-development-guide.md, PROGRESS.md

Verifikation: 32/32 neue Tests + 9/9 Regressionen, tsc exit 0, Build OK 2.79s, Alembic-Fresh-DB 0143 mit RLS bewiesen, ruff clean
2026-08-29 09:49:16 +02:00
Agent Zero fa429c3a88 docs(progress): Paket-6-Eintrag + offene Findings gepflegt (Frontend-Vorbestand erledigt, neuer Core-FK-Vorbestandsfund: entity_attachments.dms_file_id -> files blockiert alembic check) 2026-08-29 02:52:42 +02:00
Agent Zero 67c0dcd34c feat(#357): Paket 6 — Contact-Model ins ContactsPlugin (physischer Move + PEP-562-Lazy-Re-Export-Bruecke, ALEMBIC_OWNED_TABLES gegen Schema-Dual-Ownership, outbox-Vorbestands-Fix in models/__init__)
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-29 02:48:31 +02:00
Agent Zero df85fdcb5b feat(#358): Paket 5 — statische /contacts-Routen entfernt nach bewiesenem PluginRouteRenderer (nested Routes, :id-Matching, useParams), Contacts-Seiten in STATIC_COMPONENT_MAP (ARCH-019) 2026-08-29 02:10:32 +02:00
Agent Zero b5036a1fc0 feat(#357): custom_field_definitions generisch — W4b-Muster (422/403-Entity-Checks, {items,total}-Shape, ACL-Fix), zentrale Helper, Plural-Ableitungs-Fix 2026-08-29 01:27:24 +02:00
Agent Zero 36dd7c5101 test(#357): Router-Test gefixt (QueryClientProvider+Mocks, 2/2 passed); Geister-Test ContactEditModal nach §10 gelöscht (Komponente weg seit db4701b) 2026-08-28 23:38:01 +02:00
Agent Zero d9ca8af7e0 docs(progress): Weitermachen-Block fuer naechstes Modell ergaenzt — 7 offene Pakete mit Live-Messung + Repro-Steps, Offene-Findings aktualisiert 2026-08-28 22:49:40 +02:00
Agent Zero 20ff5e2142 feat(#358): W3c — /contacts Sidebar-Sonderfall entfernt (P10)
Check Cross-Plugin Imports / check (push) Has been cancelled
- ContactsPlugin-Manifest: menu_items + page_routes ergänzt
  (/contacts, /contacts/:id, /contacts/dedup — contacts:read-Gate)
- FrontendMenuItem/FrontendPageRoute-Imports in plugin.py ergänzt
- Sidebar.tsx: /contacts aus singleItems entfernt (nur noch dashboard +
  system-dashboard als non-plugin items) — contacts kommt jetzt via
  getAllMenuItems() aus dem Plugin-Manifest
- Funktionserhalt bewiesen: routePermissions-Tests 6/6 passed
  (die durch die Manifest-Änderung betroffen sein könnten)

fixes #358 (P10-Teil)
2026-08-28 22:08:46 +02:00
Agent Zero eebc2cf4de docs(roadmap): Phase L — Dokumente-Generator geplant (Briefpapier+Blöcke+Drag/Drop+KI+E-Rechnung, Basis: report_generator, Contract-Muster wie Import/Export) 2026-08-28 21:57:30 +02:00
Agent Zero ad848a5053 feat(#359): export_service.py konsolidiert — /export via ContactsContract
Check Cross-Plugin Imports / check (push) Has been cancelled
Der 78-Zeilen-Duplikat-Export (app/services/export_service.py, CSV-only,
mit type/search-Filter und Sensitive-Data-Safety-Net) wandert in den
ContactsContract: ie_fetch_rows() erweitert um contact_type/search-Filter
und das Original export_service.py CSV-Profil (17 Spalten inkl.
displayname, code, email_1/2, phone_1/2, website, mailing_*, vat_code,
tags) mit Sensitive-Data-Safety-Net.

contacts/routes.py /export nutzt jetzt den Contract statt export_service.
app/services/export_service.py geloescht.

Funktionserhalt bewiesen: 15/15 tests/test_performance.py passed
(inkl. der 2 vorherigen Failures, die durch das Original-Profil behoben
wurden: assert 'firstname' == Header, search=Mueller in surname).

fixes #359 (export_service-Konsolidierung)
2026-08-28 20:31:27 +02:00
Agent Zero b7194f0d58 docs(progress): W4c Custom-Fields-Routen in ContactsPlugin migriert dokumentiert — Funktionserhalt 11/11 bewiesen (#357) 2026-08-28 13:25:44 +02:00
Agent Zero c6decf5556 feat(#357): W4c — Custom-Fields-Routen aus Core in ContactsPlugin migriert
Check Cross-Plugin Imports / check (push) Has been cancelled
Kritikpunkt 14: app/routes/custom_fields.py war 100% Contact-spezifisch
(importiert Contact, nutzt contacts:read/write, Route /{contact_id}/custom-fields)
aber lag als scheinbar generischer Core-Service.

Fix: Die komplette Logik (2 Endpoints GET/PATCH /{contact_id}/custom-fields,
_collect_custom_field_definitions, _merge_definitions_with_values) wandert in
app/plugins/builtins/contacts/routes.py (gleicher Router-Prefix /api/v1/contacts).
app/routes/custom_fields.py geloescht, main.py bereinigt.

generischer custom_field_definitions.py-Endpoint bleibt im Core (echtes Core-
Entity). Frontend-Endpoint-Shapes unveraendert.

Funktionserhalt bewiesen: 11/11 tests/test_custom_fields.py passed.

fixes #357 (W4c-Teil)
2026-08-28 13:20:54 +02:00
Agent Zero d7b3c7c1b5 chore: Root node_modules aus Repo entfernt + .gitignore ergänzt (versehentlich committet durch vitest-worker) 2026-08-28 12:42:09 +02:00
Agent Zero cd988d6163 fix: versehentlich committetes node_modules/.vite entfernt 2026-08-28 12:41:41 +02:00
Agent Zero 0d052ab604 fix(#357): Zwei bewiesene Vorbestand-Fixes
1. Saved-Views/Filters: invalid entity_type wirft jetzt 422 (FastAPI-
   Validierungs-Konvention) statt 400 — Test-Expectation war korrekt.
   Beweis: test_create_saved_filter_invalid_entity_returns_422 passed.

2. AppShell.test.tsx: useCurrentUser-Export im @/api/hooks-Mock ergaenzt
   (fehlt seit jeher — Vorbestand). Der urspruengliche 'No useCurrentUser
   export' Error ist weg.

fixes #357 (Vorbestand-Teil)
2026-08-28 12:41:25 +02:00
Agent Zero b7b7d41c0c feat(#357): W4b — Saved-Views/Filters von contacts:read entkoppelt
Check Cross-Plugin Imports / check (push) Has been cancelled
- ENTITY_PLUGIN_OWNERS-Registry: trackt, welches Plugin welche Entity registriert
- get_entity_read_permission(): leitet die modul-korrekte Permission ab
  (contacts -> contacts:read, tasks -> tasks:read, ...) mit Core-Fallback
- registry.activate(): uebergibt plugin_name an register_entity_model
- saved_views.py + saved_filters.py: statische contacts:read-Dependencies
  durch dynamische _check_entity_read() ersetzt — Saved Views/Filters fuer
  fremde Entities brauchen jetzt die richtige modul-spezifische Permission

Verifikation: 10/11 tests/test_saved_filters.py passed (1 Vorbestand-
Failure per Stash bewiesen), create_app OK, ruff modified-files gruen.

fixes #357 (W4b-Teil)
2026-08-28 12:07:57 +02:00
Agent Zero 9bb1dbae03 docs(progress): W3b Settings Contribution-Wahrheit dokumentiert — 3 Plugin-Duplikate aus hardcodedNavItems entfernt, Dashboard-Loader als Vite-Technik verifiziert 2026-08-28 07:14:14 +02:00
Agent Zero b1a75510d6 refactor(#358): W3b Settings Contribution-Wahrheit — hardcoded mail/ai/notifications Nav-Items entfernt (7 Plugins liefern settings_pages via Manifest, Dedup greift nicht mehr); Dashboard-Verify: Loader-Registry ist Vite-Code-Splitting-Technik, Widgets kommen via Manifest-API (Kritikpunkt 20a teilweise widerlegt) 2026-08-28 07:13:01 +02:00
Agent Zero 1acef9669a docs(progress): Suite-Isolation behoben (#357) — close_engine nullt globale Engines, Engine-Restore nach Teardown, ACL-Batch 130 passed 2026-08-28 00:03:03 +02:00
Agent Zero b691dd36c0 fix(#357): Suite-Isolation behoben — close_engine() nullt globale Engines
Mechanismus (Live-Messung): Die mail_app-Fixture in tests/test_rbac_comprehensive.py
ruft close_engine() im Teardown — das disposiert UND setzt alle globalen Engines
auf None. Jede nachfolgende Test-Suite brach mit 'relation "users" does not exist'.

Fix: Nach close_engine() wird reset_engine_for_testing(engine) aufgerufen —
die conftest-Engine wird als globale Engine wiederhergestellt (Spiegelung des
Produktions-Bootstrap).

Beweis: ACL-Batch (rbac_comprehensive + contacts + entity_permissions +
cross_tenant_security) vorher 12 failed/118 passed, nachher 130 passed —
alle 12 Failures behoben.

fixes #357 (Isolation-Teil)
2026-08-28 00:02:15 +02:00
Agent Zero 9c62d35047 docs(progress): W4a Phase 1+2 dokumentiert — Backend-Kern + Frontend-Dialog deployed, Funktionserhalt 45/45 bewiesen, Vorbestand-Failures Stash-geprueft (#359) 2026-08-27 21:35:56 +02:00
Agent Zero 38df597f11 feat(#359): W4a Phase 2 — zentraler Import/Export-Dialog (Frontend)
- ImportExportDialog.tsx (neu): Modal lg/xl nach bestehendem ui/Modal-Muster
- Export-Tab: Formatauswahl (csv/xlsx/json), Download, Fehler-Handling
- Import-Tab: 4 Schritte (Datei -> Mapping -> Dry-Run -> Ausführung+Report),
  Mapping-Vorschau mit Modul-Heuristik, Background-Job-Polling ab 1000 Zeilen
- i18n: 24 importexport.*-Keys in de.json + en.json (keine hardcoded Strings)
- Integration: ContactsList Toolbar-Button (contacts:read-Gate, Upload-Icon,
  entityType=contacts vorgewählt) über bestehendes pluginToolbarStore-Muster

Gates: Vitest 12/12 (routePermissions + importExportDialog), tsc exit 0,
Production-Build exit 0 (vor Commit). 6 Failures in contacts/shell Suiten
als Vorbestand bewiesen (Stash-Test: identisch auf clean HEAD f27f047).

fixes #359 (Phase 2)
2026-08-27 21:34:13 +02:00
Agent Zero cd8ef7500c feat(#359): W4a Phase 1 — Import/Export Contribution-Architektur Kern
Check Cross-Plugin Imports / check (push) Has been cancelled
- Format-Registry (app/core/importexport_registry.py): FormatHandler-Protokoll,
  available_for() Schnittmenge, Singleton + Testing-Reset
- importexport_formats-Plugin (csv/json/xlsx), lifecycle-korrekt: on_activate
  registriert Handler in der Core-Registry, on_deactivate unregistriert
- ContactsContract: importexport-Beitrag (ie_*-Methoden) — Contacts besitzt
  seine Import/Export-Fachlogik jetzt selbst
- import_export_service.py: generische Engine, delegiert generisch ueber
  registry.list_discovered() an den besitzenden Contract (keine hartcodierten
  Plugin-Namen mehr); Signaturen identisch
- Funktionserhalt bewiesen: 45/45 import_export-Suite passed (inkl.
  Fehler-Multiplizitaet: 2 failed rows -> 3 total_errors, erreicht via
  ie_required-Weitergabe + ie_row_valid-Nur-contacts-Early-Return)

fixes #359 (Phase 1)
2026-08-27 21:11:14 +02:00
Agent Zero d8a4063c48 docs(progress): W4a Import/Export Contribution-Architektur — finale Spec (#359) festgehalten, zentraler Dialog per Toolbar-Button, Formate als Plugins 2026-08-27 20:47:00 +02:00
Agent Zero 56dcc86254 docs(progress): Welle 3a — Route-Permission-Wahrheit dokumentiert (#358), Gates tsc+Build+Vitest bewiesen 2026-08-27 20:06:02 +02:00
Agent Zero f27f0474ef fix(#P7-P8): Statische Route-Permissions auf Backend-/Manifest-Wahrheit gestellt — /communication comm:read (Phantom communication:read entfernt), /mail/settings mail:config (Backend verlangt config), /import-export import_export:read (Core-Modul, kein Plugin — Kritik-Aussage korrigiert), /activity audit:read (Phantom activity:read entfernt, Seite nutzt Audit-API), /wiki wiki:read (vorher ungeschützt); Regressionstest routePermissions.test.ts 6/6; Gates: tsc 0, Production-Build exit 0 2026-08-27 20:04:18 +02:00
Agent Zero 88d96d4a49 docs(progress): Welle 2b — Contacts-Entity-Registry Single-Source dokumentiert (#357), Vorbestand-Isolation per Stash bewiesen 2026-08-27 18:54:15 +02:00
Agent Zero e1a59e759f refactor(#11-Kritik): Contacts-Entities aus statischem Core-Registry entfernt — ContactsPlugin.get_entity_models() ist Single Source (contact/contacts/company); conftest spiegelt Produktions-Bootstrap idempotent (autouse-Fixture); Regressionstests beweisen Plugin-Registrierung; 12 ACL-Batch-Failures per Stash-Test als Vorbestand bewiesen (Suite-Isolation, identisch auf clean HEAD) 2026-08-27 18:53:12 +02:00
Agent Zero ad5601eb7d refactor(#356): DSAR-Fachlogik vollstaendig aus dem Core extrahiert — dsar_collect/dsar_erase in die 5 beteiligten Contracts (contacts, mail, tasks, calendar, kommunikation); core/jobs.py sammelt/loescht nur Core-eigene Daten und iteriert generisch ueber die Plugin-Registry; neue Plugins liefern DSAR-Kategorien ohne Core-Aenderung; Counts-/Category-Keys unveraendert; tasks/contracts.py von Patch-Artefakt bereinigt
Check Cross-Plugin Imports / check (push) Has been cancelled
fixes #356
2026-08-27 18:09:15 +02:00
Agent Zero 092c2d20fb fix(#356): DSAR-Sammlung auf Contract-Zugriff umgestellt — 4 Core→Plugin-Imports (mail/tasks/calendar/kommunikation) nutzen jetzt get_contract(); MailContract exponiert MailAccount; ImportError-Fallback-Semantik unverändert; Checker 4→0 Verstöße; DSAR-Suite 4/4 passed
Check Cross-Plugin Imports / check (push) Has been cancelled
fixes #356
2026-08-27 17:38:06 +02:00
Agent Zero 385521eddc fix(#355): Plugin-Lifecycle Runtime-Registrierungen repariert — Vorher-Status wird jetzt VOR dem registry-Aufruf gelesen (war konstant falsch → Deactivate-Cleanup toter Code); sync_notification_types hinter DB-Statusupdate verschoben; echter Integrationstest tests/test_plugin_lifecycle_service.py (install→activate×2→deactivate×2→re-activate über PluginService) rot→grün
Check Cross-Plugin Imports / check (push) Has been cancelled
fixes #355
2026-08-27 13:32:51 +02:00
Agent Zero 422cc6139d docs(progress): Legacy-Cleanup dokumentiert — toter AI-Copilot entfernt (b50a933), Vorbestand-Failure #354 bewiesen, gegengepruefte Kritik-Punkte vermerkt 2026-08-27 13:10:12 +02:00
Agent Zero b50a933d85 chore(cleanup): toten AI-Copilot-Legacy entfernt — Router nie gemountet, Tabellen von Migration 0137 gedroppt (Chat läuft seitdem über kommunikation/comm_conversations); schemas/OpenAPI-Tag bereinigt; Geister-Test test_ai_copilot.py geloescht (pytest.skip seit Phase 2); test_contacts_lifecycle Route-Anzahl-Failure als Vorbestand bewiesen (83 Routen auch auf clean HEAD) 2026-08-27 13:09:09 +02:00
Agent Zero ebf4b0363c fix(#351): CSRF-403 bei KI-Chat und Wiki-Save behoben — /auth/me liefert csrf_token, streamChat nutzt gemeinsamen Client-Token statt totem sessionStorage-Key; Regressionstests pytest+vitest 2026-08-27 11:03:24 +02:00
Agent Zero 9510b3a7c9 fix(plugins): FastAPI-Route-Matching — /plugins/{name} (d9aed51) verschlang literale Routen /active-manifests, /manifest, /updates (404 'Plugin not found'); Reihenfolge korrigiert: statische Routen jetzt vor /{name}. Folge war: Sidebar ohne Plugin-Menüeinträge in Produktion (nur Kontakte/Dashboard/System). +2 Regressionstests 2026-08-27 09:58:21 +02:00
Agent Zero 66c11d3d64 revert(frontend): Frontend auf letzten funktionierenden Stand 5680179 zurückgesetzt — i18n-Massen-Batch brach Dashboard-Shell in Produktion; Render-Loop-Fix und DSGVO-Antrags-UI liegen sicher in Historie für kontrollierten Wiedereinspiel 2026-08-27 09:14:21 +02:00
Agent Zero bea479bfad chore(tracking): Drei parallele Tracking-Dateien aufgelöst — fix-plan-v3 und test-bugs nach docs/archive/ historisiert; PROGRESS.md ist einzige Source of Truth mit verifizierten offenen Findings (Live-Messung 2026-08-27); Ein-Datei-Regel in AGENTS.md §10 2026-08-27 08:50:18 +02:00
Agent Zero 70dc0af0b6 docs(progress): Verifikationslauf — alle gemeldeten Vorbestand-Testfailures längst grün, echter Render-Loop gefixt, 17 Vitest-Failures nachgezogen, Geister-Tests entfernt 2026-08-27 08:26:47 +02:00
Agent Zero cfb2bfe7b8 docs(bugs): BUG-022/070/093–098 als erledigt dokumentiert — npm audit live 0 vulnerabilities, pytest-Suiten cross_tenant/api_audit/commands/auth/rls 66 passed + mail 46 passed + phase_g/spike_i 46 passed (2026-08-27 verifiziert) 2026-08-27 08:26:27 +02:00
Agent Zero 5874975ff9 chore(test): 5 Geister-Tests entfernt (Komponenten wurden bereits in db4701b als BUG-080/082 unused gelöscht) und Playwright-e2e-Specs aus der Vitest-Einsammelung ausgeschlossen — sie gehören zum eigenen Runner mit eigener Konfiguration 2026-08-27 08:26:27 +02:00
Agent Zero 1c52d3e502 test(frontend): veraltete Testerwartungen an aktuelle UI angepasst — Tasks 3-Spalten-Layout mit Toolbar-Store statt Inline-Button, MiniAppBlock async-Fetch + DOMPurify-Attributentfernung, Router-QueryClientProvider, Toast-Mocks auf flache echte API-Signatur, automation-Mocks mockResolvedValue 2026-08-27 08:26:27 +02:00
Agent Zero 1a24e3e999 fix(frontend): render-loop in Tasks/Reports/Communication behoben — usePluginToolbarStore wurde ohne Selector destrukturiert; jedes Store-Update re-renderte alle Seiten inkl. registerItems-Effektkette (Maximum update depth in Tests sichtbar). Selektor-Pattern wie Dms/Mail/Calendar/ContactsList 2026-08-27 08:26:27 +02:00
Agent Zero a796438dfa docs(progress): kommunikation-Split, i18n-Batch und G1-b Frontend-DSAR-UI als done verifiziert — Bloecke 0/H/A-E/G/F complete 2026-08-27 01:50:59 +02:00
Agent Zero 05bc1e2543 feat(compliance): G1-b frontend DSAR status UI — 4th subtab in ComplianceTab: type selection (Art.15/17/16), person picker, direct GDPR export download, two-step deletion confirmation; uses existing system-settings DSAR endpoints 2026-08-27 01:49:55 +02:00
Agent Zero 4cb5298768 feat(i18n): migrate hardcoded German strings to t() across 104 components/pages — AST-based batch with re-parse gate, 423 new de.json keys; tsc clean; vitest failures byte-identical to clean-tree baseline (pre-existing) 2026-08-27 01:43:06 +02:00
Agent Zero 5680179260 refactor(kommunikation): split god-object services.py into 6 focused sub-modules with re-export facade — behavior identical (comm suite 132P/1F/6E pre-existing, failures byte-identical to pre-split baseline)
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-27 01:18:15 +02:00
Agent Zero 7f6b52b8d0 docs(progress): DMS Vorbestand-Bugs ×4 behoben dokumentiert — 129/129 grün 2026-08-27 00:40:37 +02:00
Agent Zero 84061fd8d5 fix(dms-tests): multiple_files variierter Upload-Inhalt gemäß Dokumentkonvention (freigegeben)
Der Test lud 3x byteidentisches PDF_CONTENT und erwartete dennoch 3 Dateien — Kollision mit dem bewussten content_hash-Dedup-Feature (routes.py Z.145-160, inkl. Storage-Bereinigung des Duplikats). docs/test-strategy.md-Konvention angewendet: unterschiedlichen Inhalt je Upload. Jetzt PDF_CONTENT + str(i).encode(). Produktionscode unverändert, Dedup bleibt vollständig aktiv.
2026-08-27 00:40:36 +02:00
Agent Zero e0255412ac fix(dms): 3 von 4 Vorbestand-Testfailures behoben — Suite 125->128 gruen
Check Cross-Plugin Imports / check (push) Has been cancelled
1. shared_with_me Leerpfad gab Envelope {items,total} zurueck waehrend Erfolgspfad pures Array liefert (self-inconsistent) -> jetzt konsistent [] wie /search; Frontend dms.ts vertraegt beide Shapes
2+3. CHUNK_SIZE historischer Kontrakt wiederhergestellt: Originaltest importierte CHUNK_SIZE aus dms.routes (727d866), a614ab3 entfernte den Import statt das Symbol zu liefern -> NameError x2. Jetzt: oeffentliche Konstante in common.py + Re-Export + Importzeile im Test restauriert

Beweis: Full-DMS-Suite 129 Tests = 128 passed + 1 failed (nur multiple_files, s. Follow-up) vs Baseline 125+4
2026-08-26 23:17:38 +02:00
Agent Zero 4cf7a91416 docs(progress): I-G-Rest God-Object Split 2 dokumentiert — dms/routes.py -56% Fassade+3 Sub-Router, Baseline-Regression 1:1 bewiesen 2026-08-26 22:03:07 +02:00
Agent Zero f445aa69d5 refactor(i-g): BUG-018 God-Object Split 2 — dms/routes.py von 1492 auf 650 Zeilen (-56%)
Check Cross-Plugin Imports / check (push) Has been cancelled
- common.py neu: alle Safety-/Storage-Helper und Konstanten (exakte Original-Implementierung)
- folders_routes.py / sharing_routes.py / search_bulk_routes.py je eigener Router ohne Prefix
- routes.py: File-Lifecycle-Kern bleibt physisch (MAX_FILE_SIZE-Test-Patch-Semantik), Rest als Re-Export-Fassade + include_router x3
- Beweis: DMS-Suite 129 Tests = 125 passed + 4 identische Vorbestand-Failures (Baseline-Referenz 1:1), 20/20 Routen via Router-Introspection, ruff clean
2026-08-26 22:02:32 +02:00
305 changed files with 37220 additions and 7171 deletions
+4 -4
View File
@@ -44,17 +44,17 @@ STORAGE_PATH=/data/storage
# --- SMTP (for password reset emails) -----------------------------------------
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=YOUR_SMTP_PASSWORD
SMTP_FROM=noreply@example.com
SMTP_TLS=true
SMTP_FROM_EMAIL=noreply@example.com
SMTP_USE_TLS=true
# --- bcrypt tuning ----------------------------------------------------------
BCRYPT_ROUNDS=12
# --- Admin user (seeded on first start) --------------------------------------
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=Admin123!
ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
# --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
# AES-256 encryption key for mail account passwords (Fernet).
+4 -4
View File
@@ -90,10 +90,10 @@ S3_SECURE=true
# === SMTP / EMAIL ===
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=no-reply@localhost
SMTP_TLS=true
SMTP_FROM_EMAIL=no-reply@localhost
SMTP_USE_TLS=true
# === RATE LIMITING ===
RATE_LIMIT_LOGIN_MAX=5
@@ -134,4 +134,4 @@ API_GIT_BRANCH=main
# === Admin User (auto-seeded on first start) ===
ADMIN_EMAIL=admin@media-on.de
ADMIN_PASSWORD=Admin123!
ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
+1
View File
@@ -36,6 +36,7 @@ dump.rdb
# Frontend build output (regenerated on deploy)
frontend/dist/
frontend/node_modules/
node_modules/
# IDE
.idea/
+7
View File
@@ -333,3 +333,10 @@ Ein Task gilt erst als **DONE** wenn alle 8 DoD-Kriterien erfüllt sind (siehe `
### 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.
## 10. Tracking-Ein-Datei-Regel (bindend seit 2026-08-27)
- **PROGRESS.md ist die einzige Source of Truth** fuer Status und offene Punkte. Keine weiteren parallelen Tracking-Dateien (test-bugs.md/fix-plan-v3 sind in docs/archive/ historisiert).
- Ein Finding wird nur eingetragen mit **tagesaktueller Live-Messung** (Befehl + Zaehler). 'Scanner sagt' oder Plan-Text allein reicht nie.
- Tests duerfen nur zusammen mit Pflegeanspruch entstehen: UI-Aenderung zieht Test-Nachzug im selben Commit nach sich. Geister-Tests (Importziel geloescht) werden sofort geloescht.
- Playwright-e2e bleibt dem eigenen Runner vorbehalten (vite.config exclude), kein Vitest-Collection.
+361 -9
View File
@@ -1210,20 +1210,20 @@ Trigger / Event / Cron / Webhook / Agent
---
## Phase L — UI-Overhaul (Status: geplant, NICHT gestartet)
## Phase O — UI-Overhaul (Status: geplant, 2026-08-30 verifiziert)
> **Umbenannt von 'Phase L' (2026-08-30):** Der Buchstabe L war doppelt vergeben (UI-Overhaul + Dokumente-Generator). UI-Overhaul ist jetzt Phase O; Phase L = Dokumente-Generator (abgeschlossen).
> **Bug-Verifikation Phase 1 (2026-08-30, Live-Messung):** 1.1 Kontakte-Invalidation ✓ gefixt (invalidateQueries vorhanden) · 1.2 Drag-Drop Kontakte→Ordner ✗ offen · 1.3 MoveDialog ✗ offen (existiert nicht) · 1.4 Wiki-Save ✓ verdrahtet (apiPost/apiPatch live) · 1.5 Kalender-Dialog ✓ gefixt (onSaved-Handler) · 1.6 Neuer Chat ✓ gefixt (createConversation + Button) · 1.7 Wiki doppelt ✓ kein Bug (1 Menü-Eintrag + 1 page_route, konsistent). Status 'NICHT gestartet' war falsch — 5/7 Bugs bereits erledigt.
> **Herkunft:** Am 2026-08-25 aus der eigenständigen Datei `UI_OVERHAUL_PLAN.md`
> hier integriert - gemaess AGENTS.md-Regel "PLATFORM_ROADMAP.md ist EINZIGE
> Planungs-Datei". Vollständiges Original inkl. ASCII-Mockups abrufbar via
> `git show c807aac:UI_OVERHAUL_PLAN.md`.
>
> **Konflikt-Notiz (2026-08-25, Block I-D):** Phase 2 unten sieht "AI Assistant
> Page entfernen" vor. Die Seite wurde jedoch in Commit 962e0ee bewusst GEBAUT,
> um die Geister-Route /ai-assistant zu reparieren (im Backend-Manifest
> referenziert, aber nicht existent -> ErrorBoundary in Production). VOR
> Umsetzung von Phase 2 neu entscheiden: (a) Seite doch entfernen - dann auch
> Manifest-Route entfernen, oder (b) Phase 2 verwerfen zugunsten der aktuellen
> Architektur. Bitte nicht unkommentiert ausfuehren.
> **Konflikt-Notiz (2026-08-25, Block I-D) — ENTSCHIEDEN (2026-08-30):** Option (b)
> gilt — die AI-Assistant-Seite bleibt (962e0ee, repariert die Geister-Route
> /ai-assistant). Phase 2 ("AI Assistant Page entfernen") ist UEBERHOLT und
> wird nicht umgesetzt. Original-Notiz: git show f6516e4:PLATFORM_ROADMAP.md.
> **Erstellt:** 2026-08-21
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
@@ -1529,7 +1529,7 @@ Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupli
---
### Phase-L-Phasenübersicht
### Phase-O-Phasenübersicht
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|-------|--------|---------|-----------|-------------|
@@ -1590,8 +1590,360 @@ Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupli
| I — Integration & Human-AI Workstream | 6 Wochen | Agent↔Workflow↔Knowledge↔Communication, echte MiniApps, Shared/Proactive/Mobile Workstreams, Dashboard, MCP, Polish |
| J — Controlled Self-Improvement | 5 Wochen | Improvement Signals/Proposals, Evaluation/Dry-Run, Approval, Versionierung/Rollback, Wirkungsmessung |
| K — EU Compliance Finalization | 1 Woche | AI-Use-Case-Register, DPIA/AI-Impact-Support, Incident/Retention, Compliance-E2E, Betriebsdoku |
| **L — Dokumente-Generator** | **~3 Wochen** | Briefpapier + Block-System + Drag/Drop-Editor + KI-Steuerung + E-Rechnung (Contract-Muster wie Import/Export) |
| **Total** | **52 Wochen** | **LeoPlatform Endstand-Kern inkl. Privacy/DSGVO/EU-AI-Act-by-Design** |
---
*Diese Roadmap basiert auf dem Endstand-Audit des aktuellen Code-Archivs und der gemeinsamen Detail-Review. Ziel bleibt: keine unnötigen Universalmodelle, keine Massenrefactorings und keine parallelen Mechanismen. Gemeinsame technische Kerne werden dort genutzt, wo Semantik wirklich gleich ist; fachliche Speziallogik bleibt erlaubt. Bestehender funktionierender Code wird respektiert. Die eingebauten Privacy-/AI-Compliance-Funktionen schaffen technische Voraussetzungen und Nachweise; die rechtliche Konformität eines konkreten Deployments/Branchenplugins hängt zusätzlich von dessen tatsächlichem Zweck, Datenverarbeitung, Betreiberrolle und organisatorischen Maßnahmen ab.*
---
## Phase L — Dokumente-Generator ✓ ABGESCHLOSSEN (2026-08-29/30, Commits b311ab7 + 559bba6, deployed, Health healthy, Alembic 0143)
**Ziel:** Zentrale Dokument-Generierung mit Briefpapier + dynamischen Blöcken, Drag/Drop-Editor, KI-Steuerung, E-Rechnung-Fähigkeit. Module registrieren ihre Blöcke als Contribution (Contract-Muster wie Import/Export).
**Basis:** report_generator-Plugin (Jinja2-Templates, pdf_generator.py, Background-Jobs, ReportTemplate/ReportInstance-Models) — Erweiterung statt Neubau.
### L1 — Block-System (2-3 Tage)
- Briefpapier-Modell (pro Tenant: Logo, Header/Footer, CSS)
- Block-Modell (typ: text/table/chart/placeholder, order, content)
- Block-Registrierung durch Module via Contract (`document_blocks` wie `importexport_entities`)
- print_templates-Tabelle (Briefpapier-Ref + Block-Komposition)
### L2 — Drag/Drop-Editor (3-5 Tage)
- Frontend: Block-Palette (registrierte Blöcke des Moduls), Canvas, Platzierung
- Placeholder-Editor (`{{firstname}}`, `{{company.logo}}`)
- Live-Preview
### L3 — Renderer-Integration (1-2 Tage)
- report_generator-Engine an Block-Komposition anbinden
- Jinja2-Templates aus Block-Komposition generieren
- PDF/Excel/CSV-Output über bestehende Engine
### L4 — KI-Steuerung (1-2 Tage)
- „Erstelle Rechnungsvorlage" via AI-Module (agent_loop existiert)
- Template-Vorschläge aus Block-Komposition
### L5 — E-Rechnung (2-3 Tage)
- XRechnung/ZUGFeRD-Format (Verkauf-Modul registriert Rechnungs-Blöcke)
- Klären: Steuer-Behörden (Deutschland, B2B-Pflicht ab 2027) oder Kunden-Lieferungen?
**Abhängigkeiten:** L5 benötigt Phase F (Agents) und das Verkaufs-Modul (noch nicht gebaut).
**Verwandte Issues:** #359 (Import/Export Contribution — gleiche Plugin-Philosophie).
---
## Phase M — MiniApp-Plattform & Dashboard-Builder (geplant, user-abgestimmt 2026-08-29)
**Ziel:** MiniApps als universelles, teilbares UI-Baustein-System über alle Hosts (Chat, Dashboard, Windows, AI-Agenten). Dashboard-Builder mit Edit-Modus, Drag&Drop, Resize, Tabs und pro-Widget-Settings. System-Dashboard-Teile werden zurück in Plugins gebaut (Core wird zum reinen Host).
**Basis (Live-Bestand 2026-08-29):**
- `kommunikation/miniapp_registry.py` (92 Z., MiniAppDef mit register/unregister/unregister_plugin — inkl. Lifecycle-Cleanup)
- `MiniAppContribution` im Manifest-Schema (app_id, name, icon, description, render_schema) — **LÜCKE: kein permission-Feld**
- `FrontendDashboardWidget` im Manifest (id, component, col_span, row_span, permission) — **LÜCKE: kein settings_schema**
- `MiniAppBlock.tsx` als comm-Block-Typ (Chat-Host — fertig verdrahtet)
- `DashboardGrid`/`DashboardWidgetLoader` + 4 Widgets (RecentContacts, TasksSummary, CalendarUpcoming)
- Dashboard.tsx (170 Z.) mit hardcodierten StatCards (via contacts-Contract `get_counts`), ActivityFeed (via Audit-Log), System-Metrics (Admin-only) — **Rückbau-Bestand**
- `app/routes/dashboard.py` listet manifest `dashboard_widgets` (bereits permission-agnostisch, nur `dashboard:read` auf Endpoint-Ebene)
- @dnd-kit (core/sortable/utilities) bereits im Projekt (Referenz: SettingsMenuOrder, Dokumente-BlockEditor)
- windowStore (Window-Manager) existiert für spätere Hosts
**Architektur-Entscheidung (user-bestiätigt):** EINE Universal-Registry statt zweier paralleler Systeme — `dashboard_widgets` wird Alias von `miniapps`; jedes Plugin/System registriert MiniApps via Contribution (gleiches Muster wie settings_pages/print document blocks, #359-Philosophie). Ein Host-Set: Chat-Block (fertig), Dashboard (neu), Windows (M6), AI-Agenten-Tool-Ausgabe (M6).
**⚠️ Abgrenzung Workspace ≠ Dashboard (user-korrigiert 2026-08-30):**
- **Dashboard (diese Phase M)** = PERSÖNLICH: jeder User baut eigene Dashboards (Layout/Tabs/Instanzen) — Speicher ist die NEUE `dashboards`-Tabelle (owner-basiert). NIEMALS `workspace_widgets` dafür verwenden.
- **Workspace (Phase N)** = ADMIN-Kontext für Gruppen: welche Module sichtbar sind (fertig) + Modul-Teilmengen (Scopes) + welche Widget-TYPEN der Workspace anbietet (`workspace_widgets`, existiert bereits — Workspace-Eigentum).
- Schnittstelle: der aktive Workspace begrenzt nur die VERFÜGBAREN Widget-Typen; das persönliche Layout bleibt User-Eigentum und wird von keinem Workspace überschrieben.
### M1 — Universal-MiniApp-Registry (2-3 Tage)
- miniapp_registry aus kommunikation-Plugin in Plugin-Layer heben (Plattform-Konzept, kommunikation behält Chat-Hosting)
- MiniAppDef/MiniAppContribution erweitern: `permission` (Pflicht-Feld, fail-closed), `settings_schema` (generisches Settings-Form), `col_span`/`row_span`, `min_size`
- `dashboard_widgets` (Manifest) → Alias von `miniapps` (Rückwärtskompatibilität, ein Contribution-Typ)
- `/api/v1/miniapps`-Endpoint: Registry-Listing **server-seitig permission-gefiltert** (nur MiniApps sichtbar, für die der User die Permission hat)
- Host-Rendering prüft Permission zusätzlich beim Render (Defense-in-Depth wie Plugin-Routen)
- Lifecycle: Plugin-Deaktivierung → unregister_plugin → Widgets verschwinden aus allen Hosts
### M2 — Dashboard-Backend (2-3 Tage)
- `dashboards`-Tabelle: pro User mehrere Dashboards, Tabs, Layout als JSONB (`[{tab, widgets: [{app_id, settings, col, row, span}]}]`), RLS fail-closed + crm_api-Policy (0084-Muster)
- CRUD-Endpoints (list/create/update/delete + set-default), Tenant-Scoping, Owner-only oder Admin
- Dual-Path: Plugin-SQL idempotent + Alembic-Konvergenz (Gate-B-Muster wie 0143)
- Default-Dashboard-Seed beim ersten Aufruf (aus Registrierungs-Order abgeleitet)
### M3 — Dashboard-Builder-Frontend (3-5 Tage)
- Edit-Modus als Modus-Schalter: aktiv → Widgets hinzufügen/entfernen, Größe ändern (col/row-span), Einstellungen; beenden → persistiertes Layout, reine Ansicht
- Drag&Drop-Grid (@dnd-kit, Referenz BlockEditor/SettingsMenuOrder): Platzierung + Umsortieren
- Widget-Palette: verfügbare MiniApps (aus `/api/v1/miniapps`, permission-gefiltert), Suche/Kategorie
- Generisches Settings-Form pro Widget aus `settings_schema` (gleiche Philosophie wie Block-Config-Panels beim Dokumente-Editor)
- Tabs: mehrere Dashboards pro User, Tab-Verwaltung im Edit-Modus
- Dashboard.tsx wird zum reinen Host (keine hardcodierten Inhalte mehr)
### M4 — System-Rückbau (2-3 Tage)
- StatCards (Firmen-/Kontakt-Zähler via contacts-Contract) → contacts-Plugin-MiniApp
- Aktiv-diese-Woche/Neu-diesen-Monat + ActivityFeed (Audit-Log) → audit/auditlog-MiniApp
- System-Metrics-Block (DB/Redis/Worker/LLM-Kosten, Admin) → System-MiniApp mit `settings:read`-Permission
- Bestehende Dashboard-Widgets (RecentContacts, TasksSummary, CalendarUpcoming) zu MiniApps migrieren (gleiches Format, dann Chat-fähig)
### M5 — Plugin-MiniApps (2-3 Tage)
- contacts, tasks, calendar, wiki, dms, mail, knowledge (Graph-RAG), automation liefern jeweils MiniApps via Manifest-Contribution
- Jede MiniApp automatisch überall verfügbar: Chat senden + Dashboard platzieren
- Permission je MiniApp passend zum Owner-Modul (z.B. `tasks:read` für TaskSummary)
### M6 — Weitere Hosts (2-3 Tage)
- AI-Agenten-Tool: Agent kann MiniApp als Ausgabe-Block in Chat-Antwort einbetten (miniapp-Block-Typ existiert, Tool-Registry erweitern)
- Windows (windowStore): MiniApp per Klick/Expand in eigenem Fenster öffnen
- Evaluiert: Wiki-Einbettung (BlockRenderer-Muster) — nur wenn Bedarf bleibt
**Abhängigkeiten:** M3 benötigt M1+M2. M4/M5 nach M3 (Host muss stehen). M6 zuletzt.
**Verwandte Phasen/Issues:** Phase L (Gleiche Contribution-Philosophie), #359 (Contract-Muster), Phase F (Agenten für M6).
---
## Phase N — Workspace-Scopes: Modul-Teilmengen pro Arbeitskontext (geplant, user-abgestimmt 2026-08-30)
**Ziel:** Workspaces werden zu voll anpassbaren Arbeitskontexten: jedes Modul kann pro Workspace auf eine Teilmenge eingeschränkt werden (z.B. nur Kontakt-Ordner X+Y, nur DMS-Ordner „Angebote", nur Mail-Postfach vertrieb@, nur Kalender „Vertrieb"). Admin-definiert für zugewiesene User-Gruppen — klar getrennt vom persönlichen Dashboard (Phase M).
**Klare Trennung (user-korrigiert):**
- Workspace = Admin-Kontext, Gruppen-Feature: WAS ist sichtbar/verfügbar (Module, Teilmengen, Widget-Typ-Angebot via `workspace_widgets`)
- Dashboard = persönlich, User-Feature: WIE ICH mein Dashboard baue (Phase M, `dashboards`-Tabelle)
- Beide Systeme berühren sich NUR an einer Schnittstelle: der aktive Workspace begrenzt das Widget-Typ-Angebot; das persönliche Layout bleibt unberührt.
**Basis (Live-Bestand, 0 Umbau):**
- `workspace_modules.config` (JSONB) — existiert, ungenutzt → Scope-Speicher pro Modul
- `X-Workspace-ID` Header + API-Client-Interceptor (pro Tab) — existiert, wird vom Backend gelesen
- `/api/v1/workspaces/context` — existiert, liefert Modul-Konfiguration aus
- Sidebar filtert bereits live (isModuleVisible — Consumer-Beweis)
- 17/17 Workspace-Tests grün, RLS auf allen 4 Tabellen
- Contract-Muster für die Scope-Registry (wie document_placeholders)
**Security-Invariante:** Scope = reine UND-Einschränkung. Sichtbarkeit = Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions. Ein Workspace kann NIE mehr sichtbar machen, nur weniger. Ohne aktiven Workspace = kein Filter (rückwärtskompatibel, wie Sidebar).
### N1 — Scope-Registry via Contract (2 Tage)
- Plugins deklarieren `workspace_scopes()` → verfügbare Scope-Dimensionen + Wertequellen (z.B. „folder_ids, Multiselect, via /contacts/folders")
- `/context` liefert `config` der Module mit aus; Scope-Definitionen-Endpoint für den Editor
### N2 — Dynamischer Scope-Editor (2-3 Tage)
- WorkspaceManager: pro Modul automatisches Filter-UI aus der Registry (Multiselects für Ordner/Postfächer/Kalender, Toggles, Standard-Ansichten)
- Speicherung in `workspace_modules.config`
### N3 — Erste vier Module integrieren (2-3 Tage)
- Contacts: Ordner-Teilmengen, Firmen/Personen-Filter, Standard-Saved-View
- DMS: Ordner-Teilmengen, Datei-Typ-Filter
- Mail: Postfach-Teilmengen
- Calendar: Kalender-Teilmengen, Standard-Ansicht
- Backend respektiert X-Workspace-ID bei Listen (additive Filter-Logik, kein Umbau bestehender Routes)
### N4 — Restliche Module (2-3 Tage)
- Tasks (Boards/Listen, „nur meine"), Kommunikation (Räume), Wiki (Kategorien), Reports/Dokumente (Vorlagen), Automation (Agenten), Tags, Suche (Provider), Navigation (Menü-Reihenfolge, Startseite pro Workspace)
- Dashboard-Schnittstelle: workspace_widgets bestimmt verfügbare Widget-TYPEN pro Workspace (Admin) — persönliches Layout bleibt Phase M
**Abhängigkeiten:** unabhängig von Phase M. N3/N4 nach N1+N2.
---
## Phase P — Notizen-App (Notion-artig, ersetzt das Wiki komplett) (geplant, user-abgestimmt 2026-08-30)
**Ziel:** Aus dem Wiki wird eine Notion-artige Notizen-/Firmen-Wissen-App: Seiten-Baum (beliebig tief, statt flacher Kategorien), Inline-Block-Editor mit Slash-Menü und Drag&Drop, Quer-Verweise zwischen Seiten, MiniApp-Einbettung, vollständige Such-Indexierung. Das alte Wiki wird KOMPLETT ersetzt (keine Legacy-App parallel).
**User-Entscheidungen (2026-08-30):**
- Keine Notion-Datenbanken zunächst — stattdessen MiniApps als einbettbare Blöcke (Phase M-Synergie)
- Später: Plugin-Erweiterbarkeit (eigene Block-Typen via Contract), evtl. Datenbank-Block als Plugin nachlieferbar
- Vollständige Such-Indexierung ist PFLICHT (Notizen/Firmen-Wissen auffindbar)
- Quer-Verweise (Seiten verlinken Seiten)
**Edit-Konzept (Notion-Recherche 2026-08-30):** Notion hat KEINEN separaten Bearbeitungsmodus — "all content is editable by default": Klick in die Seite = tippen, Auto-Save im Hintergrund, Slash-Menü für Block-Typen. Confluence macht stattdessen Draft/Publish-Workflow. Für uns: Live-Inline-Editing wie Notion als Standard; der "Lese-Modus" entsteht natürlich über Permissions (nur-Lesen = gerenderte Seite ohne Editierfunktion) + optional Page-Lock. Kein Mode-Toggle im UI nötig.
**Basis:** Wiki-Plugin (486 Z. Backend: WikiCategory/WikiArticle/WikiArticleVersion + 10 Endpoints) wird erweitert, nicht neu gebaut. Block-Muster aus Phase L (JSONB {id, type, config}), dnd-kit vorhanden, Custom-Field-Engine für spätere Properties, Entity-Links für CRM-Quer-Verweise vorhanden. Unified Search: Provider-Registry + BaseSearchProvider (Embeddings + hybrid FTS/vector) + chunking existieren — die App liefert Provider + Re-Index-Hook.
### P1 — Datenmodell & Migration (2 Tage)
- WikiArticle → WikiPage: `blocks JSONB` (statt content Text), `parent_id` (Seiten-Hierarchie statt Kategorien), `icon`, `is_favorite`, **`is_locked` (Page-Lock, user-entschieden 2026-08-30)**; Quer-Verweise als Block-Typ page_link
- Migration: Markdown-Artikel → Text-Blöcke, Kategorien → Eltern-Seiten, Versionen (WikiArticleVersion) bleiben erhalten
- Dual-Path: Plugin-SQL idempotent + Alembic-Konvergenz (Gate-B-Muster)
### P2 — Sidebar mit Seiten-Baum (2 Tage)
- Notion-artiger Baum: Seiten anlegen/umbenennen/löschen, Drag&Drop-Umsortierung (dnd-kit), + Button, Kontextmenü, Favoriten, Seitensuche
- Ersetzt die alte Kategorien-Navigation komplett
### P3 — Inline-Block-Editor (4-5 Tage) — Herzstück
- Live-Inline-Editing (Klick = tippen, kein Mode-Toggle), Auto-Save debounced in blocks JSONB
- Slash-Menü: „/" → Block-Typ-Auswahl
- Block-Typen: Text, H1-H3, To-do, Toggle (auf/zu), Quote, Callout, Code, Divider, Bild, Page-Link (Quer-Verweis mit Auto-Vervollständigung), MiniApp
- Drag&Drop-Block-Umsortierung (dnd-kit, Phase-L-Erfahrung)
- **Page-Lock (user-bestaetigt 2026-08-30):** `is_locked = true` = Seite nicht editierbar (auch mit wiki:write). Backend lehnt Block-Updates mit 409 `page_locked` ab; Frontend zeigt rein gerenderte Seite + Schloss-Badge; Lock setzen/loeschen nur Owner oder Admin (Lock-Button in der Seitentoolbar); gelockte Seiten bleiben fuer Search/Versionen/Kommentare normal indexiert
### P4 — MiniApp-Blöcke + Plugin-Erweiterbarkeit (1-2 Tage)
- „/ MiniApp"-Block-Typ: registrierte MiniApps in Seiten rendern (erster MiniApp-Konsument neben Chat — treibt Phase M mit)
- Contract `wiki_blocks()`: Plugins melden eigene Block-Typen für den Editor an (Muster document_blocks) — Basis für späteren Datenbank-Block
### P5 — Vollständige Such-Indexierung (1-2 Tage)
- Content-Extraktion aus Blöcken (Text/Überschriften/To-do/Callout) → content_tsv + Embedding-Chunks (chunking.py)
- WikiPage-Search-Provider an unified_search (hybrid FTS + vector, Re-Index bei jedem Auto-Save)
- Suchergebnis verlinkt direkt auf Seite + Sprungmarke
**Abhängigkeiten:** P4 benötigt M1 (Universal-Registry). P1-P3, P5 unabhängig startbar.
## Phase Q — Frontend-Plugin-Architektur ✓ ABGESCHLOSSEN (2026-09-13, Commits 895f85d + b666fe5, deployed, Health healthy)
> **Umgesetzt am selben Tag wie geplant.** Q3 (Generator + PluginLoader) + Q4 (MiniAppHost)
> in 895f85d; Q1 (statische Plugin-Routen entfernt) + Q2 (Settings-Routen + Renderer-Variante)
> in b666fe5. Details und Live-Beweise: PROGRESS.md Phase-Q-Section.
**Ziel:** Die letzten verbliebenen Plugin-Grenzverletzungen im Frontend beseitigen — ein Plugin soll sein Backend, Manifest UND React-Seite liefern können, ohne dass zentrale Frontend-Dateien angefasst werden müssen. Basis: externes Architektur-Audit (2026-09-13), dessen Backend-Punkte bereits gefixt sind (siehe PROGRESS.md „Externer Architektur-Audit"); die vier Frontend-Punkte sind bewusst als eigene Phase geplant, weil sie ein durchdachtes Build-Time-Discovery-Konzept erfordern (Vite kann dynamische Imports zur Laufzeit im Production-Bundle nicht zuverlässig auflösen).
### Q1 — Statische Plugin-Routen aus routes/index.tsx entfernen (Doppel-Architektur)
- Status quo: `/calendar`, `/dms`, `/mail`, `/reports`, `/tasks`, `/communication`, `/workflows`, `/import-export`, `/wiki`, `/agents`, `/automation` sind statisch im zentralen Router eingetragen UND kommen gleichzeitig über die Plugin-Manifeste via PluginRouteRenderer.
- Ziel: Nur noch PluginRouteRenderer bedient Plugin-Seiten; statische Einträge nur für echte Core-Seiten (Dashboard, Settings-Shell, Login, Trash, Approvals bis Core-Migration).
- Risiko: Manifest-Routen müssen Permissions, Layout-Einbindung (AppShell-Children vs. eigenständig) und Ladezustände 1:1 abbilden.
### Q2 — Statische Settings-Routen ausdünnen
- Status quo: settings/roles, users, groups, mail, notifications, ai, ai-proactive, automation, documents sind statisch UND via settings_pages der Manifeste vorhanden.
- Ziel: settings_pages (Manifest) wird einzige Wahrheit für Plugin-Settings-Seiten; statische Einträge nur für Core-Settings (theme, system, backup, webhooks, menu, workspaces).
### Q3 — STATIC_COMPONENT_MAP ersetzen durch Build-Time-Discovery
- Status quo: PluginLoader.tsx hält eine zentrale Komponenten-Liste (~26 Einträge). Ein neues Plugin muss die Leo-Frontend-Codebasis anfassen.
- Ziel: Build-Skript scannt app/plugins/builtins/*/plugin.py auf FrontendPageRoute/SettingsPage/Component-Pfade und generiert automatisch eine Import-Map (generated, committet), die Vite statisch chunken kann. Keine manuelle Zentral-Liste mehr.
### Q4 — widgetRegistry in MiniAppHost durch generierte Map ersetzen
- Status quo: 11 Widget-Komponenten sind zentral hardcodiert (RecentContactsWidget, TasksSummaryWidget, ...).
- Ziel: Q3-Mechanismus deckt auch dashboard_widgets/miniapps component-Pfade ab; MiniAppHost nutzt dieselbe generierte Import-Map.
**Reihenfolge (wie umgesetzt):** Q3 → Q1/Q2 → Q4 (Q4 fiel mit Q3 mit, da MiniAppHost dieselbe generierte Map nutzt). Jeder Schritt mit Vitest-Sicherung der betroffenen Seiten und Production-Build-Verifikation (Chunk-Existenz prüfen).
## Externaudit Astra 2026-09-17 (41 Findings) — Sanierung PHASE S (bestätigt, NÄCHSTE PHASE, vor/neben R)
**Auditergebnis:** 2 P0 (KI führt nicht freigegebene Tools aus; Mandantenverwaltung kann globale Anmeldeidentitäten ändern), 29 P1, 10 P2. Geprüft am vollständigen Stand ee5545d (ZIP). Interne Verifikation am 2026-09-17: 10 Findings stichprobenartig am Code nachgelesen (F01, F02, F05, F08, F10, F12, F17, F24, F37, F41) — **alle 10 korrekt**. Übrige Findings: detailliert mit Zeilennummern belegt, Detail-Verifikation erfolgt jeweils bei Umsetzung. Volltext des Audits: [docs/audits/astra-audit-2026-09-17.md](docs/audits/astra-audit-2026-09-17.md); Kernpunkte je Finding in den Wellen-Issues.
**Strukturdiagnose (Astra):** Mehrere Stellen verwalten denselben Zustand (Plugin-Aktivität, Schema); Contracts garantieren zu wenig Verhalten; API- und Worker-Ausführung nicht gleichwertig; Berechtigungsprüfungen liegen zu weit vom Seiteneffekt entfernt; Statusanzeigen teils von tatsächlicher Funktion entkoppelt. — Bestätigt und deckt sich mit den realen Incidents (#389 Plugin down 4 Wochen, #380 158 Events failed).
**Sanierungswellen (Reihenfolge nach Risiko, an Astra-Empfehlung angelehnt):**
### Welle S1 — Sicherheitsgrenzen (P0 + Auth/Permission-Kette) — ZUERST
- **F01 (P0)** agent_loop._execute_tool: Tool-Ausführung ohne Allowlist- und Permission-Check — unmittelbar vor Handleraufruf prüfen: Tool in der dem LLM angebotenen Liste, required_permission gegen aktuelle User-Rechte, Verbote, Mandant, Plugin aktiv, ggf. Approval. Abnahme: nicht angebotenes Tool → Ablehnung, Handler bleibt null.
- **F02 (P0)** users.py update_user: globale User.email durch Mandanten-Admin (users:write) änderbar → globale Identitätsänderungen (email, is_system_admin global, Passwort) von Mandantenverwaltung trennen; nur Selbstservice oder echte globale Admin. Abnahme: Tenant-Admin kann globale E-Mail/Aktivstatus fremder Mandanten-Mitglieder nicht ändern.
- **F10** require_permission: Token-Scopes ersetzen User-Rechte (early-return) → effektive Rechte = Schnittmenge(User, Token-Scopes, Delegation), Verbote vorrangig. Abnahme: Token mail:write + User ohne mail:write → 403.
- **F05** require_active_plugin läuft vor Auth/ohne Mandantenkontext → Plugin-Gate an authentifizierten Kontext binden, fehlender Kontext = ablehnen. Abnahme: mandantendeaktiviertes Plugin → 403 auch bei gültiger Session.
- **F03** Session-Widerruf: Deaktivierung/Austritt/Löschen/Passwortwechsel müssen in Redis- UND DB-Fallback-Sessionpfaden wirken; Widerruf dauerhaft speichern. Abnahme: Widerruf wirkt auch bei Redis-Ausfall.
- **F11** Approval-Resolution: approver_id/Ablauf/Gruppe/Atomarität prüfen, Entscheider getrennt speichern, Approval an Aktion+Argumente+Revision binden.
- **F15** Workflow-HTTP: aufgelöste IPv4/6-Ziele gegen Privatnetz prüfen, Verbindung an geprüfte Auflösung binden, Redirects prüfen.
- **F20** prestart überschreibt gezielte Rechte-Entzüge (0100) mit pauschalem GRANT DELETE → Tabellenschutz nur migrieren; keine Rechteanhebung beim Start.
- **F21** test_migrations.sh: MIGRATION_DATABASE_URL überschreiben + Zielidentität vor DDL prüfen (sonst Gefahr für echte DB).
- **F23** Tenant-Backup-API triggert datenbankweiten Restore → Gesamtrestore als globale Betriebsoperation mit separater Berechtigung.
- **F30** Admin-Standardpasswort bei unkonfiguriertem Start → verpflichtendes Secret oder sicherer Einmal-Generierung.
### Welle S2 — Ausführung verbinden (Worker, Jobs, Contracts, Migrationen)
- **F06** Worker registriert keine der 44 Plugin-Event-Handler (BasePlugin.register_event_handlers ist leer) → API und Worker dieselbe idempotente Registrierung; Abnahme über echten Outbox-Durchgriff (Kontakt anlegen → Worker → Suchindex).
- **F07** Hintergrundjobs verlieren Mandantenkontext/Transaktionen → Mandant+Auftraggeber im Job-Payload Pflicht; Kontext vor erstem SQL; fachliche Änderung+Audit+Outbox gemeinsam committen.
- **F08** External-Agent-API: require_permission an Cookie-Auth gebunden (Bearer nie erreicht) + get_db() ist kein Contextmanager (TypeError) → gemeinsamer geprüfter Auth-Kontext für Cookie+Token; Session-Factory statt get_db.
- **F09** CRM-/MCP-Tools senden nicht anerkannte interne Header → Delegationsmechanismus (delegation_token.py) einbinden; UI und Agent gleiche Rechte-Antwort.
- **F12** Workflow approve/reject: approval["id"] auf ORM-Objekt (TypeError) + falsche resolve-Signatur → an zentralen Vertrag anpassen, wartende Freigabe auflösen statt Selbst-Genehmigung.
- **F13** Workflow-Engine: acquire_lock ohne Aufrufer, Idempotenz unvollständig, Resume ungesperrt → Engine als verbindlichen Zustandsübergang; Abnahme: Worker-Neustart + parallele Resume → keine Doppel-Mails.
- **F14** enforce_data_policy lässt Strings ungefiltert + läuft nur vor der Schleife mit db=None → strukturierte Filterung vor Serialisierung; JEDE LLM-Anfrage (inkl. Tool-Antworten) durch Policy; nicht ladbare Policy = Versand-Stop.
- **F16** Plugin-Lifecycle: prestart reaktiviert absichtlich deaktivierte Plugins; Aktivierungsfehler lassen DB-Zustand aktiv → gewünschten Zustand von Installation/Mandantenfreigabe/Laufzeitgesundheit trennen; Abnahme: Deaktivierung überlebt Neustart.
- **F17** 6 Produktionsstellen rufen ContractRegistry.get() auf (existiert nicht; nur get_contract) → Aufrufer fixen; Abnahme über reale Einstiegspunkte (Miniapp-Tools, proaktive Hinweise, Report-Jobs).
- **F18** Drei Schema-Verfahren (Alembic/Plugin-SQL/sync_plugin_schema) mit Sync-Verlust bei Unique/Partial-Indizes → einen Migrationsbesitzer pro Objekt; Startup-Sync als lesender Driftbericht.
- **F19** alembic/env.py lädt nur app.models (46/129 Tabellen; Sortierung scheitert) → deterministische vollständige Modelldiscovery.
- **F31** Provider-Registry vs. Reindex-Listen divergieren → Plugin-Beiträge als gemeinsame Quelle; Abnahme: neuer Provider wird vollständig indiziert.
- **F37** SMTP-Env-Namen (SMTP_USER vs smtp_username u.a.) → Compose/Config/Doku angleichen; Abnahme: Reset-/Alarm-Mail authentifiziert.
- **F40** Plugin-Migrationen nur Dateiname-Tracking → Hashes speichern und prüfen; Sollzustand vorhandener Tabellen (Spalten/FKs/Policies) vergleichen.
- **F41** Agenten-Stundenlimit zählt ab jetzt() statt letzte Stunde → timedelta(hours=1); Kontingent atomar reservieren.
### Welle S3 — Fachliche Integrität (Daten- und UI-Korrektheit)
- **F25** CSV-Import: Rollback vernichtet frühere Zeilen, Zähler behalten Erfolge, RLS-Kontext weg → Savepoints pro Zeile, Original-Zeilennummern; Zähler = Persistenz.
- **F26** DMS-Dedup vermischft Identität (fremder Datensatz statt eigener Upload) → Content-Storage vs. Fachobjekt trennen; jeder Upload eigene Identität/Rechte.
- **F27** Kalender: SQL-Filter wirft Serien weg bevor Wiederholungen berechnet werden; end_at-Dauer; Mehrtagesüberlappung → Serie nach Laufzeit selektieren, Wiederholungen im Fenster erzeugen.
- **F28** Import/Export ohne Fachrechte (import_export:write ≠ contacts:write; Export ohne Feldrechte) → Fachrechte UND Importrecht; Feldfilter vor Dateierzeugung.
- **F32** Suche: entity_types=[] = alle (soll 0), Filter nach Top-N, Offset unwirksam, before_search zu spät → None/[] unterscheiden; Filter vor Limit; Hook vor Parametern.
- **F33** Workspace-Wechsel invalidiert fachliche Querykeys nicht → Workspace in Query-Identität oder kontrolliert verwerfen.
- **F34** Mandantenwechsel: alte Daten bis Refetch sichtbar → kontrollierter Kontextwechsel (Abbrechen, Caches leeren, Header synchronisieren).
- **F38** pluginStore-Fehler → Dauerspinner (loaded bleibt false) → Fehler/Leer/Erfolg getrennt rendern, Retry anbieten.
- **F39** Office-Edit-Session verweist auf /preview (PDF-only) + Callback-Route existiert nicht → funktionsfähigen Ablauf anbinden oder Feature als nicht-betriebsbereit kennzeichnen.
### Welle S4 — Betriebsfreigabe (inkl. korrigierter Phase R)
- **F22** Backup im Container nicht betriebsfähig (pg_dump fehlt, Pfade nicht persistiert, Kontext-/User-Bugs) → dokumentierter Ablauf mit Programmen, Rechten, persistiertem Ziel.
- **F24** /health/ready liefert 200 bei not_ready; Worker-Check meldet up ohne Worker → korrekte HTTP-Codes (503), Heartbeat-Alter statt Queue-Länge.
- **F29** CI ohne Lockfiles/Testdienste/tatsächliches Artefakt → reproduzierbare Pipeline gegen eigenes Image.
- **F36** Komponenten-Map-Generator nicht verbindlich im Build → Check an npm-Build/Dockerfile/CI hängen.
- **F04** Suche: autocomplete/similar ohne Objekt-/Feldrechte; Snippet/Titel unfiltert zur LLM → ein Schutzpfad für ALLE Suchvarianten vor Snippet- und LLM-Übergabe.
- **F35** PWA abgeschaltet, aber Offline-Banner verspricht Schreibspeicherung → PWA wiederherstellen ODER Banner an Realität anpassen.
- Phase-R-Korrektur (siehe unten, bereits eingearbeitet).
**Abnahmeszenarien quer über alle Wellen (Astra-Vorschlag, verbindlich):**
1. Kontaktanlage → Audit/Outbox → separater Worker → Suchindex → erlaubte KI-Abfrage (F06, F07, F17, F04)
2. Mailentwurf → Freigabe → einmaliger Versand → nachvollziehbares Ergebnis (F11, F12, F13)
**Reihenfolge-Logik:** S1 zuerst (jede nicht autorisierte Aktion verboten), S2 parallel startbar nach S1-P0s, S3/S4 danach. Nach S1+S2 verifizierter Welle: Aufwand neu schätzen (Astra-Hinweis: die 9-14 Tage aus Phase R sind keine Schätzung für 41 Findings).
## Phase R — Betriebssicherheit & 95%-Produktionsreife (geplant, user-abgestimmt 2026-09-16 — läuft in S4 auf; korrigiert 2026-09-17 nach Astra-Kritik)
**Ziel:** Von „Produktion läuft stabil" zu „Produktion verlässlich": stille Ausfälle werden automatisch erkannt und alarmiert (Minuten statt Wochen), die Test-Suite wird zum vertrauenswürdigen Regressionsschutz, Schema-Drift wird automatisch erkannt, Kernprozesse werden nach jedem Deploy regressionsgetestet, Backups sind nachweislich wiederherstellbar.
**Warum diese Phase (Evidenz aus realen Incidents):**
- KI-Chat war 4 Wochen still down — ai_assistant migration_failed seit 2026-08-21, entdeckt am 2026-09-16 nur durch Zufall (#389)
- External-API war durch CSRF-Middleware für externe Systeme unbrauchbar (fix b91ee5b)
- Outbox: 158 failed Events wochenlang unbemerkt (#380)
- Suite-Isolation und alembic-check-Blockade verhindern verlässliche Regressionsschutz-Gates
**95%-Definition (korrigiert 2026-09-17 nach Astra-Kritik):** Die fünf Kriterien sind kein mathematischer Reifegrad, sondern **konkrete Freigabekriterien**. Dokumentiert wird: erfüllte Kriterien, verbleibende Risiken und bekannte Grenzen (Battle-Testing im Echtbetrieb). „95 %" = Zustand, in dem jeder Ausfall laut statt still wird; die restlichen ~5 % sind Echtbetriebs-Edge-Cases, die nur echte Nutzung findet.
**Astra-Kritik an Phase R (8 Punkte, 2026-09-17) — eingearbeitet:**
1. ARQ-Heartbeat überwacht sich nicht selbst → zusätzlich externe Überwachung außerhalb der ARQ/Redis-Ausfallkette (z.B. Cron auf Host oder externer Uptime-Check gegen /health/ready).
2. „Installiert aber inaktiv"-Alarm trifft absichtliche Deaktivierung → **Sollzustand** (DB desired state) mit tatsächlicher Betriebsbereitschaft vergleichen; nur Abweichung alarmiert.
3. Leere Queue ≠ laufender Worker → Worker-Heartbeat-ALTER und Verarbeitungsnachweis messen, nicht Queue-Länge.
4. Komplette Suite grün reicht nicht (Mocks/Admin-Tests können Rechtefehler verdecken) → zusätzlich echte API-/Worker-Prozesse mit tatsächlichen Laufzeitrollen prüfen.
5. Ein FK-Fix + Migrationshash genügt nicht → vollständige Modelldiscovery (F19) und eindeutige Schema-Verantwortung (F18) sind Voraussetzung; R3 hängt an S2.
6. E2E-Normalfälle prüfen Rechteentzug/Neustart nicht → Mehrmandanten-, Rollen-, Fehler- und Wiederaufnahme-Szenarien ergänzen.
7. Monatlicher Restorejob beweist keine sichere Zielwahl → isoliertes Ziel und tatsächliche DB-+Datei-Wiederherstellung nach Containerersatz nachweisen.
8. „95 % Produktionsreife" ist keine messbare Zahl → konkrete Freigabekriterien + verbleibende Risiken dokumentieren (siehe oben).
**Aufwandskorrektur (Astra):** Die 9-14 Tage gelten NICHT für die Behebung aller 41 Audit-Findings (Phase S). Neue Schätzung nach Abschluss von S1+S2.
### R1 — Stille-Ausfälle-Wächter + Alerting (2-3 Tage) — PRIORITY 1, größter Risikoreduktor
- ARQ-Heartbeat-Job (alle 5 Min) prüft: (a) /api/v1/plugins — installiert aber nicht active → ALARM (exakt der #389-Fall), (b) /health/ready — DB/Redis/Storage/Worker, (c) Outbox-DLQ — failed > 0 (#380-Klasse), (d) Worker-Queue-Länge
- Alarm-Kanal: E-Mail über bestehende Mail-Infra (SMTP) an Admins; Alarm-Zustand zusätzlich als rote Badge im Admin-UI (System-Dashboard)
- Abnahme live: Plugin absichtlich deaktivieren → Alarm muss nachweislich auslösen (Chaos-Test)
- Bestand, auf dem aufgebaut wird (kein Neubau): /health/ready (docs/monitoring.md), ARQ-Worker (app/core/worker.py), Mail-Plugin (SMTP), System-Dashboard-Routen
### R2 — Test-Suite verlässlich machen (2-3 Tage)
- Suite-Isolation fixen: Combo-Runs quaken mit „relation users does not exist" (Solo grün) — conftest.py-DB-Setup deterministisch machen
- Vitest-Worker-OOM fixen (Worker-/Fork-Konfiguration)
- Abnahme: `python -m pytest` kompletter Lauf grün + `npx vitest run` kompletter Lauf grün — erst DANACH gilt die Suite als verbindliches DoD-Gate
### R3 — Schema-Integrität automatisieren (1-2 Tage)
- entity_attachments-FK fixen → `alembic check` läuft als Schema-Drift-Gate
- Migration-Runner: Hash-Check ergänzen — geänderte getrackte Migration = Alarm statt stiller Skip (verhindert die #389-Bugklasse systemisch)
- scripts/schema_drift_check.py + scripts/check_migration_hashes.py in scripts/ci_pipeline.sh integrieren
### R4 — E2E-Kernprozess-Regression (2-3 Tage)
- Playwright-Suite über Kern-Flows: Login, Kontakte-CRUD, Mail senden/lesen, DMS upload/download, Kalender-Termin, KI-Chat-Antwort, Workflow-Ausführung, Gäste einladen
- Automatischer Run nach jedem Full-Deploy (fast-deploy.sh-Erweiterung)
- Bestand: Playwright-Setup existiert (frontend/e2e/, Login-E2E bewiesen funktioniert)
### R5 — Backup-/Restore-Nachweis (1-2 Tage)
- scripts/restore_drill.sh monatlich per ARQ-Job/Cron ausführen + Ergebnis alarmieren
- RTO/RPO messen und dokumentieren (scripts/backup.py, scripts/restore.py, restore_test.sh existieren)
### R6 — Ops-Runbook & Alarm-Kette final (1 Tag)
- Eskalationskette: Wer wird wie alarmiert (E-Mail/Handy), wer reagiert
- docs/incident-response-runbook.md um die realen Ausfallklassen ergänzen (Plugin-inactive, DLQ-Vollauf, Migration-Crash, CSRF/Auth-Layer, Worker-Stillstand) — jede mit Schritt-für-Schritt-Fix aus dem echten Incident
**Aufwand gesamt: ~9-14 Arbeitstage.** R1 zuerst (unabhängig startbar), R2 parallel, R3 nach R2, R4 nach R1, R5/R6 unabhängig. Kann mit Phase O/P verzahnt werden — aber R1-R3 vor neuen Features.
**Definition of Done Phase R:** Alle 5 Abnahmekriterien live gemessen und grün + ein dokumentierter Chaos-Test (absichtlicher Ausfall → Alarm in < 30 Min). Pro Task ein Forgejo-Issue mit Milestone „Phase R — Betriebssicherung" (AGENTS.md §9).
## UI-Backlog — Backend-Module ohne UI (laufend seit 2026-09-08, Source of Truth: PROGRESS.md-Tabelle)
**Kontext:** Frontend-Backend-Gegenüberstellung (2026-09-01) ergab 16 Backend-Module ohne UI (~64 Ops). User-Entscheidung: Module einzeln mit UI ausstatten, priorisiert nach Business-Nutzen. Jedes Modul folgt derselben Verifikationskette: Vitest → tsc → Production-Build → Deploy → Live-API-Check → Forgejo-Issue → PROGRESS.md-Update.
**Status 2026-09-16: 16/16 erledigt — UI-BACKLOG KOMPLETT.**
- Erledigt: 1 Approvals, 2 Delegations, 3 API-Tokens, 4 Tenants, 5 Marketplace, 6 Permission-Templates, 7 Skills, 8 Agent-Memory, 9 Outbox, 10 Policies, 11 Graph-RAG, 12 Companies, 13 Public-Share, 14 Guests, 15 External-Agent, 16 Ownership-Transfer (Commits + Issues #369, #372-#388 in PROGRESS.md-Tabelle)
- Alle 16 Backend-Module haben jetzt UI. Bei neuen Backend-Modulen ohne UI: analog verfahren.
**Architektur-Regel (seit Phase Q):** Plugin-Module (wie Marketplace, Skills, Agent-Memory) werden AUSSCHLIESSLICH via Plugin-Manifest registriert (page_routes + menu_items + Komponenten-Map-Generator) — routes/index.tsx und Sidebar.tsx bleiben unangetastet. Core-Module (wie Delegations, API-Tokens, Tenants, Permission-Templates) laufen als statische Core-Routen + Settings-Nav.
**Muster:** Jedes Modul = api/<modul>.ts (TanStack-Hooks) + pages/<Modul>.tsx (Karten/Dialoge/Permission-Gating) + i18n de/en + Vitest-Tests + Registrierung. Referenz-Implementierungen: Approvals (Core) und Marketplace (Plugin/Phase Q).
+565 -5
View File
@@ -1,7 +1,545 @@
# LeoPlatform — Fortschritts-Tracking
> **Letztes Update:** 2026-08-21
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
## Externer Architektur-Audit — 13 Backend-Fixes verifiziert & umgesetzt (2026-09-13, Commit 4a25ac1, [#370](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/370)) ✅
**Ausgangslage:** Externes KI-Audit (leocrm-full.zip, Stand 86cea5d) meldete 17 Findings. Verifikation gegen den echten Code: **alle 17 BESTÄTIGT** (inkl. exakt der 12 gemeldeten fehlenden Permission-Keys — per AST-Scan 1:1 reproduziert). 13 Backend-/Lifecycle-Punkte sofort gefixt; die 4 Frontend-Plugin-Architektur-Punkte sind als **Phase Q** in die Roadmap eingeplant (Begründung dort).
**Fixes (alle mit Live-Verifikation, `tests/test_audit_architecture_fixes.py` 17/17):**
| # | Finding | Fix | Verifikation |
|---|---|---|---|
| P1 | `GET /workspaces` lieferte `modules: []` → Workspace-Editor überschrieb Konfig | `list_workspaces()` lädt Module+User-Counts gebündelt (2 Queries statt N+1) | test_f1: modules mit is_visible-Flags 1:1 |
| P1 | `/plugins/active-manifests` ignorierte Tenant-Deaktivierung (UI zeigte 403-Menüs) | Registry/Service/Route tragen `tenant_id` durch, filtern `tenant_plugin_activation.is_active=false` | test_f2: Plugin im Manifest ohne Filter, gefiltert mit Tenant-Eintrag |
| P1 | `uninstall()` umging PluginService-Cleanup (stale Permissions/Entity-Models) | `uninstall_plugin()` ruft `deactivate_plugin()` VOR `registry.uninstall()` | test_f3: Quellcode-Verifikation + Lifecycle-Verhalten |
| P1 | Contract-Lazy-Loading kannte DB-Aktivstatus nicht (Restart-Edge-Case) | Startup markiert `active=False`-Plugins (`mark_db_inactive`), Guard in `get_contract()`, Re-Activate cleart | test_f4: fail-closed + reopen |
| P1/P2 | `register_field_definitions()` ohne Unregister, nicht im Runtime-Lifecycle | `unregister_field_definitions()` + Aktivierung/Deaktivierung registrieren/entfernen Field-Defs | test_f5: voller Lifecycle über PluginService |
| P1/P2 | 39 Contact-Felddefinitionen lagen im Core (`CORE_FIELD_DEFINITIONS`) | Verschoben ins ContactsPlugin-Manifest (`field_definitions=`); Core behält nur users-Felder; `sensitive_data.py` nutzt jetzt die Registry-Gesamtsicht | test_f10: Core ohne contacts-Module, Plugin mit 39 Defs, Sensitivities erhalten |
| P1 | 12 verwendete Permission-Keys nicht registriert | 9 in CORE_PERMISSIONS (automation:admin, bank-accounts:*, delegations:*, policies:*, templates:*), 2 im permissions-Plugin (permissions:read/admin), 1 im forgejo-Reporter (system:read) | AST-Re-Scan: 146 Keys, **0 fehlend**; test_f9 |
| P2 | `contact_folder` als Core-Entity | Ins ContactsPlugin verschoben; `register_entity_model(..., plugin_name=...)` befüllt jetzt ENTITY_PLUGIN_OWNERS (war tot) | test_f15: `get_entity_read_permission('contact_folder') == 'contacts:read'` via Owner |
| P2 | Entity-Permission-Fallback `contacts:read` | Fail-closed Sentinel `__unmapped__:read` (nicht grantbar → 403); unbekannte Entities werden vorher via 422 abgelehnt | test_f15 |
| P2 | Forgejo-Error-Reporter `is_core=True` trotz „test/staging only" | `is_core=False` (deaktivierbar) | test_f11 |
| P1/P2 | Core-FK `entity_attachments.files` vs. „DMS = Plugin" Widerspruch | **ADR-020:** DMS als Plattform-Core-Plugin deklariert (`is_core=True`) — FK-Richtung ist damit legitim, Registry erzwingt Nicht-Deaktivierbarkeit | test_f12 |
| P2 | Core-Worker importierte Contact für Trash-Cleanup | `cleanup_contacts_trash` ins Contacts-Plugin ausgelagert (jobs.py, `get_job_modules()`-Discovery wie knowledge), Cron 04:15 | test_f13: kein `app.models.contact`-Import im Worker + Job registriert |
| P1/P2 | DSGVO-Export doppelt (Legacy-Route kannte Contacts direkt) | `GET /dsgvo-export` delegiert an `_dsar_collect_user_data` (autoritativer DSAR-Collector, Plugin-Contracts) | test_f14: Delegation, kein Contact-Import |
| P2 | False-green Tests (`or True`, irreführender Name, veraltete >100-Routes-Assertion) | 3 Assertions durch echte Prüfungen ersetzt; Test umbenannt (`_simulated`); Route-Count-Assertion auf Plugin-Architektur umgestellt (vorher schon auf HEAD rot — pre-existing) | Suite grün |
**Nicht als Code-Fix, sondern als Phase Q geplant** (Roadmap „Phase Q“, user-pending): statische Plugin-Routen + Settings-Routen im zentralen Router (Doppel-Architektur), STATIC_COMPONENT_MAP, widgetRegistry — benötigt Build-Time-Discovery-Konzept.
**Beweis Suite-Isolation (nicht durch Fixes verursacht):** test_m4_system_miniapps solo 7/7 grün (mit UND ohne Fixes), test_n4_scope_declarations solo 18/18 grün — Combo-Failures sind das bekannte „relation users does not exist“-Problem.
**Regressionen:** test_contacts_lifecycle 8/8, test_custom_field_definitions, test_contacts_entity_registry 3/3, test_contacts_model_ownership, test_workspace_scopes 18/18, test_rbac_comprehensive, test_plugin_lifecycle_service, test_einvoice_generator — alles grün. Cross-Plugin-Checker: 497 Dateien, 0 verbotene Imports. compileall sauber. Ruff auf 7-Error-Baseline.
**Deployiert & live bewiesen (Commits 4a25ac1 + 1b80090, 2x Full Deploy SUCCESS, Health healthy, Worker up):**
- GET /workspaces: Standard-Workspace liefert modules=24 (vorher []) — Editor-Overwrite-Bug behoben
- GET /plugins/active-manifests: 26 Manifeste
- GET /roles/permissions: 141 Keys, 12/12 neue Keys sichtbar (erste Deploy-Runde nur 11/12 — system:read fehlte, weil der ursprüngliche Patch die permissions-Liste versehentlich in PluginRouteDef-kwargs platziert hatte; in 1b80090 korrekt auf Manifest-Ebene, Test f9 prüft jetzt echte Manifeste statt manueller Registrierung — Live-Check ist DoD-Pflicht)
- field_definitions: contacts=39 (Plugin), users=4 (Core) — Ownership-Verschiebung live bestätigt
## Phase Q — Frontend-Plugin-Architektur vollendet (2026-09-13) ✅ — PHASE Q KOMPLETT
**Ausgangslage:** Die 4 Frontend-Findings des externen Audits (Doppel-Architektur
Routen/Settings, STATIC_COMPONENT_MAP, widgetRegistry) wurden als Phase Q geplant
und jetzt vollständig umgesetzt. Ein Plugin meldet ab sofort Backend, Manifest
UND React-Komponenten über sein Manifest — keine zentrale Frontend-Datei muss
mehr angefasst werden.
**Q3+Q4 (Commit 895f85d) — Build-Time-Discovery statt Zentral-Listen:**
- `scripts/generate_component_map.py`: scannt alle builtin-Manifeste +
system_miniapps.py, generiert `frontend/src/generated/pluginComponents.generated.ts`
(37 Komponenten). Fail-Hard bei Ghost-Komponenten (bewiesen: exit 1), erkennt
default- vs. named-exports, deterministisch, `--check`-Modus für CI.
- PluginLoader.tsx: STATIC_COMPONENT_MAP (26 Einträge) GELÖSCHT → generierte Map.
- MiniAppHost.tsx: widgetRegistry (11 Einträge) GELÖSCHT → generierte Map (löst Q4 mit).
- Contacts-Manifest: DedupMergePage-Pfad-Alias auf echte Datei korrigiert.
**Q1+Q2 (Commit b666fe5) — Manifeste = einzige Routen-Quelle:**
- routes/index.tsx: 14 statische AppShell-Plugin-Routen + 9 statische
Settings-Routen + 20 tote Lazy-Imports entfernt. Nur noch Core-Routen + die
StartLayout-Hub-Bäume (/agents, /automation, /logs, /help — verschachtelte
Sub-Navigation) bleiben statisch (bewusste Entscheidung: Layout-Routen mit
Sub-Navigation werden von flachen Manifest-Einträgen nicht abgebildet).
- PluginRouteRenderer: neue `variant`-Prop — 'pages' (absolute Pfade, AppShell-
Catch-all) vs. 'settings' (bare Sub-Segmente, Descendant-Matching im
/settings-Subtree). Getrennte Entry-Listen verhindern Pfad-Kollisionen.
- Manifeste ergänzt: Calendar +/calendar/kanban, Tags +/tags (+ Menü-Item),
Automation: /workflows auf workflows:read (Parität zur ersetzten statischen
Route), tote flache /agents-+/automation-Einträge entfernt.
**Verifikation (jeder Schritt live gemessen):**
- tsc --noEmit exit 0 (nach Q3 und nach Q1/Q2) · production build exit 0 (2×)
- Ghost-Fail-Hard: Generator exit 1 mit Fehlermeldung bei eingepflanzter Ghost-Komponente
- Vitest: Dashboard + MiniAppWindow 17/17, pluginStore 18/18, kombiniert 35/35
- Backend-Regressionen: Route-Order, M5-MiniApps, N4-Scope, N3-Filtering 49/49
- compileall sauber · Cross-Plugin-Checker 497/0 · ruff clean
- Full Deploy SUCCESS · Health healthy · Live-Manifest-Checks: /calendar/kanban,
/tags, workflows:read, keine toten Einträge — alle OK · SPA-Routen 200
**Nächster Schritt (Roadmap):** Re-Audit durch den externen Prüfer — alle 17
Audit-Findings sind behoben (13 Backend + 4 Frontend). Danach Phase O UI-Overhaul,
Phase P Notizen-App oder UI-Backlog-Module 2-16.
## Weitermachen (2026-09-15, Übergabe — für das nächste Modell/jede KI)
**Produktion läuft stabil** (HEAD b91ee5b = origin/main, 0 ungepushte Commits, Health healthy, Alembic 0144, RLS 113 Tabellen, Worker up). Alle Forgejo-Issues bis #389 geschlossen. Outbox sauber: 158 Events published (Webhook-Fix #380).
**2026-09-13 bis 16 abgeschlossen:** (1) Externer Architektur-Audit verifiziert — alle 17 Findings bestätigt, 13 Backend-Fixes (Commit 4a25ac1, #370). (2) PHASE Q KOMPLETT — Frontend-Plugin-Architektur: generierte Komponenten-Map (scripts/generate_component_map.py, jetzt 43 Eintraege, Fail-Hard bei Ghosts) ersetzt STATIC_COMPONENT_MAP + widgetRegistry; Plugin-Routen/Settings nur noch aus Manifesten via PluginRouteRenderer (variant pages/settings). (3) Drei Produktions-Bugfixes 2026-09-14 (siehe Bugfix-Tabelle unten): Webhook-JSONB-Containment (#380), Zustands-Selector-Spinner-Hang (#381), Consumer-Registry-qualname. (4) ZWEI weitere Produktions-Bugfixes 2026-09-16 (Bugfix-Tabelle): ai_assistant-Reaktivierung (#389 — KI-Chat war seit 0137 down) + CSRF-Bearer-Skip (External-API fuer Integrationen). (5) **UI-BACKLOG 16/16 KOMPLETT** — alle 16 Backend-Module haben jetzt UI (Commits + Issues #369, #372-#388, siehe Tabelle): Module 11-13 an einem Tag (2026-09-15), Module 14-16 am 2026-09-16 (Guests #386, External-Agent #387 inkl. 2 Backend-Fixes, Ownership-Transfer #388).
**OFFENE THREADS (alles Weitere hängt hier, nichts geht verloren):**
1. **Re-Audit ausstehend:** Externer Prüfer prueft leocrm-reaudit.zip (Stand b58c96f, liegt beim User). Bei neuen Findings: erst die fixen. Hinweis: ZIP enthaelt NICHT die UI-Module 2-16 — bei Bedarf frischen ZIP erstellen (git archive HEAD).
2. **Traefik no-cache-Header fuer index.html** (User-Angebot offen, prevents stale JS-Chunks nach Deploys). HINWEIS: Der "Dashboard loads forever"-Incident wurde 2026-09-14 aufgeklaert — es war der Zustands-Selector-Bug (#381), kein Caching-Problem. Der no-cache-Header bleibt trotzdem sinnvoll gegen stale Chunks nach Deploys.
3. **Server-Entlastung** (User-Thema offen): Cron gegen alte Browser-Prozesse (Incident: 3 Zombie-Chromium, 500+ h CPU) und/oder VPS-Upgrade-Diskussion (22 Container auf 7,6 GB).
4. **Phase O UI-Overhaul:** offen 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog.
5. **Phase P Notizen-App** (P1-P5, user-abgestimmt, Roadmap-Details stehen).
6. **Vorbestands-Findings (nicht blockierend):** entity_attachments-FK blockiert alembic check; Suite-Isolation (Combo-Runs "relation users does not exist", Solo gruen); Vitest-Worker-OOM.
7. **Marketplace ist leer:** Keine Listings in der DB (API 200, listings=0). Demo-Listings koennen via Admin-API (MarketplaceListingCreate, marketplace:admin) angelegt werden — User fragen.
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
- **Phase S** — Astra-Sanierung (S1-S4, bestätigt 2026-09-17). **NÄCHSTE PHASE.** Externaudit Astra: 41 Findings (2 P0, 29 P1, 10 P2), 10 stichprobenartig intern verifiziert — alle korrekt. Volltext: [docs/audits/astra-audit-2026-09-17.md](docs/audits/astra-audit-2026-09-17.md). Wellen: S1 Sicherheitsgrenzen [#396](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/396) (**KOMPLETT 11/11, deployed 2026-09-18**: F01 ✓ f2a7206 KI-Tool-Guard, F02 ✓ 824686c globale Identität, F03 ✓ 4743265 Session-Widerruf beide Stores, F05 ✓ a17772a Plugin-Gate nach Auth, F10 ✓ b8a5560 Token-Scopes Obergrenze, F11 ✓ 015b7e3 Approval-Bindung (resolved_by, 403/409/410), F15 ✓ a802159 SSRF DNS-Auflösung, F20 ✓ b2f7549 DELETE-Grants Migration 0145 (Live-SQL-Beweis: crm_api DELETE nur auf users/user_tenants/sessions/plugins/notification_types; audit_log/api_tokens/tenants entzogen), F21 ✓ 17f990c Migrationstest-Ziel-Beweis, F23 ✓ 25b4d61 Restore System-Admin, F30 ✓ 632554b kein Default-Passwort; S1-Guards-Suite 18/18; Logout-Smoke 200), S2 Ausführung verbinden [#397](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/397) (**12/16**: F12 ✓ 5169b12 Workflow-approve/reject an zentralen Vertrag; F17 ✓ 8c5682f 6× Registry.get()→get_contract(); F37 ✓ 5d6fe6b SMTP-Env-Namen; F41 ✓ 5d6fe6b Agenten-Stundenlimit; F06 ✓ 62d107d Worker registriert Plugin-Event-Handler; F08 ✓ 001e4b4+8e744c9 External-API Bearer + Migration 0147 RLS-Henne-Ei; F14 ✓ fdc4e36 KI-Datenrichtlinie komplett; F09 ✓ 4217267 CRM-/MCP-Tools HMAC-Delegation + Worker-URL; F19 ✓ 49a9493 Alembic vollständige Model-Discovery (129 Tabellen, Sortierung OK — Voraussetzung für R3); F40 ✓ fd4a1ec Plugin-Migrations-Hashes + DRIFT-Warnung (Migration 0148, verhindert #389-Klasse systemisch); F31 ✓ 13deaf9 Reindex aus gemeinsamer Quelle (dynamisch über Registry); offen: F07 Jobs-Mandantenkontext, F13 Workflow-Locks, F16 Plugin-Lifecycle, F18 Schema-Verfahren — die 4 konsolidierungsintensivsten), S3 Fachliche Integrität [#398](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/398) (F25-F28, F32-F35, F38, F39), S4 Betriebsfreigabe [#399](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/399) (F04, F22, F24, F29, F36 + korrigierte Phase R). Milestone 16. Abnahmen quer: Kontaktanlage→Outbox→Worker→Suchindex→KI-Abfrage; Mailentwurf→Freigabe→einmaliger Versand. Neue Aufwandsschätzung nach S1+S2.
- **Phase R** — Betriebssicherheit & 95%-Produktionsreife (R1-R6, user-abgestimmt 2026-09-16). Läuft in Phase S Welle 4 auf; korrigiert 2026-09-17 nach Astra-Kritik (8 Punkte in Roadmap eingearbeitet). R1 Alerting gegen stille Ausfälle [#390](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/390) (PRIORITY 1 — Evidenz: KI-Chat 4 Wochen still down #389), R2 Suite verlässlich [#391](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/391), R3 Schema-Integrität [#392](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/392), R4 E2E-Kernflows [#393](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/393), R5 Backup-Restore-Drill [#394](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/394), R6 Ops-Runbook [#395](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/395). Milestone 15. 5 messbare Abnahmekriterien = die 95%-Definition (Details + DoD: Roadmap Phase R).
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section).
- **Phase N** — Workspace-Scopes (N1-N4). **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung contacts/dms/mail/calendar + Frontend-Defaults), **N4 ✓ erledigt — PHASE N KOMPLETT** (7 weitere Module: Tasks nur-meine, Kommunikation-Räume, Wiki-Kategorien-Subtree, Reports-Vorlagen, Agents, Tags, Search-Entity-Types + Navigation Startseite/Menü-Reihenfolge + Dashboard-Schnittstelle — siehe Phase-N4-Section). **Nächster Schritt:** Phase O UI-Overhaul (offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) oder Phase P Notizen-App (P1-P5).
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
1. `entity_attachments.dms_file_id → files` (Core-FK auf DMS-Tabelle) blockiert `alembic check`
2. Suite-Isolation: kombinierte Test-Runs quicken mit "relation users does not exist" (Solo-Runs grün)
3. AppShell vitest worker OOM bei Solo/Combo-Runs
**Modul-Bauplan (bewaehrtes Muster, Module 14-16 direkt anwendbar):** Backend lesen (Routes/Schemas/Permissions) → `api/<modul>.ts` (TanStack-Hooks) oder bestehenden Client erweitern → `pages/<Modul>.tsx` → Registrierung (Plugin: manifest plugin.py page_routes+menu_items + generate_component_map.py + ICON_MAP-Icon; Core: routes/index.tsx + Settings.tsx; oeffentlich: statische Route ausserhalb ProtectedRoute) → i18n de/en (Python-Patch-Skript, JSON-Roundtrip pruefen) → Vitest → tsc → Build → Deploy (frontend-only wenn kein plugin.py; full bei plugin.py) → Live-Verifikation (API curl + echter-Login Playwright DOM-Check) → Forgejo-Issue (Label 5=task, danach schliessen) → PROGRESS.md + Roadmap-Zeile.
**Session-Lektionen fuer Tests/Implementation (2026-09-15, wiederkehrende Stolperfallen):**
- Vitest: Mutation-Mocks mit `vi.hoisted()` definieren (Top-Level const = ReferenceError durch Hoisting)
- TanStack Query v5 ruft `mutationFn(variable, context)` — Assertion auf `mock.calls[0][0]`, nicht `toHaveBeenCalledWith(...)`
- Query-Ergebnisse asynchron: `await screen.findByTestId(...)` statt synchronem getByTestId
- `window.confirm`: Direkt-Zuweisung im beforeEach (`window.confirm = () => true`), spyOn nur in-Test
- Hook-Mocks (`useXxx: () => (...)`) sind robuster als queryFn-Mocks — synchron, kein isLoading-Handling
- text_editor verschluckt gelegentlich JSX-Kommentar-Schliessungen (`*/` ohne `}`): vor tsc mit `grep '{/*'` pruefen
- Frontend-Catch: der Client-Interceptor wirft `ApiError` mit `.status` auf Top-Level — NICHT `err.response.status` lesen
- i18n: Block ggf. bereits vorhanden (ungenutzte Alt-Keys) — nur fehlende Keys mergen, nicht ueberschreiben
- Oeffentliche Seiten (ohne Login): statische Route analog `/guest/*`, NIEMALS in die AppShell/ProtectedRoute
**Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth').
> **Letztes Update:** 2026-09-15
## Produktions-Bugfixes (2026-09-16)
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-09-16) |
|---|---|---|---|
| Plugin ai_assistant seit Alembic 0137 (2026-08-21) migration_failed/inactive — KI-Chat und /api/v1/ai/* in Produktion down (403 plugin_inactive); Migration 0003 exec ALTER TABLE ai_chat_sessions crashte bei jedem Container-Start (Tabelle von 0137 gedroppt) | [#389](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/389) | Migrationen 0001-0003 von Referenzen auf gedroppte ai_chat-Tabellen befreit (0003: nur ai_chat_folders behalten; 0001: Ghost-CREATEs entfernt; 0002: attachments+ALTER entfernt). Runner skipt getrackte Migrationen per Dateiname (kein Hash-Check) → kein Prod-Risiko (Commit 0383dd2) | Prod-DB-Diagnose: nur ai_chat_folders existiert, 0001+0002 getrackt; nach Full Deploy: Plugin status=active, GET /api/v1/ai/agents → 200 mit echtem LeoCRM Assistant, KI-Chat wieder live |
| CSRF-Middleware verlangte Origin+X-CSRF-Token auch auf Bearer-authentifizierten API-Calls → External-Agent-API (/api/v1/external/agent/*) fuer externe Systeme unbrauchbar (403 ohne Origin/CSRF) | (in #387 aufgegangen) | Authorization: Bearer-Requests skippen die CSRF-Pruefung — Bearer ist CSRF-immun per Design (Browser haengen Authorization-Header nie automatisch an); Session-Requests unverändert voll geprueft (Commit b91ee5b) | Live: Dummy-Bearer → 401 not_authenticated (Auth-Ebene erreicht statt 403 CSRF); Session-Request ohne CSRF bleibt 403 csrf_missing_token; pytest test_auth.py 11/11 |
## Produktions-Bugfixes (2026-09-14)
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-09-14) |
|---|---|---|---|
| Jeder Outbox-Event-Publish crashte im Webhook-Dispatcher mit `Neither 'AnnotatedColumn' nor 'Comparator' object has an attribute 'any'` → 158 failed Events (`file.deleted`, 2026-08-27) | [#380](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/380) | `Webhook.events` ist JSONB-Column (KEINE Relationship): `.any()` an 2 Stellen (webhook_dispatcher.py, webhook_service.py) ersetzt durch `cast(events, JSONB).contains([event])` (Commit 50d6733) | pytest test_webhooks.py 6/6 (SQL: `CAST(webhooks.events AS JSONB) @> ...`); Full Deploy Health 200; Live: `replay-all` → 158 replayed, danach stats `{published:158, failed:0}` (vorher `{failed:158}`) |
| Alle Core-Lazy-Routen im AppShell-Baum hingen ewig im Route-Suspense-Spinner ("Dashboard loads forever"-Incident) bei Direkt-Aufruf/Reload | [#381](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/381) | Root Cause via Bisekt: `useWorkspaceStore(s => s.moduleMenuOrder())` + `s.visibleModuleKeys()` erzeugten bei jedem getSnapshot NEUE Map/Set-Objekte → useSyncExternalStore-Render-Loop → Suspense-Commits landeten nie. Fix: stabile `context`-Referenz selektieren + useMemo-Ableitung (Commit 3fd0c69) | Bisekt-Beweis: Min-AppShell rendert alles, +Sidebar → Hang; Import-Bisect: Chunk resolved aber kein Commit. Live PROD: /dashboard frischer Kontext `h1='Dashboard', spinner=false` (vorher hängender Spinner); /outbox echter Login: h1='Event Outbox', Published 158, Failed 0, 35 Registry-Karten, mainTextLen 1699 |
| Consumer-Registry zeigte scheinbare Duplikate: `on_contact_created` 3x (drei Plugins mit gleichem Methodennamen ununterscheidbar) | Commits 591ef06 + fccf009 | `_get_handler_name` nutzt `__qualname__` für bound methods (Plugin-Handler): Registry zeigt `AutomationPlugin.on_contact_created` vs `UnifiedSearchPlugin.on_contact_created` vs `SystemNotifPlugin.on_contact_created`; plain functions behalten `__name__` | pytest test_outbox_phase5 17/17; Full Deploy Health 200; Live: Registry 46 Handler, eindeutige Plugin-Namen, Duplikat-Check: nur `_noop_handler` (korrekt — 1 Placeholder-Fn für mehrere Events) |
## Produktions-Bugfixes (2026-08-27)
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-08-27) |
|---|---|---|---|
| KI-Chat `Stream failed: 403` (sessionStorage-Key `leocrm_csrf_token` wird nie geschrieben → Request ohne X-CSRF-Token) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | streamChat nutzt `getCsrfToken()` aus dem gemeinsamen Client | Vitest streamChat.test.ts 2/2 passed: X-CSRF-Token-Header bewiesen |
| Alle Mutationen (Wiki-Save etc.) 403 nach Seiten-Reload (`/auth/me` lieferte csrf_token nicht zurück → In-Memory-Token nach Reload weg) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | `/auth/me` liefert `csrf_token` aus Session; useCurrentUser stellt ihn beim Bootstrap wieder her | pytest test_auth.py 11/11 passed inkl. neuem Regressionstest `test_me_returns_csrf_token_for_reload_restore` |
**Gates:** ruff exit=0 · tsc --noEmit exit=0 · pytest 11 passed · Vitest 2 passed
## W4c — Custom-Fields-Routen in ContactsPlugin migriert (2026-08-28) ✅
**Verify-first:** `app/routes/custom_fields.py` war 100% Contact-spezifisch (importiert Contact, nutzt contacts:read/write, Route /{contact_id}/custom-fields) — lag aber als scheinbar generischer Core-Service (Kritikpunkt 14).
**Fix (c6decf5):** Die komplette Logik (2 Endpoints GET/PATCH, `_collect_custom_field_definitions`, `_merge_definitions_with_values`, `CustomFieldUpdateRequest`) wandert in `app/plugins/builtins/contacts/routes.py` (gleicher Router-Prefix /api/v1/contacts, bereits via manifest.routes gemounted). `app/routes/custom_fields.py` gelöscht, main.py bereinigt. Der generische `custom_field_definitions.py`-Endpoint bleibt im Core.
**Verifikation:** tests/test_custom_fields.py **11/11 passed** (Funktionserhalt) · create_app OK · ruff grün · Full Deploy SUCCESS · Health healthy
## Phase L1-L3 — Dokumente-Generator Backend+Editor (2026-08-29) ✅
**Scope:** Briefpapier (letterheads) + Druckvorlagen (print_templates) + Assets (document_assets) + Block-Registry + Contract-Beiträge + Drag&Drop-Editor + globaler Dokument-Dialog. Erweiterung des report_generator-Plugins (kein Neubau).
**Umgesetzt:**
- Backend: `documents.py` (13 Endpoints), `document_blocks.py` (Registry: text/image/shape/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via `document_blocks()`-Contract), `document_renderer.py` (Blocks→HTML→PDF, WeasyPrint data:-URI-only SSRF-Policy, Briefpapier-@page-Frame mit running header/footer)
- Contract-Beitrag contacts: `document_placeholders(entity_type)`, `document_data(db, tenant_id, entity_id, entity_type)` (#359-Muster wie importexport_entities)
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path-Konvergenz, RLS fail-closed nach 0084-Muster)
- Frontend: `api/documents.ts` + `DocumentSettings`-Page (Settings→Dokumente, eigener Menüpunkt via settings_pages) + `BlockEditor` (@dnd-kit: Palette/Canvas/Config-Panel/Live-Preview-iframe) + `LetterheadEditor` + `PrintTemplateEditor` + `DocumentGenerationDialog` (global für Module, integriert in ContactDetailPage)
- i18n de/en vollständig
**Verifiziert:**
- ✅ tests/test_documents_generator.py: 32/32 (CRUD, Tenant-Isolation, RBAC 403, Block-Validierung 422, Preview, Render-PDF `%PDF`, Assets, Contract-Unit)
- ✅ Regression: test_report_generator.py + test_plugin_route_order.py 9/9
- ✅ tsc --noEmit Exit 0; Production-Build OK (2.79s)
- ✅ Alembic-Fresh-DB: 0001→0143 komplett, letterheads/print_templates/document_assets mit RLS+FORCE+crm_api-Policy bewiesen (Scratch-DB wieder gedroppt)
- ✅ ruff check clean; check_migration_hashes 93/93 OK
- ⚠️ Bekannt: Router-Reihenfolge im Manifest — documents-Router muss VOR routes stehen (/{report_id}-Catch-all)
**Offen (Folgepakete):**
- L4: KI-Steuerung („Erstelle Rechnungsvorlage") via agent_loop
- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul)
- Weitere Module können Blöcke/Platzhalter beisteuern (Contract-Muster dokumentiert in plugin-development-guide.md)
## Phase L4-L5 — KI-Vorschlag + XRechnung-Format-Layer (2026-08-29) ✅
**User-Klärung:** Verkaufsmodul kommt später — aber das XRechnung-FORMAT ist jetzt implementiert (reiner Format-Layer, kein Rechnungs-CRUD).
**Umgesetzt:**
- L5 Format-Layer: `einvoice.py` — EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes (BT-1/2/3/5, BT-10, BT-27, BT-31/32, BG-25, BT-126/146), Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (Guideline urn:xoev-de:kosit:standard:xrechnung_3.0)
- Endpoints: `/einvoice/render` (inline → XML), `/einvoice/validate` (422 mit Fehlliste), `/einvoice/render-for` (Contract-Resolver `einvoice_data()` — Andockpunkt Verkaufsmodul, ohne Beitrag 404 no_data_source)
- L4 KI-Steuerung: `/documents/suggest` — natürliche Sprache → Block-Komposition via zentralem llm_complete (gpt-4o-mini, Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungültige KI-Blöcke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles, Prompt-Textarea, Vorschläge werden an Blöcke angehängt), i18n de/en
**Verifiziert:**
- ✅ TDD: Rot 25 failed → ✅ Grün **25/25** (tests/test_einvoice_generator.py: Validierung 6 Unit, XML-Struktur 5 Unit inkl. Escaping/Profil/Summen, Contract-Resolution 2 mit Mock-Registry, API 6: 200-XML/422-BT-Codes/403/404, Suggest 6: Mock-LLM/Filter/Fence/502/403)
- ✅ tsc exit 0 (useMutation-Typisierung SuggestResult,Error,SuggestInput), Production-Build BUILD_EXIT=0
- ✅ ruff clean
**Offen:** Verkaufsmodul dockt später via `einvoice_data()` an — Contract + Doku (plugin-development-guide.md) fertig.
## Phase M — MiniApp-Plattform & Dashboard-Builder (2026-08-29 geplant, user-abgestimmt)
**User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed.
**Status:** done — M1M6 alle erledigt (siehe Sections unten). Phase-Gate: alle Tasks implementiert, getestet (TDD), deployed und auf Produktion verifiziert (live curl-Beweise je Section).
**Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden.
## Phase M1 — Universal-MiniApp-Registry (2026-08-30) ✅
**Umgesetzt:**
- `app/plugins/miniapp_registry.py` (154 Z.): Registry in den Plugin-Layer gehoben (Plattform-Konzept). MiniAppDef erweitert um `permission` (fail-closed, leer = jeder), `settings_schema`, `col_span`/`row_span`, `hosts` (chat/dashboard/window), `component`, `order`, `builtin`.
- Kompatibilitäts-Brücke: `kommunikation/miniapp_registry.py` re-exportiert die Universal-Registry — alle Bestands-Importer (kommunikation contracts, automation routes, tests) unverändert lauffähig.
- Lifecycle: `BasePlugin.on_activate` registriert Manifest-Beiträge automatisch (miniapps + dashboard_widgets-Alias mit component/spans/permission — ein Contribution-Typ, #359-Philosophie); `on_deactivate` entfernt per `unregister_plugin` nur die eigenen Apps.
- Manifest-Schema: `MiniAppContribution` + `FrontendDashboardWidget` um M1-Felder erweitert (settings_schema, hosts etc.).
- API: `GET /api/v1/miniapps` (Server-seitig permission-gefiltert, ?host=), `GET /api/v1/miniapps/{app_id}` (403 fail-closed / 404).
**Verifiziert (2026-08-30):**
- TDD: Rot 16 errors/failed → ✅ Grün **16/16** (tests/test_miniapp_registry.py: Registry-Unit 6, Bridge-Import 1, Manifest-Registrierung+Lifecycle 3, API 6 inkl. Viewer-Filter-Beweis + Host-Filter + 403/404)
- ✅ Regression: test_contracts.py 23/23, plugin_lifecycle + route_order 4/4, create_app OK
- ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand
- ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster)
**Offen in Phase M:** — (Phase M abgeschlossen).
## UI-Backlog: Frontend-Backend-Gap (2026-09-08 laufend)
**Kontext:** Frontend-Backend-Gegenüberstellung (2026-09-01) ergab 16 Backend-Module ohne UI (~64 Ops) bei 76-84% Business-UI-Coverage. User-Entscheidung: Module einzeln mit UI ausstatten, priorisiert nach Business-Nutzen.
| # | Modul | Ops | Status | Issue |
|---|-------|-----|--------|-------|
| 1 | Approvals (Freigaben) | 6 | ✅ Done | #369 |
| 2 | Delegations (Vertretungen) | 5 | done | Commit 36771d4, [#372](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/372): UI live — Vitest 9/9, tsc 0, Build 0, Frontend-Deploy, API 200 ({items:[],total:0} + active-check OK) |
| 3 | API-Tokens | 3 | done | Commit 4bdc6c6, [#373](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/373): Settings-Page /settings/api-tokens — Vitest 8/8, tsc 0, Build 0, Frontend-Deploy, live: API 200 (echter TestToken sichtbar) + SPA 200 |
| 4 | Tenants (Mandanten) | 4 | done | Commit 79ca1cb, [#374](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/374): Settings-Page /settings/tenants — Vitest 8/8, tsc 0, Build 0, Frontend-Deploy, live: API 200 (Default Org sichtbar) + SPA 200 |
| 5 | Marketplace (Plugin-Markt) | 5 | done | Commit 289dfc8, [#375](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/375): ERSTES Modul via Phase-Q-Manifest-Architektur — Vitest 10/10, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, SPA 200 |
| 6 | Permission-Templates (Berechtigungs-Vorlagen) | 5 | done | Commit 33b4b52, [#376](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/376): Settings-Page /settings/permission-templates — Vitest 10/10, tsc 0, Build 0, Frontend-Deploy, live: API 200 + SPA 200 |
| 7 | Skills (AI-Skill-Definitionen) | 5 | done | Commit 3f8d1bd, [#377](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/377): /skills via automation-Manifest (Phase Q) — Vitest 10/10, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, SPA 200 |
| 8 | Agent-Memory | 5 | done | Commit 24423b6, [#378](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/378): /agent-memory via agent_memory-Manifest (Phase Q) + ICON_MAP-Fix (Brain/Store/Tags) — Vitest 11/11, tsc 0, Build 0, Full Deploy, live: Manifest OK, API 422 ohne agent_id (Pflichtfeld bewiesen), SPA 200 |
| 9 | Outbox (Event-Verwaltung) | 7 | done | Commit 31154b9, [#379](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/379): Core-Route /outbox + Sidebar order 93 mit Admin-Filter — Vitest 11/11, tsc 0, Build 0, Frontend-Deploy, live: stats/failed/consumer-registry API 200 (158 echte failed events `file.deleted` 2026-08-27, 20+ Handler), Nav-Link gerendert, Chunk MD5-identisch |
| 10 | Policies (ABAC-Richtlinien) | 4 | done | Commit d734923, [#382](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/382): Settings-Page /settings/policies mit Entity-Typ-Tabs (8), Conditions-Builder (AND/OR, Whitelist-Felder, 12 Ops), Principal-Picker (User/Group/Role) — Vitest 12/12, tsc 0, Build 0, Frontend-Deploy, live: API 200 (items=[]) + echter-Login DOM-Check (Page gerendert, alle Tabs, kein Spinner) |
| 11 | Graph-RAG Traversal | 4 | done | Commit 0404c8f, [#383](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/383): /graph-rag via graph_rag-Manifest (Phase Q, Share2-ICON_MAP, Komponenten-Map 41) + knowledge.ts-Erweiterung (traverse/create/delete) — Vitest 11/11, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, echter-Login DOM-Check (Page gerendert, kein Spinner) |
| 12 | Companies (Firmen-API) | 9 | done | Commit 06b7284, [#384](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/384): /companies via contacts-Manifest (Phase Q, Building2-ICON_MAP, Komponenten-Map 42) — Vitest 12/12, tsc 0, Build 0, Full Deploy, live: Manifest OK, API 200 (echte Firmendaten), echter-Login DOM-Check (3 Firmenkarten, Export-Buttons, kein Spinner) |
| 13 | Public-Share | 3 | done | Commits 00f8f10 + 2fbffcd, [#385](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/385): Oeffentliche Seite /share/:token (Passwort-Support, Download, 404/410-Zustaende) + ShareDialog kopiert jetzt SPA-Links statt API-JSON — Vitest 9/9, tsc 0, Frontend-Deploy, live ohne Login: Fehlerseite 'Link not found' gerendert, kein Login-Redirect |
| 14 | Guests | 3 | done | Commit b3eaa0e, [#386](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/386): Settings-Page /settings/guests mit Admin-Gate (Outbox-Muster), Gästeliste mit Status-Badges (invited/active/disabled), Invite-Modal (RHF+zod), Revoke-ConfirmDialog — Vitest 8/8, tsc 0, Frontend-Deploy, live: API GET /api/v1/guests 200 [], echter-Login DOM-Check (Page, EmptyState, Invite-Button, Nav-Eintrag gerendert) |
| 15 | External-Agent | 3 | done | Commit e8e07fa, [#387](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/387): Settings-Page /settings/external-agents via ai_assistant-Manifest (Phase Q, permission ai:read, Komponenten-Map 43) — Agentenliste mit curl-Snippets (run/status/stream), Copy-Buttons, Bearer/Rate-Limit-Hinweis, Token-Link — Vitest 6/6, tsc 0, Full Deploy, live: echte Agent Card (LeoCRM Assistant), Snippets+Copy-Buttons, Nav-Eintrag; VORAUSSETZUNG waren 2 Backend-Fixes: ai_assistant-Reaktivierung (Commit 0383dd2, [#389](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/389)) + CSRF-Bearer-Skip (Commit b91ee5b) |
| 16 | Ownership-Transfer | 1 | done | Commit e8e07fa, [#388](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/388): Settings-Page /settings/ownership (statische Core-Route) mit Admin-Gate, From/To-User-Selects, 10 Entity-Type-Chips, ConfirmDialog, Ergebnis-Tabelle — Vitest 6/6, tsc 0, Full Deploy, live: Formular/Chips/Submit/Nav gerendert, API 422 mit korrekten Pydantic-Fehlern (Admin-Route erreichbar) |
**Modul 1 — Approvals (2026-09-08) ✅:** Review-Queue (Status-Tabs Offen/Alle/Genehmigt/Abgelehnt/Abgelaufen), Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve), Metadata-Anzeige. Phantom-Permission-Bug gefixt (approvals:read/write/approve fehlten in CORE_PERMISSIONS — Rollen konnten sie nie erhalten, M2-Fehlerklasse). Vitest 10/10, RBAC 102/102, live: /approvals 200, Prod-Bundle enthält UI. Deploy: ecc7a24 (Full).
## Phase N4 — Restliche Module (2026-09-01) ✅ — PHASE N KOMPLETT
**Spec:** [#368](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/368) | **Roadmap:** Phase N, N4 (letzter Task) | **Milestone:** Phase N — Workspace-Scopes (#14)
**Umgesetzt:**
- **Scope-Deklarationen (7 Plugins):** tasks only_mine-Toggle („nur meine"), kommunikation conversation_ids (Räume), wiki category_ids (Subtree — NEUE contracts.py, wiki hatte zuvor keinen Contract), report_generator template_ids (Vorlagen), automation agent_ids (module_key agents — page route ohne Menüeintrag), tags tag_ids (Root-Array), unified_search entity_types DYNAMISCH aus Provider-Registry (13 Entity-Types, Live-Set + deterministischer Klassen-Fallback).
- **Core-Beiträge (Aggregator):** navigation default_route (Startseite pro Workspace, Optionen aus CORE_PERMISSIONS + bekannten Frontend-Routen) + dashboard widget_app_ids (begrenzt das Widget-TYP-Angebot — workspace_widgets-Boundary; persönliches Layout bleibt Phase M).
- **Backend-Filter (additive UND, kein Umbau):** GET /tasks (assigned_to OR created_by), GET /comm/conversations (Subset), GET /wiki/articles + /categories (expand_folder_scope-Subtree), GET /reports/print-templates (Subset), GET /agents (Subset), GET /tags (Subset), GET+POST /search (apply_entity_type_scope: requested ∧ scope), GET /miniapps?host=dashboard (widget_app_ids begrenzt NUR Dashboard-Angebot, chat/window unberührt).
- **Frontend-Navigation:** WorkspaceSwitcher navigiert nach default_route beim Wechsel (Validierung: muss mit / beginnen); Sidebar sortiert nach workspace menu_order als Admin-Default (persönliche savedOrder bleibt Override); workspaceStore moduleMenuOrder()-Helper.
**Verifiziert (2026-09-01):**
- TDD: Deklarationen **18/18** (rot: 18 failed → Implementation → grün), Filter **11/11** (rot: 8 failed + 1 error → grün; inkl. Dashboard-Boundary: scoped {w1} vs. unscoped Superset, chat unberührt)
- ✅ Frontend: Vitest Switcher-Navigation 2/2, Store 18/18 (moduleMenuOrder +2), tsc clean, Build OK
- ✅ Kombi-Regression (N1+N3+N4-Dateien): 64 passed / 4 failed — alle 4 per Solo-Lauf als Suite-Isolation bewiesen (N1 solo 18/18, N3-Test solo grün — bekannter Vorbestand, unterschiedliche Plugin-Fixtures in einem Prozess)
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: 7 Fehler = exakt Vorbestand (Stash-Beweis: clean HEAD identisch 7)
**Phase N Gesamtbilanz:** Workspace-Scopes komplett — Registry via Contract (N1), dynamischer Editor (N2), Backend-Filterung für alle 11 Module (N3: contacts/dms/mail/calendar + N4: tasks/communication/wiki/reports/agents/tags/search) + Navigation (Startseite, Menü-Reihenfolge) + Dashboard-Schnittstelle (widget_app_ids). Security-Invariante durchgehend: Scope = reine UND-Einschränkung, Exemptions nur System-Admin + configure_modules-Inhaber (Editor-Deadlock). Issues #365-#368 alle geschlossen.
## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅
**Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14)
**Umgesetzt:**
- **Core-Resolver** `resolve_workspace_scope()` (workspace_scope_service.py): X-Workspace-ID → Workspace aktiv/Tenant → User-Zuweisung → Modul-config; leere Dimensionswerte fallen weg. Exemptions: System-Admins + `workspaces:configure_modules`-Inhaber — löst den Editor-Deadlock (N2-Scope-Editor lädt Wertoptionen über dieselben Endpoints).
- **FastAPI-Dependency** `require_workspace_scope(module_key)` (deps.py) — Header-Parsing gekapselt, einzeilige Nutzung pro Route.
- **Ordner-Subtree** `expand_folder_scope()`: self + descendants (zyklensicher) für ContactFolder + DMS Folder — Ordner-Scopes gelten inkl. Unterordnern. `scope_uuid_set()`: fail-closed (garbage UUIDs → leere Menge).
- **Listen-Filter (additive UND-Einschränkung, kein Umbau):** contacts (folder_ids-Subtree + contact_types auf GET /contacts; List-Cache bei aktivem Scope deaktiviert — Cross-Workspace-Leak-Gefahr beseitigt), dms (folder_ids-Subtree + file_types auf GET /files, Baum-Reduktion auf GET /folders; semantische Typ-Matcher pdf/image/spreadsheet/word/other), mail (account_ids auf GET /mails, /threads, /accounts-Picker), calendar (calendar_ids auf GET /calendar/entries + /calendars-Picker).
- **Frontend-Defaults:** `getModuleConfig(moduleKey)` im workspaceStore; ContactsList wendet `default_saved_view_id` beim Mount an (admin-definierte Standard-Ansicht), Calendar setzt `default_view` (day/week/month/range) bei Workspace-Wechsel.
**Verifiziert (2026-09-01):**
- TDD: Rot (7 ImportError + 14 Fixture-Errors) → ✅ Grün **21/21** (Resolver 7, Contacts 5 mit Cache-Bypass-Beweis + scharfem AND-Beweis (Beta=Person im Ordner-Scope fällt raus), DMS 3, Mail 3, Calendar 3 inkl. Admin-Bypass)
- ✅ Regression: N1 + N2 + Workspaces + test_mail **81 passed**
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: nur per Stash bewiesener Vorbestand (N806/UP017)
- ✅ Frontend: tsc clean, Vitest (workspaceStore 16/16, CalendarPage, ContactsList) grün, Production-Build OK
**Offen in Phase N:** N4 restliche Module (Tasks „nur meine", Kommunikation-Räume, Wiki-Kategorien, Reports-Vorlagen, Automation-Agenten, Tags, Search-Provider, Navigation-Defaults) + Dashboard-Schnittstelle (workspace_widgets begrenzt Widget-TYP-Angebot).
## Phase N2 — Dynamischer Scope-Editor (2026-09-01) ✅
**Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14)
**Umgesetzt:**
- **WorkspaceScopeEditor.tsx** (neu): generisches Filter-UI aus /scope-definitions — multiselect (statische Options ODER value_source-Fetch), select (mit 'Keine Einschränkung'-Placeholder), toggle. WidgetSettingsForm-Philosophie (M3): die Komponente kennt keine spezifischen Module, Plugins deklarieren via Contracts.
- **resolveScopeItems** (api/hooks/workspaces.ts): Wertequellen-Auflösung für alle N1-Formate — items-Wrapper (contact-folders), Root-Listen (mail/accounts, calendars, saved-views), DMS-Ordner-Baum (children-Flattening). Nie-crashend: defekte Responses → leere Liste.
- **Hooks:** useWorkspaceScopeDefinitions (queryKey workspace-scope-definitions) + useScopeValues (endpoint-spezifisch, staleTime 60s).
- **WorkspaceManager:** JSON-Textarea-Editor ENTFERNT — dynamischer Scope-Editor inline pro sichtbarem Modul; Speicherung unverändert über POST /{id}/modules in workspace_modules.config.
- **i18n:** workspaces.scopeEditor.* 5 Keys (de/en) — hint (Security-Invariante im UI), noRestriction, noDimensions, noValues, loadError.
**Verifiziert (2026-09-01):**
- TDD: Rot 4 failed → ✅ Grün **21/21** (WorkspaceScopeEditor 17: resolveScopeItems-Unit 4, Rendering 7, onChange 6; WorkspaceManager-Integration 4: Textarea weg + Scope-Fields da, Config-Roundtrip checked, Save-Payload config korrekt, No-Dimensions-Hinweis nach Toggle)
- ✅ npx tsc --noEmit: clean (0 Errors)
- ✅ Production-Build: OK (vite build, nur Chunk-Size-Warnung Vorbestand)
- ✅ Frontend-only-Deploy + Bundle live verifiziert
**Offen in Phase N:** N3 Backend-Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung: contacts Ordner/Typen/View, dms Ordner/Typen, mail Postfächer, calendar Kalender/View), N4 restliche Module.
## Phase N1 — Scope-Registry via Contract (2026-08-31) ✅
**Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14)
**Umgesetzt:**
- **Contract-Hook `workspace_scopes()`** (document_placeholders-Muster, #359-Philosophie): Plugins deklarieren Scope-Dimensionen ihres Moduls inkl. Wertequellen; der generische Editor bleibt modul-agnostisch.
- **Deklarationen (4 N3-Module):** contacts (folder_ids via /api/v1/contact-folders, contact_types Firmen/Personen, default_saved_view_id via /api/v1/saved-views?entity_type=contact), dms (folder_ids via /api/v1/dms/folders, file_types PDF/Bilder/Tabellen/Dokumente/Sonstige), mail (account_ids via /api/v1/mail/accounts), calendar (calendar_ids via /api/v1/calendars, default_view Tag/Woche/Monat/Zeitraum — Frontend-Ansichten live abgeglichen).
- **Pydantic fail-closed** (app/schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1/-Pfade — SSRF-sicher per Konstruktion; Validator), WorkspaceScopeDimension (multiselect/select ohne options UND value_source → ValidationError), WorkspaceModuleScopes (module_key + min. 1 Dimension).
- **Aggregator** (app/services/workspace_scope_service.py): iteriert discovered Plugins, lazy-loadet Contracts, ARCH-014-safe (deaktivierte bleiben weg), Crash-sicher pro Plugin, ungültige Deklarationen verworfen (Warning-Log).
- **Endpoint** `GET /api/v1/workspaces/scope-definitions` (workspaces:configure_modules — Admin-Kontext) — VOR /{workspace_id} registriert (Route-Order-Falle, test_plugin_route_order-Klasse).
**Verifiziert (2026-08-31):**
- TDD: Rot (ImportError) → ✅ Grün **18/18** (tests/test_workspace_scopes.py: Pydantic-Unit 4, Contract-Deklarationen 8, Aggregator fail-closed 1, HTTP-Endpoint 2 (Admin bekommt alle 4 Module, Viewer-403), Route-Order 1, Value-Endpoint-Existenz via OpenAPI 1 (431 Pfade, app.routes enthält nur _IncludedRouter-Wrapper — isinstance-Scan versagt, OpenAPI kanonisch), /context-config-Regression 1)
- ✅ Regression: test_workspaces.py **17/17**
- ✅ Cross-Plugin-Checker: 0 Verstöße (495 Dateien)
- ✅ Ruff clean (alle 8 geänderten Dateien; UP037-Quote-Fix)
**Offen in Phase N:** N2 Dynamischer Scope-Editor (WorkspaceManager rendert Filter-UI aus /scope-definitions, Speicherung in workspace_modules.config), N3 Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung), N4 restliche Module.
## Phase M6 — Weitere Hosts (2026-08-30) ✅ — PHASE M KOMPLETT
**Spec:** [#364](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/364) | **Roadmap:** Phase M, M6 (letzter Task)
**Umgesetzt:**
- **Windows-Host:** `openMiniAppWindow`-Helper + `MiniAppWindowContent` (windowStore, schwebende/verschiebbare Fenster, kompakte Default-Größe 520×480). Öffnen-Buttons: Chat-Block (MiniAppBlock, ExternalLink-Icon) und Dashboard-Widget (DashboardBuilder, auch View-Modus).
- **AI-Agenten-Host:** Core-Tool `send_miniapp` (app/ai/miniapp_tools.py) — Agent bettet MiniApp als interaktiven Ausgabe-Block (block_type miniapp, approval_request-Präzedenz) in seinen Chat-Raum ein. Permission fail-closed gegen den aufrufenden User pro App (resolve_permissions + check_permission); Registrierung im lifespan-Startup.
- **agent_loop:** tool_context um agent_name erweitert (Raum-Auflösung "Agent: {name}").
- **Fix:** MiniAppBlock nutzt jetzt useMiniapps (universelle Registry MIT component-Feld) statt Legacy /comm/miniapps — der Fenster-Button erscheint damit erstmals zuverlässig.
- **Fix (Vorbestand, live gemessen):** /api/v1/agents/tools rief registry.list_tools() auf (Methode existiert nicht → 500) — auf list_for_api() mit korrektem Feld-Mapping umgestellt + Regressionstest gesichert.
**Verifiziert (2026-08-30):**
- TDD: Rot 7 failed → ✅ Grün **8/8** (tests/test_m6_miniapp_hosts.py: Tool-Registrierung 1, Handler 5 (unknown/nie-posten/Permission-deny/Block-Posting mit exakter block_data/no-room-degradation), agent_name-Kontext 1, list_for_api-Regression 1)
- ✅ Backend-Regression: M6 + M5 + Phase-F-Agenten **57/57**
- ✅ Frontend: Vitest **26/26** (4 neue Window-Tests: MiniAppWindowContent-Rendering + openMiniAppWindow-Store-Integration, Typ/Title); `npx tsc --noEmit` clean; Production-Build OK (2.74s)
- ✅ Deploy (335762d + 04e9279, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
- ✅ Produktions-Verifikation (curl): /api/v1/agents/tools listet **send_miniapp live** (18 Tools total, plugin system) — Vorbestands-500 gefixt; neuer Frontend-Bundle live
**Phase M Gesamtbilanz:** MiniApp-Plattform komplett — universelle Registry (M1), persönliche Dashboards mit RLS (M2), Builder mit Drag&Drop/Tabs/Settings (M3), Core als reiner Host (M4), 8 Plugins + 2 System-Apps liefern Widgets (M5), Chat + Dashboard + Fenster + AI-Agenten als Hosts (M6). 17 Apps, 11 renderbar, in Produktion live.
## Phase M5 — Plugin-MiniApps (2026-08-30) ✅
**Spec:** [#363](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/363) | **Roadmap:** Phase M, M5
**Umgesetzt:**
- 5 Manifest-Beiträge (MiniAppContribution, gleiche Philosophie wie contacts_stats):
- dms: `dms_folders` (dms:read, Ordner mit Dateizählern, Settings max_items)
- mail: `mail_unread` (mail:read, ungelesene Mails je Ordner, Settings max_items)
- wiki: `wiki_recent` (wiki:read, zuletzt aktualisierte Artikel, Settings max_items)
- graph_rag: `graph_overview` (graph:read, Beziehungsübersicht, Settings max_items)
- automation: `automation_status` (automation:read, aktive/inaktive Automationen, Settings max_items)
- 5 Frontend-Widgets auf bestehenden API-Clients (keine neuen Backend-Endpoints, §0.2): DmsFoldersWidget (fetchFolders), MailUnreadWidget (fetchAccounts+fetchFolders/unread_count), WikiRecentWidget (fetchWikiArticles), GraphOverviewWidget (fetchGraphRelationships), AutomationStatusWidget (useAutomations). MiniAppHost-Registry +5.
- **Bug gefunden & gefixt (live gemessen):** automation/plugin.py on_activate re-registrierte Manifest-MiniApps in einem Legacy-Block OHNE component/permission — überschrieb die korrekte M1-Registrierung aus super().on_activate(). Legacy-Block entfernt + Regressionstest gesichert (test_automation_legacy_reregistration_removed).
- ruff: wiki I001 Import-Sortierung gefixt.
**Verifiziert (2026-08-30):**
- TDD: Rot 7 failed → ✅ Grün **8/8** (tests/test_m5_plugin_miniapps.py: Manifest-Felder 5, Lifecycle-component-Beweis 1, settings_schema 1, Legacy-Regressionstest 1)
- ✅ Backend-Regression: M5 + Registry + M4 + M2 **53/53**; nach automation-Fix: M5 + lifecycle + registry **26/26**
- ✅ Frontend: Vitest **22/22**; `npx tsc --noEmit` clean; Production-Build OK
- ✅ Deploy (7ed5349 + cd34bab, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
- ✅ Produktions-Verifikation (curl): /api/v1/miniapps?host=dashboard liefert **17 Apps, 11 renderable** — alle 5 neuen Apps live mit component und Permission (automation_status nach Fix: comp=YES, perm=automation:read)
## Phase M4 — System-Rückbau (2026-08-30) ✅
**Spec:** [#362](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/362) | **Roadmap:** Phase M, M4
**Umgesetzt:**
- `app/core/system_miniapps.py`: Core-eigene MiniApps — `audit_activity` (Aktivitäten, audit:read, settings_schema max_items 1-50, Standard 10) + `system_metrics` (DB/Redis/Worker/API, settings:read; Endpoint /system/dashboard bleibt require_admin, Widget zeigt ohne Admin-Rechte kompakten Hinweis). Registrierung im lifespan-Startup (main.py), unabhängig von Plugin-Aktivierung.
- **base.py-Fix (M1-Lücke):** native Manifest-MiniApps reichen jetzt `component` an die Registry durch (vorher nur der dashboard_widgets-Alias — Ursache, warum Chat-Apps kein component hatten).
- **contacts-Manifest:** `contacts_stats` als native MiniApp (ContactsStatsWidget, contacts:read, settings: show_companies/show_persons) — Nachfolger der StatCards.
- **Seed-Fix (routes/dashboards.py):** Dashboard-Seed platziert nur renderbare Apps (component vorhanden) — Chat-Interaktions-Apps ohne Frontend-Component bleiben aus Layouts raus (Produktions-Messung M2: 9 Widgets, nur 3 renderbar → jetzt gefiltert).
- **Frontend-Widgets:** ContactsStatsWidget (Firmen-/Personen-Zähler), AuditActivityWidget (ActivityFeed-Nachfolger, max_items), SystemMetricsWidget (DB/Redis/Worker/API-Karten) — alle mit WidgetComponentProps (settings). MiniAppHost-Registry +3.
- `Dashboard.tsx` ist reiner Host (26 Z.): keine hardcodierten Inhalte mehr — StatCards/ActivityFeed/SystemMetrics existieren ausschließlich als persönliche MiniApp-Instanzen.
- i18n: systemMetricsNoAccess (de/en). M2-Seed-Tests auf renderbare Apps umgestellt (neue Seed-Spezifikation).
**Verifiziert (2026-08-30):**
- TDD: Rot 7 errors → ✅ Grün **7/7** (tests/test_m4_system_miniapps.py: System-App-Definitionen 4, API-Permission-Filter 2, Seed-component-Filter 1)
- ✅ Backend-Regression: M4 + M2 (angepasst) + MiniApp-Registry **46/46** — base.py-Fix und Seed-Änderung brechen keine Bestandstests
- ✅ Frontend: Vitest **22/22** (Builder 13, Page-Pure-Host 4 neu geschrieben, i18n 5); `npx tsc --noEmit` clean; Production-Build OK
- ✅ Deploy (3c496f4, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
- ✅ Produktions-Verifikation (curl): /api/v1/miniapps?host=dashboard liefert **12 Apps, 6 renderable** (vorher 3) — contacts_stats/audit_activity/system_metrics live mit component; Chat-Apps korrekt comp=NONE
## Phase M3 — Dashboard-Builder-Frontend (2026-08-30) ✅
**Spec:** [#361](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/361) | **Roadmap:** Phase M, M3
**Umgesetzt:**
- `api/miniapps.ts` + `api/dashboards.ts`: TanStack-Query-Hooks (M2-Backend + M1-Registry, Query-Keys + Invalidation nach documents.ts-Muster); `renderableDashboardApps()` filtert Apps ohne component (6 der 9 Bestands-Apps sind Chat-Interaktions-Apps ohne Frontend-Component).
- `MiniAppHost.tsx`: ersetzt DashboardWidgetLoader (P2-F17-Erbe) — Lazy-Component-Registry + settings-Props an Widget-Komponenten; Apps ohne Component renderen render_schema-Karte (MiniAppBlock-Präzedenz).
- `DashboardBuilder.tsx` (605 Z.): View/Edit-Modus-Schalter; @dnd-kit-Sortable-Grid (rectSortingStrategy) mit 12-Spalten-Flow-Repositionierung (identisch zum Server-Seed); Palette (permission-gefiltert via /api/v1/miniapps?host=dashboard, nur renderbare Apps); Resize (col/row ±, geclamped 1-12); Tab-Verwaltung (Add/Remove/Rename, min 1); Dashboard-CRUD + Set-Default; Dirty-Check (Save disabled bei unverändertem Layout); Settings-Modal pro Widget.
- `WidgetSettingsForm.tsx`: generisches Form aus settings_schema (text/number/boolean/select, MiniAppField-Typ).
- Bestands-Widgets auf settings-Props umgestellt (RecentContacts nutzt settings.limit, geclamped 1-50; Tasks/Calendar kompatibel optional).
- `Dashboard.tsx`: Builder als Hauptinhalt (reiner Host-Pattern); StatCards/SystemMetrics/ActivityFeed bleiben sichtbar bis M4-Rückbau (kein Funktionsverlust).
- Legacy `DashboardGrid.tsx` + `DashboardWidgetLoader.tsx` gelöscht; Geister-Test ersetzt (§10: UI-Änderung → Test-Nachzug).
- i18n: dashboard.builder.* 22 Keys (de+en).
**Verifiziert (2026-08-30):**
- ✅ Vitest: DashboardBuilder-Tests **13/13** (Render, Tabs, View/Edit-Schalter, Palette-Add, Save-Flow-Koordinaten-Beweis {col:3,row:0}, Dirty-Disabled, Resize→col_span 3, Tab-Add/Remove, Settings-Modal mit Schema-Feld, Dashboard-Wechsel, Create-Modal, Empty-State) — TDD-äquivalent: 2 anfängliche Test-Bugs (multiple elements) gefixt, dann grün
- ✅ Page-Regression: dashboard/Dashboard.test.tsx **11/11** (Builder-Mocks ergänzt); i18n-Test grün (de/en)
-`npx tsc --noEmit` clean; Production-Build **OK** (2.98s)
- ⚠️ Sidebar-Test-Run: Worker-OOM (0 Tests ausgeführt) = PROGRESS.md Finding #3 Vorbestand (identisch bei AppShell-Runs vor M3)
- ✅ Frontend-Deploy (26948fd, ~20s): neuer Bundle live (index-BGuPWW7I.js), Health healthy, Login 200; /api/v1/miniapps?host=dashboard liefert 3 renderbare Apps von 9 (component-Filter greift: 6 Chat-Interaktions-Apps ohne Frontend-Component)
## Phase M2 — Dashboard-Backend (2026-08-30) ✅
**Spec:** [#360](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/360) | **Roadmap:** Phase M, M2
**Umgesetzt:**
- `app/models/dashboard.py`: `dashboards`-Tabelle (persönlich, saved_views-Präzedenz: user_id NOT NULL CASCADE, TenantMixin, kein OwnedMixin). Layout JSONB, `is_default`, partial unique index (tenant, user, name) WHERE deleted_at IS NULL — soft-deleted Boards geben Namen frei (Verbesserung ggü. saved_views-Wart).
- `app/schemas/dashboard.py`: DashboardLayout/Tab/Widget (12-Spalten-Grid: col/row ≥ 0, Spans 1-12) → 422 auf invalide Layouts, bevor persistiert wird.
- `app/routes/dashboards.py` (313 Z.): 6 Endpoints — GET (Liste + lazy Seed), POST (409 dup, erstes = default, ein leerer Start-Tab), GET/{id}, PUT/{id} (Name/Layout, db.refresh gegen MissingGreenlet), DELETE/{id} (Soft-Delete, Default-Promotion), POST/{id}/set-default (exakt ein Default). Owner-only (tenant + user_id Filter, fremde = 404), Audit-Log bei allen Mutationen.
- Lazy Default-Seed: erste GET-Abrufung erzeugt „Mein Dashboard“ aus MiniApp-Registry (permission-gefiltert via geteiltem `user_permits`, Registry-Order, 12-Spalten-Flow mit Wrap).
- `user_permits()` in miniapp_registry.py als geteilter Fail-Closed-Filter (miniapps.py behält `_user_permits`-Alias).
- CORE_PERMISSIONS: `dashboard:read`/`dashboard:write`**fixt Phantom-Permission** (app/routes/dashboard.py verlangte dashboard:read, nirgends registriert → Nicht-Admins konnten sie nie erhalten).
- Migration `0144_personal_dashboards.py`: dashboards-Tabelle + RLS im 0090-Muster (**crm_api + crm_worker**) + **konvergenter Fix der 3 Phase-L-Policies** (letterheads/print_templates/document_assets waren `TO crm_api`-only — live auf Produktion gemessen, s. Verifikation). Plugin-SQL 0003 ebenfalls auf beide Rollen korrigiert.
- Doku: api-documentation.md (neue Core-Section dashboards, 6 Endpoints).
**Verifiziert (2026-08-30):**
- TDD: Rot 21 failed/1 passed → ✅ Grün **23/23** (tests/test_dashboards_backend.py: Model/Permission-Unit 3, Layout-Validation 5, CRUD 9, Ownership/Isolation 4, RLS-Konvergenz 2)
- ✅ Live-Messung (psql): Produktion vor Fix — 3 Policies `{crm_api}`-only (letterheads, print_templates, document_assets); lokal nach 0144 — alle 4 Tabellen `{crm_api,crm_worker}`
- ✅ Regression: rls_coverage + miniapp_registry + dashboard + lifecycle + route_order 37/38 — 1 Failure (test_dashboard cross-tenant, POST /companies 405) = **per Stash bewiesener Vorbestand** (identischer Failure auf clean HEAD); solo 5/5 grün
- ✅ Regression Welle 2: rbac_comprehensive + arch_block_a **125/125**
- ✅ ruff clean (alle M2-Dateien inkl. Testdatei); create_app OK (85 Router-Routen)
- ✅ Deploy (b3e259f, Full-Deploy): Health healthy, Alembic 0144, RLS 113 Tabellen
- ✅ Produktions-Verifikation (psql + curl, 2026-08-30): alle 4 Policies {crm_api,crm_worker}, 0 fehlende Rollen; GET /api/v1/dashboards liefert Lazy-Seed („Mein Dashboard", default, 1 Tab, 9 Widgets aus Registry)
## Phase N — Workspace-Scopes (2026-08-30 geplant, user-abgestimmt)
**User-Vision:** Workspaces als voll anpassbare Arbeitskontexte — jedes Modul pro Workspace auf Teilmengen einschränkbar (z.B. nur Kontakt-Ordner X+Y, nur DMS-Ordner "Angebote", nur Mail-Postfach vertrieb@, nur Kalender "Vertrieb"). Admin-definiert, für zugewiesene User-Gruppen.
**WICHTIG — Klarstellung Workspace ≠ Dashboard (user-korrigiert):** Zwei getrennte Systeme. Workspace = Admin-Kontext (WAS ist sichtbar/verfügbar, Gruppen-Feature). Dashboard = persönlich (WIE ICH mein Dashboard baue, Phase M). workspace_widgets bleibt Workspace-Eigentum (verfügbare Widget-TYPEN), dashboards-Tabelle (Phase M2) bleibt User-Eigentum (persönliches Layout). Kein Überbau, keine Vermischung.
**Status:** not_started — Phase N (N1-N4) in PLATFORM_ROADMAP.md verankert. 0 Umbau nötig: Speicher (workspace_modules.config JSONB), Transport (X-Workspace-ID-Interceptor), Context-Endpoint und Sidebar-Consumer existieren bereits; N3/N4 = additive Scope-Anwendung in Modul-Listen (kein Refactoring).
**Security-Invariante:** Scope = reine UND-Einschränkung (Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions). Workspace kann NIE mehr sichtbar machen, nur weniger. Ohne Workspace = kein Filter (rückwärtskompatibel).
## Phase P — Notizen-App (Notion-artig, ersetzt Wiki) (2026-08-30 geplant, user-abgestimmt)
**User-Entscheidung:** Wiki wird komplett ersetzt durch Notion-artige Notizen-/Firmen-Wissen-App. Keine Legacy-App, keine Notion-Datenbanken erstmal — MiniApp-Blöcke stattdessen. Quer-Verweise + vollständige Such-Indexierung Pflicht. Edit-Konzept: Live-Inline-Editing wie Notion (kein Mode-Toggle, Auto-Save), Lese-Modus entsteht über Permissions + optional Page-Lock.
**Status:** not_started — Phase P (P1-P5) in PLATFORM_ROADMAP.md verankert. P1-P3+P5 unabhängig startbar; P4 braucht M1 (MiniApp-Registry).
## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅
**Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate.
**Fix (b1a7551):** hardcodedNavItems auf 7 echte Core-Settings reduziert (stammdaten, user-management, system, custom-fields, webhooks, workspaces, backup) — Plugin-Settings kommen ausschließlich via pluginNavItems. Path-Dedup bleibt als Sicherheitsnetz.
**Dashboard-Verify (Kritikpunkt 20a):** Dashboard.tsx lädt Widgets bereits dynamisch via `useDashboardWidgets()` API (Manifest-Contributions von calendar/contacts/tasks) → DashboardWidgetLoader ist nur der Vite-Code-Splitting-Renderer, **keine fachliche Doppelquelle** — Kritikpunkt 20a teilweise widerlegt.
**Verifikation:** Vitest 90/90 (13 Dateien inkl. settings + dialog + permissions) · tsc exit 0 · frontend-only Deploy FE_EXIT=0
## Suite-Isolation (2026-08-28) ✅
**Mechanismus (Live-Messung):** `close_engine()` in der rbac `mail_app`-Fixture disposiert UND setzt alle globalen Engines auf None — 12 ACL-Batch-Failures (`relation "users" does not exist`) in Nachfolger-Suiten.
**Fix (b691dd3):** `reset_engine_for_testing(engine)` nach `close_engine()` im Teardown — conftest-Engine wird als globale Engine wiederhergestellt (Produktions-Bootstrap-Spiegelung).
**Verifikation:** ACL-Batch vorher 12 failed/118 passed → nachher **130 passed** (alle 12 behoben).
**Nur tests/ geändert — kein Production-Deploy nötig.**
## W4a — Import/Export Contribution-Architektur (2026-08-27, SPECS final)
**Spec:** [#359](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/359) (user-abgestimmt)
**Kernentscheidungen:** Zentraler Dialog (Modal lg/xl) geöffnet per Toolbar-Button in jeder Modulliste, deren Plugin Import/Export anbietet; /import-export-Seite wird zur Übersicht aller Angebote (Variante b). Formate als Plugins (CSV/JSON/XLSX bundled; PDF später separat). Module = Contributors via Contract (`importexport_meta`); Core = Orchestrator + Sicherheits-Policy-Layer (Sensitive-Filter, Tenant-Scoping, Audit unabhängig vom Modul erzwungen). Modul-native Formate (ICS/EML/ZIP) registrieren sich nur anzeigend.
**Abgelöst:** `export_service.py` (78 Z., CSV-only, alter /export-Endpoint) + contact-spezifische Logik in `import_export_service.py` (504 Z.) — der bewiesene Doppel-Weg wird konsolidiert.
### Phase 1 — Backend-Kern ✅ (cd8ef75, deployed 21:14)
- Format-Registry `app/core/importexport_registry.py` (FormatHandler-Protokoll, `available_for()` Schnittmenge)
- `importexport_formats`-Plugin (csv/json/xlsx), lifecycle-korrekt (on_activate registriert, on_deactivate entfernt)
- ContactsContract `importexport_entities()` + `ie_*`-Methoden (Columns, Validatoren, Normalizer, Fetch, Persist mit Audit)
- `import_export_service.py` generische Engine — iteriert über `registry.list_discovered()`, keine hartcodierten Plugin-Namen
- **Funktionserhalt: 45/45 import_export-Suite passed** (inkl. Fehler-Multiplizität: 2 failed rows → 3 total_errors via ie_required + ie_row_valid-Trennung; Zwischenstand mit 2 Failures live gefangen und korrigiert)
### Phase 2 — Frontend-Dialog ✅ (38df597, deployed 21:34)
- `ImportExportDialog.tsx` (neu): Modal lg/xl, Export-Tab (1 Schritt) + Import-Tab (4 Schritte: Datei → Mapping-Editor → Dry-Run → Ausführung+Report inkl. Background-Job-Polling)
- 24 `importexport.*`-i18n-Keys (de + en, keine hardcoded Strings)
- ContactsList: Toolbar-Button (contacts:read-Gate, Upload-Icon, entityType vorgewählt) über pluginToolbarStore
- **Verifikation: Vitest 12/12** (importExportDialog 6/6 + routePermissions 6/6) · tsc exit 0 · Production-Build exit 0 · 6 contacts/shell-Failures per Stash-Test als Vorbestand bewiesen (identisch auf clean HEAD) · frontend-only Deploy FE_EXIT=0
### Phase 3 — PDF (separat, offen)
PDF als weiteres Format-Plugin — eigener Design-Baustein (Library-Choice, Templates pro Modul).
## Welle 3a — Route-Permission-Wahrheit (2026-08-27)
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|---|---|---|---|
| **Phantom-Permission:** `/communication` prüfte `communication:read` — nirgends registriert (Plugin liefert nur `comm:*`) → Nicht-Admins mit gültigem comm:read bekamen die Seite nie | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `comm:read` (Backend+Manifest-Wahrheit) | Vitest routePermissions.test.ts **6/6 passed** (Source-Inspektion: 5 Route-Korrekturen + Phantom-Nachweis) |
| `/mail/settings` prüfte zu schwaches `mail:read` — Backend verlangt `mail:config` | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `mail:config` | dito |
| `/import-export` prüfte `contacts:read` — Backend (Core-Modul `app/routes/import_export.py`) verlangt `import_export:read`; Kritik-Aussage „import_export-Plugin" widerlegt (Existenz geprüft: keins) | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `import_export:read` (in CORE_PERMISSIONS registriert, Zeile 59) | dito |
| `/activity` prüfte Phantom `activity:read` (nirgends registriert, kein Backend-Nutzer); Seite nutzt Audit-API | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `audit:read` | dito |
| `/wiki` ungeschützt im statischen Router — Backend verlangt `wiki:read` | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `wiki:read` | dito |
**Gates (Kritikpunkt 21 erfüllt):** Vitest 6/6 · tsc --noEmit exit 0 · **Production-Build exit 0 (vor Commit)** · frontend-only Deploy FE_EXIT=0
## Welle 2b — Contacts-Entity-Registry Single-Source (2026-08-27)
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|---|---|---|---|
| Doppelquelle: statische `contact/contacts/company`-Einträge in `ENTITY_MODELS` neben identischer dynamischer Lieferung via `ContactsPlugin.get_entity_models()` (Kritikpunkte 911) | [#357](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/357) | 3 statische Einträge entfernt — ContactsPlugin ist Single Source; conftest spiegelt Produktions-Bootstrap idempotent (autouse-Fixture); `contact_folder` etc. bleiben korrekt (echte Core-Entities, von keinem Plugin geliefert) | Regressionstests `tests/test_contacts_entity_registry.py` **3/3 passed** (Source-Inspektion + Plugin-Lieferung + Bootstrap); entity_permissions + cross_tenant_security Suiten grün; ruff 0 Fehler; create_app OK |
| VORBESTAND bewiesen: 12 ACL-Batch-Failures durch Suite-Isolation (`relation "users" does not exist` in Nachfolger-Suiten) | [#357](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/357) (Body) | Kein Fix in diesem Commit — Stash-Test: identische 12 Failures auf clean HEAD (118 passed) vs. mit Fix (121 passed, nur +3 neue Tests) | Separates Isolation-Bugfix-Paket nötig |
**Gates:** ruff modified-files 0 · pytest entity_registry 3/3 + ACL-Funktionserhalt grün · create_app OK
## Welle 2 — Cross-Plugin/DSAR-Fix (2026-08-27)
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|---|---|---|---|
| 4 Core→Plugin-Imports in `core/jobs.py` DSAR-Sammlung | [#356](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/356) | Schritt 1 (092c2d2): Imports auf `get_contract()` umgestellt | Checker 4→0; DSAR-Suite 4/4 |
| **Vertiefung nach Review-Einspruch:** Plugin-Fachlogik (welche Kategorien, welche Felder, Limits) lag weiterhin hart im Core — Core kannte Plugin-Details | [#356](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/356) | **Komplette Extraktion:** `dsar_collect()`/`dsar_erase()` in die 5 beteiligten Contracts (contacts, mail, tasks, calendar, kommunikation); `core/jobs.py` sammelt/löscht nur Core-eigene Daten (Profil, Audit, Notifications, User-Anonymisierung) und iteriert generisch über `registry.list_discovered()` → Contract-DSAR-Beiträge. **Neue Plugins liefern DSAR-Kategorien ohne Core-Änderung.** | Checker **0 Verstöße**; DSAR-Suite **4/4 passed** (Counts-/Category-Keys unverändert: `contacts`, `contacts_soft_deleted` etc. via Contracts); `create_app()` OK; ruff nur 2 Vorbestand-N811; korruptes tasks/contracts.py (Patch-Artefakt, ast-gefangen) sauber neu geschrieben |
| P16 manuell klassifiziert: `app.models.contact`-Imports in core/jobs.py + worker.py sind KEINE Verstöße (Contact liegt im Core-Models-Layer) | — | Keine Aktion nötig, dokumentiert in #356 | Checker-Regex deckt nur `app.plugins.*` ab — korrekt so |
**Gates:** ruff modified-files grün (2 Vorbestand N811 ausgenommen) · Cross-Plugin-Checker 0 · pytest DSAR 4/4 · create_app OK
## Welle 1 — Plugin-Lifecycle-Fix (2026-08-27)
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|---|---|---|---|
| P1: `was_already_active`/`was_already_inactive` wurden in `plugin_service.activate/deactivate_plugin()` aus dem Record NACH dem Registry-Aufruf berechnet → konstant falsch → Runtime-Deregistrierung beim Deactivate war toter Code; Activate-Zweig lief nie | [#355](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/355) | Vorher-Status wird VOR dem Registry-Aufruf gelesen (`_get_plugin_record`) und beide Zweige laufen jetzt wirklich | TDD: neuer echter Integrationstest `tests/test_plugin_lifecycle_service.py` (install→activate×2→deactivate×2→re-activate über PluginService, beweist Permissions×1, Gate-Eintrag, ENTITY_MODELS on/off) rot→grün; Regression 93 passed (nur bekannter #354-Vorbestand) |
| P3: `registry.activate()` synced Notification Types VOR dem Statusupdate → Types des frisch aktivierten Plugins fehlten | [#355](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/355) | `sync_notification_types()` hinter DB-Statusupdate+Flush verschoben | Beweis im selben Integrationstest: NotificationType existiert nach activate, entfernt nach deactivate |
**Gates:** ruff modified-files grün · pytest Lifecycle 2/2 + Regression 93 passed · Deploy folgt
## Legacy-Cleanup (2026-08-27)
| Aktion | Commit | Verifikation (Live-Messung) |
|---|---|---|
| Toter AI-Copilot-Legacy gelöscht (`ai_copilot.py` Routes+Service+Schema+Model+Geister-Test, schemas/__init__, OpenAPI-Tag) — Chat läuft seit Migration 0137 über kommunikation/comm_conversations | b50a933 | Router war nie gemountet; Prod-DB: `relation "ai_conversations" does not exist`; create_app OK 83 routes identisch auf clean HEAD (Stash-Beweis); ruff modified-files grün; pytest auth/plugins/marketplace 100 passed |
| Vorbestand-Failure dokumentiert: `test_contacts_lifecycle ..._without_contacts_special_case` erwartet >100 Backend-Routen, Architektur liefert 83 (Routen in Manifesten/Frontend-Router) | [#354](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/354) | Stash-Test auf ebf4b03: identischer Failure → Vorbestand bewiesen |
Bekannter Rest (aus 27-Punkte-Kritik gegengeprüft): Plugin-Lifecycle-Cleanup tot (P1), sync_notification_types-Reihenfolge (P3), manueller Lifecycle-Test (P2), Cross-Plugin 4 Verstöße core/jobs.py + versteckte app.models.contact-Imports (P15/P16), Sidebar-Sonderdrahtung contacts (P10), statische ENTITY_MODELS (P11), saved_views/filters contacts:read-Pin (P13), Settings-Dedupe (P19), Dashboard hartes Widget-Map (P20a). Welle 1 (Lifecycle+echter Integrationstest) startet als nächstes.
> **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
---
@@ -119,10 +657,15 @@
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
| I-G-Rest | i18n-Restbestand: ~461 JSX-Text-/Attribut-Strings in 104 Dateien ohne t() (Scan über src/**/*.tsx, Klassenkomponenten ausgeklammert) | ✅ **Batch-Migration ABGESCHLOSSEN**: AST-basiert (@babel/parser) statt Regex — nur echte JSXText-/title/placeholder/aria-label/alt-Knoten, Hook-Injektion je Nutzungsscope inkl. Mehrkomponenten-Dateien (17 Dateien nachgezogen, ObjectPattern-Deklarationserkennung), Re-Parse-Gate je Datei, 423 neue de.json-Keys (Fallback en→de per fallbackLng). Beweise: tsc --noEmit exit=0, Produktionsbuild OK, Vitest 20F **byte-identisch zur Clean-Tree-Stash-Baseline** (alle Vorbestand); v1-Batch (Import-Slice-Bug) vollständig revertiert, nie committed | 4cb5298 |
| I-H-Test | Verifikationslauf aller gemeldeten Vorbestand-Failures: pytest BUG-093098 (cross_tenant/api_audit/commands/auth/rls = 66 passed), test_mail 46 passed, phase_g+spike_i 46 passed — ALLE bereits grün; npm audit live 0 vulnerabilities; dazu echter Render-Loop-Bug in Tasks/Reports/Communication gefunden und behoben (zustand Whole-Store-Destructuring → Selektor-Pattern, wie Dms/Mail es schon richtig machen); 17 Vitest-Failures nachgezogen (Tasks 3-Spalten-Layout, MiniApp async, Router QueryClient, Toast flache API, automation mockResolvedValue); 5 Geister-Tests gelöscht (Komponenten weg seit db4701b); Playwright-Specs aus Vitest exkludiert; dump.rdb-Hygiene (G3). Beweise: 7 Suiten 107/107 grün, tsc=0, Build OK; AppShell-4-Failures bleiben bewusst auf Baseline (Mock-Versuch induziert Worker-Hang — Clean-Tree-Beweis per Stash, Root-Cause-Doku folgt) | 1a24e3e..cfb2bfe |
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | ✅ **Pilot ABGESCHLOSSEN**: mail/services.py 3087→~170 Z. (**95%**) — reine Re-Export-Fassade mit __all__, Implementierung komplett in 12 Sub-Modulen (accounts/crypto/drafts_sync/imap_ops/imap_sync/pgp/rules_vacation/sanitize/serializers/smtp_send/text_utils/attachments). Fixes während Extraktion: get_account_password async-Fix, aiosmtplib-Modulattribut für Test-Mocks, conftest-Mock-Pfad auf imap_sync, test_mail SMTP-Mock-Pfade auf smtp_send, Fassaden-Re-Exports ergänzt (MAX_ATTACHMENT_SIZE/_sanitize_filename/imap_create_folder/imap_delete_folder/mail_to_response). Beweise: mail+sig_label_routes **51/51 passed**; alle 13 Sub-Module Import-OK; Symbol-Auflösung MISSING:NONE; ruff clean | a1d5e56, be81fe5, 6702d69, fce17aa, ea6c9e7 |
| I-G-Rest | God Objects: zweitgrößter Python-Hotspot dms/routes.py (1492 Z., 24 Routen) | ✅ **Split ABGESCHLOSSEN**: routes.py 1492→650 Z. (**56%**) — neu: common.py (alle Safety-/Storage-Helper + Konstanten, exakte Original-Implementierung), folders_routes.py / sharing_routes.py / search_bulk_routes.py je eigener prefix-loser Router; routes.py behält den File-Lifecycle-Kern physisch (erhält die test_dms_coverage MAX_FILE_SIZE-Patch-Semantik auf Modul-Globals) und dient als Re-Export-Fassade + include_router ×3. Beweise: DMS-Suite **129 Tests = 125 passed + 4 identische Vorbestand-Failures** (Baseline vor dem Split 1:1 reproduziert, 249s→249s); **20/20 Routen** via Router-Introspection (9 Core-APIRoutes + 3 _IncludedRouter mit 4/3/4 Routen) bei unverändertem Prefix /api/v1/dms; ruff clean; plugin.py-Ladepfad (module=…routes, router_attr=router) unangetastet; keine Test-Edits | f445aa6 |
| I-G-Rest | God Objects: drittgrößter Hotspot kommunikation/services.py (1364 Z., 28 Funktionen) | ✅ **Split ABGESCHLOSSEN**: services.py → Re-Export-Fassade (~70 Z.) + 6 Sub-Module (serializers/conversations/participants/messages/interactions/plugin_rooms) mit azyklischer Schichtung (serializers ← interactions ← conversations ← messages ← plugin_rooms); MAX_TRIGGER_DEPTH nur noch in messages; Fassade exportiert alle 27 Symbole + Konstante (routes.py/contracts.py/test_notification_migration.py unverändert). Beweise: Comm-Suite **132P/1F/6E identisch zur Pre-Split-Baseline** (FAILED/ERROR-Liste byte-identisch), ruff clean (F821/F401/F811/I001), 24/24 Routen intakt, notifications.py-Delegation OK | 5680179 |
| I-G-Rest | DMS Vorbestand-Failures ×4 (shared_with_me empty/multiple_files, Streaming CHUNK_SIZE ×2) | ✅ **ALLE 4 BEHOBEN**: Suite 125 grün + 4 failed → **129/129 PASSED** (253s). (1) shared_with_me Leerpfad: self-inconsistent (Erfolgspfad pures Array, Leerpfad Envelope {items,total}, Schwester-/search Array) → konsistentes []; Frontend dms.ts Z.196 vertraegt beide Shapes. (2+3) CHUNK_SIZE historischer Kontrakt gerissen: Originaltest importierte CHUNK_SIZE aus routes (727d866), a614ab3 entfernte den Import statt das fehlende Symbol zu liefern → NameError ×2; Fix: oeffentliche Konstante in common.py + Re-Export + restaurierte Importzeile (keine Assertion angefasst). (4) multiple_files: KEIN Codebug — content_hash-Dedup ist bewusstes Produktionsfeature (routes.py Z.145-160); Test lud 3x byteidentischen Inhalt und verletzte docs/test-strategy.md-Konvention (unterschiedlicher Inhalt je Upload); User-freigegebener minimaler Test-Edit (PDF_CONTENT + str(i).encode()), Dedup bleibt vollstaendig aktiv | e025541, 84061fd |
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | Export erweitern auf die fehlenden Kategorien als Follow-up (Job-Helfer _dsar_collect_user_data ist der Erweiterungspunkt); Kernpflichten Art. 15/17 sind jetzt funktionsfähig | — |
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | Export auf 8 Kategorien erweitert (2d17746); zusätzlich Frontend-DSGR-UI nachgereicht: 4. ComplianceTab-SubTab 'DSGVO-Anfragen' mit Typ-Wahl Art.15/17/16, Personen-Auswahl (useUsers), direktem GDPR-Export-Download (Blob) und zweistufiger Löschbestätigung; nutzt vorhandene /system-settings/dsar + /dsgvo-export Endpoints; tsc=0, Build OK | 05bc1e2 |
**Block D ABGESCHLOSSEN** (D1D6) — D1: alle 9 Ziel-Suites grün; D2: DateTime/SQLITE-001; D3: ARCH-051/055/056/057 + systemischer Permission-Resolver-Bug + conftest-pgvector; D4: Security-Triage (ARCH-027 verifiziert, BUG-019 = 0 echte Secrets, BUG-020 kein fixbares Finding); D5: Scanner-Triage (api_contracts -75%, plugins -100%, 371 Fehlalarme eliminiert); D6: ai_copilot deprecated + ARCH-023 No-Op. Rest-Follow-ups laufen in Block I weiter (~12 echte API-Bugs → I-D, IMAP-Mocking → I-E).
@@ -144,14 +687,14 @@
- ~~~12 echte API-Bugs~~ ✅ GELÖST in I-D-1 bis I-D-4 (3e5f13f, 86c96f0, 5232361): ai/sessions ×5, policies ×4, mail ×4, notifications DELETE, agents/skills — je nach Befund tote Frontend-Ketten gelöscht oder fehlende Backend-Routen ergänzt.
### Handover-Hinweis für Nachfolge-Agent
- Reparaturplan: docs/fix-plan-v3.md — **Blöcke 0/H/A/B/C/D/E done**, Block I ~60% (A/B/C/D-Cluster done, E/F/G-Rest offen), Block F komplett offen, G1/G2 offen (kanonisch in BLOCK G nach I-F-Entdoppelung)
- Reparaturplan: docs/fix-plan-v3.md — **Blöcke 0/H/A/B/C/D/E/F/G done** (G1 inkl. Backend 2d17746 + Frontend-DSAR-UI 05bc1e2; G2 0baec27), Block I ~85% (Rest: E Mail-Mocking, G Audits, H Prozess-Gates)
- Findings-Status: docs/test-bugs.md (✅/⏳ je Finding)
- Verifikationsmuster: Stash-Test gegen Pre-Block-Commit für Vorbestands-Nachweis; Endpoint-Diff via OpenAPI-Snapshot; Cross-Plugin-Scan als Gate
- Test-DB: .env.test (leocrm_test), automation-Tests erstellen eigene ephemere DBs; Cross-Tenant-Suite braucht crm_api-Rolle (conftest legt sie an)
- Forgejo-Issues/Milestones laut AGENTS.md §9 noch NICHT angelegt — nur PROGRESS.md-Tracking
- Server-Admin-Follow-ups beim User: Credential-Rotation ×7, Actions-Runner + Branch-Protection, E2/E4/E5
**Offen gesamt:** Block I-Rest (D-API-Bugs, E Mail-Mocking+PluginLoader+BUG-099, G God Objects/i18n/Audits, H Prozess-Gates), Block G (G1 DSGVO ⚠️ KRITISCH, G2 Session-Revocation), Block F (F1 Rollback, F2 No-Touch, F3 Guide ⚠️ Pflicht).
**Offen gesamt:** Block I-Reste (E Mail-Mocking, G verbleibende God Objects jenseits mail/dms/kommunikation, G Audits, H Prozess-Gates). Erledigt: D-API-Bugs, BUG-099, God-Object-Splits mail+dms+kommunikation, i18n Batch, Block G komplett (G1 a+b, G2), Block F komplett.
**Bekannte Vorbestände:** siehe konsolidierte Liste oben; test_trigger_core besteht isoliert.
---
@@ -447,3 +990,20 @@ Siehe `ENTERPRISE_READINESS_PLAN.md` für Details.
---
*Diese Datei wird vom Agent bei jedem Task-Status-Wechsel aktualisiert. Sie ist die schnelle Übersicht über den Fortschritt. Detaillierte Diskussion und Bug-Tracking laufen über Forgejo Issues.*
---
## Offene Findings (einzige gueltige Tracking-Sektion, Stand 2026-08-28)
> Ab hier gilt: Nur Findings mit Live-Messung vom selben Tag. Scanner-/Plan-Aussagen ohne Beweiszaehler zaehlen nicht.
| Finding | Verifiziert am | Messwert | Ort |
|---|---|---|---|
| Cross-Plugin Import Core→Plugin | ✅ **erledigt 2026-08-28** (ad5601e: DSAR auf Contracts umgestellt, Checker 4→0 gegen 482 Dateien) | 0 Verstöße | scripts/check_cross_plugin_imports.py |
| God Objects >500 Z. (real, Refactoring-Programm) | 2026-08-27 | 59 Dateien; Top: mail/routes.py 1950, mail/imap_sync.py 1148, self_improvement/services.py 1058, calendar/routes.py 1026, plugins/registry.py 907 | wc -l |
| Frontend-Vorbestand: 8 Test-Failures | ✅ **erledigt 2026-08-29** (Router ×2 per QueryClientProvider+Mocks 2/2 passed; AppShell-Mock existierte bereits, Plan-Eintrag veraltet; ContactEditModal = Geister-Test nach §10 gelöscht — Komponente weg seit db4701b) | Pakete 2+3, Commit 36dd7c5 | src/__tests__/shell/Router.test.tsx |
| Core-FK auf Plugin-Tabelle bricht `alembic check` | 2026-08-29 (Live-Messung: frische DB → upgrade head OK → `alembic check` NoReferencedTableError `entity_attachments.dms_file_id → files`; per Stash identisch auf clean HEAD = Vorbestand, kein Paket-6-Regression; event_outbox-Pendant im selben Lauf gefunden und FIX in 67c0dcd: models/__init__.py outbox-Import) | 1 verbleibender FK: entity_attachments.dms_file_id → files (DMS-Plugin-Tabelle); Metadata kennt `files` nur nach DMS-Plugin-Model-Import | app/models/entity_attachment.py + alembic/env.py (laedt nur app.models) |
| test_saved_filters 422-vs-400 | ✅ **gefixt 2026-08-28** | `_validate_entity_type` wirft jetzt 422 (FastAPI-Konvention), Test passed | app/routes/saved_filters.py + saved_views.py |
Erledigt und archiviert: BUG-006/012/015-Teile/021/022/025035/039/069070/075078/080082/093100, ARCH-004/006/007/019/024/028/045 — Details in docs/archive/.
+18
View File
@@ -3,6 +3,15 @@
from __future__ import annotations
import asyncio
# F19 (Astra P1): deterministic full-model discovery for Alembic.
# `from app.models import *` only loads CORE models (48 tables in a fresh
# process). Contact and ~80 other tables physically live in plugins
# (e.g. app.plugins.builtins.contacts.models) — the lazy package
# __getattr__ never fires for wildcard imports. Without the plugin models
# the metadata sort fails (contact_merge_history → contacts FK) and
# `alembic check` compares against an incomplete schema.
import importlib
from logging.config import fileConfig
from sqlalchemy import pool
@@ -13,6 +22,15 @@ from alembic import context
from app.config import get_settings
from app.core.db import Base
from app.models import * # noqa: F401,F403
from app.plugins.registry import get_registry
_registry = get_registry()
_registry.discover_builtins()
for _plugin_name in _registry.list_discovered():
try:
importlib.import_module(f"app.plugins.builtins.{_plugin_name}.models")
except ImportError:
pass # plugin has no models module
config = context.config
if config.config_file_name is not None:
@@ -0,0 +1,115 @@
"""Documents Generator tables (Phase L1): letterheads, print_templates,
document_assets.
Revision ID: 0143
Revises: 0142
Create Date: 2026-08-29
Dual-path convergence (Gate B): on plugin-first installs the report_generator
plugin migration 0003 has already created these tables — skip instead of
failing. Both paths converge to the identical schema (see
app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql).
"""
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from alembic import op
revision = "0143"
down_revision = "0142"
branch_labels = None
depends_on = None
def _table_exists(conn, table_name: str) -> bool:
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def _rls(table: str) -> None:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
f"FOR ALL TO crm_api "
f"USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) "
f"WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)"
)
def upgrade() -> None:
conn = op.get_bind()
if _table_exists(conn, "letterheads"):
return
op.create_table(
"letterheads",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("config", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=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("created_by", PGUUID(as_uuid=True), nullable=False),
)
op.create_index("ix_letterheads_tenant", "letterheads", ["tenant_id"])
op.create_index("ix_letterheads_name", "letterheads", ["name"])
op.create_table(
"print_templates",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="SET NULL"), nullable=True),
sa.Column("entity_type", sa.String(100), nullable=False, server_default="contact"),
sa.Column("blocks", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("output_format", sa.String(20), nullable=False, server_default="pdf"),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=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("created_by", PGUUID(as_uuid=True), nullable=False),
)
op.create_index("ix_print_templates_tenant", "print_templates", ["tenant_id"])
op.create_index("ix_print_templates_name", "print_templates", ["name"])
op.create_table(
"document_assets",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="CASCADE"), nullable=True),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("mime_type", sa.String(100), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("storage_path", sa.String(1024), nullable=False),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=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("created_by", PGUUID(as_uuid=True), nullable=False),
)
op.create_index("ix_document_assets_tenant", "document_assets", ["tenant_id"])
op.create_index("ix_document_assets_letterhead", "document_assets", ["letterhead_id"])
for table in ("letterheads", "print_templates", "document_assets"):
_rls(table)
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "letterheads"):
return
for table in ("document_assets", "print_templates", "letterheads"):
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.drop_table(table)
@@ -0,0 +1,111 @@
"""Personal dashboards table (Phase M2) + RLS policy-role convergence.
Revision ID: 0144
Revises: 0143
Create Date: 2026-08-30
Part 1 — dashboards: personal per-user dashboard layouts (JSONB tabs /
widgets). RLS follows the 0090 fail-closed pattern scoped to BOTH runtime
roles (crm_api, crm_worker).
Part 2 — convergence fix (measured live on production 2026-08-30):
migration 0143 created the letterheads/print_templates/document_assets
tenant-isolation policies with ``TO crm_api`` only, while the established
pattern (0090, verified by tests/test_rls_coverage.py) requires both
crm_api AND crm_worker. This migration recreates those policies with both
roles so both install paths (plugin-SQL 0003 / alembic 0143) converge.
"""
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from alembic import op
revision = "0144"
down_revision = "0143"
branch_labels = None
depends_on = None
_TENANT_USING = (
"tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid"
)
def _table_exists(conn, table_name: str) -> bool:
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def _create_policy(table: str) -> None:
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
f"FOR ALL TO crm_api, crm_worker "
f"USING ({_TENANT_USING}) "
f"WITH CHECK ({_TENANT_USING})"
)
def _rls(table: str) -> None:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
_create_policy(table)
def upgrade() -> None:
conn = op.get_bind()
# ── Part 1: dashboards table ──
if not _table_exists(conn, "dashboards"):
op.create_table(
"dashboards",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("layout", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index(
"uq_dashboards_tenant_user_name",
"dashboards",
["tenant_id", "user_id", "name"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_index("ix_dashboards_tenant_user", "dashboards", ["tenant_id", "user_id"])
_rls("dashboards")
else:
# Dual-path convergence: table exists (plugin SQL), ensure policy roles
_create_policy("dashboards")
# ── Part 2: converge Phase L policies to crm_api + crm_worker ──
for table in ("letterheads", "print_templates", "document_assets"):
if _table_exists(conn, table):
_create_policy(table)
def downgrade() -> None:
conn = op.get_bind()
# Revert the convergence fix to the (buggy) Phase L state first…
for table in ("letterheads", "print_templates", "document_assets"):
if _table_exists(conn, table):
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
f"FOR ALL TO crm_api "
f"USING ({_TENANT_USING}) "
f"WITH CHECK ({_TENANT_USING})"
)
if _table_exists(conn, "dashboards"):
op.execute("DROP POLICY IF EXISTS dashboards_tenant_isolation ON dashboards")
op.drop_index("ix_dashboards_tenant_user", table_name="dashboards")
op.drop_index("uq_dashboards_tenant_user_name", table_name="dashboards")
op.drop_table("dashboards")
@@ -0,0 +1,80 @@
"""Converged DELETE grants (F20/Astra).
Removes the effect of the blanket ``GRANT DELETE ON ALL TABLES`` that
prestart.sh applied on every boot — which silently undid migration
0100's protections on every container start.
Documented target state:
Runtime-legitimate DELETEs (crm_api only):
- users, user_tenants (user deletion on last membership, BUG-030)
- sessions (logout session invalidation)
- plugins, notification_types (plugin uninstall + registry sync)
Protected — DELETE stays REVOKED from crm_api AND crm_worker:
- audit_log (Astra acceptance: API/Worker write, never delete)
- api_tokens (revoke is an UPDATE on revoked_at)
- password_reset_tokens (consumption is an UPDATE on used_at)
- plugin_allowlist, plugin_migrations (install/migration path only —
plugin_migrations rows are deleted via the migration factory)
- tenants (never deleted at runtime)
- tenant_plugin_activation (deactivation is an UPDATE)
crm_worker receives no DELETE on any protected table (workers never
delete users, sessions or plugin rows).
Revision ID: 0145
Revises: 0144
"""
from alembic import op
revision = "0145"
down_revision = "0144"
branch_labels = None
depends_on = None
# Tables where runtime DELETE is a documented, legitimate operation (crm_api)
RUNTIME_DELETE_TABLES = [
"users",
"user_tenants",
"sessions",
"plugins",
"notification_types",
]
# Tables where DELETE must stay revoked from BOTH runtime roles (0100 + F20)
PROTECTED_TABLES = [
"audit_log",
"api_tokens",
"password_reset_tokens",
"plugin_allowlist",
"plugin_migrations",
"tenants",
"tenant_plugin_activation",
]
def upgrade() -> None:
# 1. Re-assert 0100's revocations — production DBs have lived with the
# blanket boot grant, so revoke first for a deterministic baseline.
for table in PROTECTED_TABLES:
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;")
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_worker;")
# 2. Grant the runtime-legitimate DELETEs to crm_api (BUG-030 stays
# fixed, logout keeps working, plugin management keeps working).
for table in RUNTIME_DELETE_TABLES:
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_api;")
def downgrade() -> None:
# Best-effort inverse: revoke the runtime grants, re-grant the
# protected tables (matching the pre-F20 blanket state).
for table in RUNTIME_DELETE_TABLES:
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;")
for table in PROTECTED_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,31 @@
"""Add resolved_by to approval_requests (F11/Astra).
Separates the assigned approver (approver_id — who the request was
addressed TO) from the actual decider (resolved_by — who decided).
Previously resolve_approval_request overwrote approver_id with the
acting user, destroying the assignment record.
Revision ID: 0146
Revises: 0145
"""
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from alembic import op
revision = "0146"
down_revision = "0145"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"approval_requests",
sa.Column("resolved_by", PGUUID(as_uuid=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("approval_requests", "resolved_by")
@@ -0,0 +1,50 @@
"""Disable RLS on api_tokens (F08 bootstrap fix, Astra S2).
verify_api_token() must look up the token hash via the request session
(crm_api) BEFORE any tenant context exists — the TOKEN is what determines
the tenant. Forced RLS with a tenant-isolation policy on api_tokens made
that lookup return zero rows, so EVERY Bearer token was rejected with 401
"token_invalid", including freshly created ones (verified live on
production 2026-09-18).
This restores the documented decision from migration 0080 ("written
during login before tenant context") which 0084 inadvertently overrode
by blindly re-enabling fail-closed RLS everywhere. sessions and
password_reset_tokens remain RLS-off for the same bootstrap reason.
Security unchanged: the SHA-256 token hash IS the access secret — a
lookup by hash cannot enumerate other tenants' tokens, and every use of
the row still goes through the authenticated verify path.
Revision ID: 0147
Revises: 0146
"""
from alembic import op
revision = "0147"
down_revision = "0146"
branch_labels = None
depends_on = None
POLICY_NAME = "api_tokens_tenant_isolation"
def upgrade() -> None:
# Remove the tenant-isolation policy first (it only covered the
# runtime roles anyway), then disable + unforce RLS.
op.execute(f"DROP POLICY IF EXISTS {POLICY_NAME} ON api_tokens;")
op.execute("ALTER TABLE api_tokens DISABLE ROW LEVEL SECURITY;")
op.execute("ALTER TABLE api_tokens NO FORCE ROW LEVEL SECURITY;")
def downgrade() -> None:
# Best-effort inverse: restore forced RLS + the previous policy.
op.execute("ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY;")
op.execute("ALTER TABLE api_tokens FORCE ROW LEVEL SECURITY;")
op.execute(
"CREATE POLICY api_tokens_tenant_isolation ON api_tokens "
"FOR ALL TO crm_api, crm_worker "
"USING (tenant_id = (NULLIF(current_setting('app.current_tenant_id', true), ''))::uuid)"
)
@@ -0,0 +1,41 @@
"""Add content_hash to plugin_migrations (F40/Astra).
The migration runner previously tracked migrations by FILENAME only —
editing an already-applied migration stayed unnoticed (the #389 bug
class: a broken migration was fixed, but the runner silently skipped
it because the filename was already tracked).
Now every applied migration records the SHA-256 of its SQL content.
On subsequent runs the runner compares hashes and logs a loud warning
when an applied migration was modified (repaired) — the skip stays
idempotent, but drift becomes VISIBLE.
Revision ID: 0148
Revises: 0147
"""
import sqlalchemy as sa
from alembic import op
revision = "0148"
down_revision = "0147"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"plugin_migrations",
sa.Column("content_hash", sa.String(64), nullable=True),
)
op.create_index(
"ix_plugin_migrations_content_hash",
"plugin_migrations",
["content_hash"],
)
def downgrade() -> None:
op.drop_index("ix_plugin_migrations_content_hash", table_name="plugin_migrations")
op.drop_column("plugin_migrations", "content_hash")
+148 -2
View File
@@ -122,6 +122,10 @@ async def _execute_tool(
"""Execute a single tool call via the registry.
Returns the tool result as a string, or an error message.
Note: access control (allowlist + required_permission) is enforced by
``_check_tool_access`` in ``run_react_loop`` BEFORE any execution path
(dry-run, approval, execute) is reached.
"""
tool = tool_registry.get(tool_name)
if tool is None:
@@ -135,6 +139,131 @@ async def _execute_tool(
return f"Error: {exc}"
def _filter_observation(observation: str) -> str:
"""F14 (Astra P1): sanitize a tool observation before it re-enters
the LLM conversation.
Tool responses are raw data (CRM records, mail payloads, settings) and
may contain sensitive fields (smtp_password, api keys, ...). The data
policy runs BEFORE the loop — observations arise INSIDE it and used to
reach the provider verbatim. Parse JSON observations and strip
sensitive fields (same SENSITIVE_FIELDS set as the data policy).
"""
stripped = observation.strip()
if not stripped.startswith(("{", "[")):
return observation
try:
import json
parsed = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
return observation
from app.core.sensitive_data import SENSITIVE_FIELDS
sensitive_names: set[str] = set()
for fields in SENSITIVE_FIELDS.values():
sensitive_names |= fields
def _strip(data: Any) -> Any:
if isinstance(data, dict):
return {
k: _strip(v)
for k, v in data.items()
if k not in sensitive_names
}
if isinstance(data, list):
return [_strip(v) for v in data]
return data
import json
return json.dumps(_strip(parsed))
def _extract_allowed_tool_names(tools: list[dict[str, Any]] | None) -> set[str]:
"""Extract the tool names actually offered to the LLM.
Always returns a set (possibly empty) — empty means no tools were
offered, so the caller fails closed on ANY tool call.
"""
if not tools:
return set()
names: set[str] = set()
for t in tools:
fn = (t or {}).get("function") or {}
name = fn.get("name")
if name:
names.add(str(name))
return names
def _normalize_permissions(ctx: dict[str, Any] | None) -> dict[str, Any]:
"""Normalize a user/agent context into the resolved-permissions shape
expected by ``check_permission`` (permissions / denied / is_system_admin).
Session user contexts carry ``denied_permissions`` while resolved
permission dicts use ``denied`` — both are accepted here.
"""
if not isinstance(ctx, dict):
return {"permissions": [], "denied": [], "is_system_admin": False}
return {
"permissions": list(ctx.get("permissions", []) or []),
"denied": list(ctx.get("denied", ctx.get("denied_permissions", [])) or []),
"is_system_admin": bool(ctx.get("is_system_admin", False)),
}
def _check_tool_access(
tool_registry: ToolRegistry,
tool_name: str,
allowed_tools: set[str] | None,
user_permissions: dict[str, Any] | None,
) -> str | None:
"""F01 guard: enforce allowlist + required_permission before execution.
Must run before EVERY execution path (dry-run, approval, execute).
Returns None when access is granted, otherwise an error observation.
Checks (fail-closed):
1. Allowlist — the tool must be among the schemas actually offered to
the LLM. A hallucinated/injected tool name never reaches a handler.
2. required_permission — when the tool declares one, the acting user's
CURRENT permissions must grant it. Without a permission context the
call is rejected (deny list first, system admin bypass).
"""
tool = tool_registry.get(tool_name)
if tool is None:
return None # "not found" is handled by _execute_tool
# 1. Allowlist: only tools offered to the LLM may run.
if allowed_tools is not None and tool_name not in allowed_tools:
logger.warning(
"F01 guard: tool '%s' is registered but NOT offered to this agent — rejected",
tool_name,
)
return f"Error: Tool '{tool_name}' is not available to this agent"
# 2. required_permission: enforce against the user's CURRENT permissions.
required = getattr(tool, "required_permission", None)
if isinstance(required, str) and required:
resolved = _normalize_permissions(user_permissions)
from app.core.permissions import check_permission
if not check_permission(resolved, required):
logger.warning(
"F01 guard: tool '%s' requires '%s' which the acting user lacks — rejected",
tool_name,
required,
)
return (
f"Error: Permission '{required}' required for tool '{tool_name}' "
"and not granted to the acting user"
)
return None
async def run_react_loop(
agent_definition: Any, # AgentDefinition from automation models
messages: list[dict[str, Any]],
@@ -151,6 +280,7 @@ async def run_react_loop(
dry_run: bool = False,
require_approval: bool = False,
approval_tools: list[str] | None = None,
user_permissions: dict[str, Any] | None = None,
) -> ReActResult:
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
@@ -197,8 +327,13 @@ async def run_react_loop(
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"db": db,
"agent_name": getattr(agent_definition, "name", "Agent"),
}
# F01 (Astra P0): allowlist — only tools actually offered to the LLM
# may ever execute. Empty set = no tools offered = every call rejected.
allowed_tool_names: set[str] = _extract_allowed_tool_names(tools)
# Audit helper — records every tool call in the audit log.
async def _audit_tool_call(
step_number: int,
@@ -365,7 +500,15 @@ async def run_react_loop(
args = {}
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"])
if dry_run:
# F01 (Astra P0): enforce allowlist + required_permission before
# every execution path (dry-run, approval, execute). Fail-closed:
# a hallucinated or injected tool name never reaches a handler.
guard_error = _check_tool_access(
tool_registry, tool_name, allowed_tool_names, user_permissions
)
if guard_error is not None:
observation = guard_error
elif dry_run:
observation = json.dumps(
{
"dry_run": True,
@@ -394,7 +537,7 @@ async def run_react_loop(
if agent_run_id:
try:
from app.plugins.builtins.contracts import get_contract_registry
komm = get_contract_registry().get("kommunikation")
komm = get_contract_registry().get_contract("kommunikation")
if komm:
agent_id = getattr(agent_definition, "id", uuid.uuid4())
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
@@ -442,6 +585,9 @@ async def run_react_loop(
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)
# F14 (Astra P1): tool responses are raw data — sanitize the
# observation before it re-enters the LLM conversation.
observation = _filter_observation(observation)
observations.append(observation)
# Audit every tool call (real or simulated)
+4 -1
View File
@@ -20,7 +20,8 @@ import asyncio
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any, AsyncGenerator
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from app.ai.agent_loop import ReActStep, run_react_loop
@@ -71,6 +72,7 @@ async def stream_react_loop(
max_steps: int = 20,
timeout_seconds: int = 300,
trace_id: str | None = None,
user_permissions: dict[str, Any] | None = None,
) -> AsyncGenerator[str, None]:
"""Run the ReAct loop and yield SSE-formatted events.
@@ -116,6 +118,7 @@ async def stream_react_loop(
timeout_seconds=timeout_seconds,
trace_id=trace_id,
on_step=on_step,
user_permissions=user_permissions, # F01: enforce at execution time
)
await queue.put(
_sse(
+46
View File
@@ -82,6 +82,14 @@ async def enforce_data_policy(
else c
for c in content
]
elif isinstance(content, str):
# F14 (Astra P1): JSON-serialized strings passed through
# UNFILTERED before — a payload like '{"smtp_password": ...}'
# reached the provider verbatim. Parse, filter, re-serialize;
# non-JSON strings stay unchanged (plain prose is fine).
content = _filter_json_string_content(
content, metadata, compliance, agent_definition
)
new_msg = dict(msg)
new_msg["content"] = content
filtered.append(new_msg)
@@ -89,6 +97,44 @@ async def enforce_data_policy(
return filtered
def _filter_json_string_content(
content: str,
metadata: AIUseCaseMetadata,
compliance: dict[str, Any] | None,
agent_definition: Any,
) -> str:
"""Filter a JSON-serialized string payload (F14).
Tries to parse the string as a JSON object/array and runs the SAME
dict-level filtering on it. Non-JSON strings are returned unchanged.
"""
stripped = content.strip()
if not stripped.startswith(("{", "[")):
return content
try:
import json
parsed = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
return content # not JSON — plain string content is not a leak vector
if isinstance(parsed, dict):
filtered = _filter_dict_content(parsed, metadata, compliance, agent_definition)
import json
return json.dumps(filtered)
if isinstance(parsed, list):
filtered = [
_filter_dict_content(item, metadata, compliance, agent_definition)
if isinstance(item, dict)
else item
for item in parsed
]
import json
return json.dumps(filtered)
return content
def _filter_dict_content(
data: dict[str, Any],
metadata: AIUseCaseMetadata,
+127
View File
@@ -0,0 +1,127 @@
"""Core AI agent tools for MiniApp output (Phase M6).
``send_miniapp`` lets an agent embed a MiniApp as an interactive output
block in its chat room (block_type "miniapp", approval_request precedent
from agent_loop). Permission is checked fail-closed against the CALLING
user for the target app's own permission — the tool never widens access.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.ai.tool_registry import get_tool_registry
from app.core.permissions import check_permission, resolve_permissions
from app.plugins.miniapp_registry import get_miniapp_registry
logger = logging.getLogger(__name__)
def _get_komm_contract() -> Any | None:
"""Resolve the kommunikation contract (None when plugin inactive)."""
from app.plugins.builtins.contracts import get_contract_registry
return get_contract_registry().get_contract("kommunikation")
async def _send_miniapp_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Send a MiniApp as an output block to the agent's chat room."""
app_id = str(arguments.get("app_id") or "")
settings = arguments.get("settings") or {}
if not isinstance(settings, dict):
settings = {}
app = get_miniapp_registry().get_app(app_id)
if app is None:
return f"Error: MiniApp '{app_id}' not found"
db = context.get("db")
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
if not tenant_id or not user_id or db is None:
return "Error: Missing tenant/user context"
try:
tenant_uuid = uuid.UUID(str(tenant_id))
user_uuid = uuid.UUID(str(user_id))
except (ValueError, TypeError):
return "Error: Invalid tenant/user context"
# Fail-closed permission check against the calling user
resolved = await resolve_permissions(db, user_uuid, tenant_uuid)
if app.permission and not check_permission(resolved, app.permission):
return f"Error: Permission '{app.permission}' required for MiniApp '{app.app_id}'"
komm = _get_komm_contract()
if komm is None:
return "Error: Communication plugin not available"
agent_name = str(context.get("agent_name") or "Agent")
conv_id = await komm.find_locked_room_id(
db=db,
tenant_id=tenant_uuid,
plugin_name="automation",
title=f"Agent: {agent_name}",
)
if conv_id is None:
return f"Info: No agent chat room found for '{agent_name}' — MiniApp not posted"
agent_id_raw = context.get("agent_id")
try:
sender_id = uuid.UUID(str(agent_id_raw)) if agent_id_raw else None
except (ValueError, TypeError):
sender_id = None
await komm.send_message(
db=db,
tenant_id=tenant_uuid,
conversation_id=conv_id,
sender_id=sender_id,
sender_type="agent",
content=f"MiniApp: {app.name}",
content_format="text",
blocks=[
{
"block_type": "miniapp",
"block_data": {"app_id": app_id, "config": settings},
"sort_order": 0,
}
],
)
return f"MiniApp '{app_id}' sent to chat"
# ─── Registration ───
def register_miniapp_tools() -> None:
"""Register the MiniApp agent tools in the global tool registry."""
registry = get_tool_registry()
registry.register(
name="send_miniapp",
description=(
"Eine MiniApp als interaktiven Ausgabe-Block in den Agent-Chat senden "
"(z.B. ein Widget mit Einstellungen anzeigen). Verfügbare App-IDs "
"stehen in /api/v1/miniapps."
),
parameters={
"type": "object",
"properties": {
"app_id": {
"type": "string",
"description": "ID der MiniApp (z.B. recent_contacts, tasks_summary)",
},
"settings": {
"type": "object",
"description": "Optionale Einstellungen für die MiniApp-Instanz",
},
},
"required": ["app_id"],
},
handler=_send_miniapp_handler,
plugin_name="system",
required_permission=None, # per-app check inside the handler (fail-closed)
category="ui",
)
+122 -9
View File
@@ -52,6 +52,12 @@ class ApprovalRequest(Base, TenantMixin):
PGUUID(as_uuid=True), nullable=True
)
approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True)
# F11: who actually decided — approver_id stays the ASSIGNMENT,
# resolved_by records the ACTUAL decider (previously the assignment
# was overwritten by whoever decided).
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
)
@@ -103,6 +109,41 @@ async def create_approval_request(
return req
class ApprovalDecisionError(Exception):
"""Raised when an approval decision is invalid (F11/Astra).
Attributes:
code: machine-readable reason for the HTTP layer.
http_status: suggested HTTP status code.
"""
def __init__(self, code: str, message: str, http_status: int = 403):
super().__init__(message)
self.code = code
self.http_status = http_status
async def _user_in_approver_group(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, group_name: str
) -> bool:
"""Check whether the user is a member of the named approver group."""
from sqlalchemy import select
from app.models.group import Group, UserGroup
result = await db.execute(
select(UserGroup.id)
.join(Group, UserGroup.group_id == Group.id)
.where(
UserGroup.user_id == user_id,
UserGroup.tenant_id == tenant_id,
Group.name == group_name,
)
.limit(1)
)
return result.first() is not None
async def resolve_approval_request(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -111,12 +152,31 @@ async def resolve_approval_request(
decision: str,
approver_id: uuid.UUID,
comment: str | None = None,
is_system_admin: bool = False,
) -> ApprovalRequest | None:
"""Approve or reject a pending approval request.
"""Approve or reject a pending approval request (F11 hardened).
Returns the updated request, or ``None`` if not found / not pending.
Returns the updated request, or ``None`` if not found.
Raises ApprovalDecisionError when the decision is invalid:
- ``expired`` (410): the request's expires_at has passed — it is
marked expired and can no longer be decided.
- ``not_pending`` (409): the request was already decided concurrently.
- ``wrong_approver``(403): the acting user is neither the assigned
approver (approver_id) nor a member of the assigned approver_group.
Unassigned requests (no approver_id AND no approver_group) may be
decided by anyone holding approvals:approve; system admins may
decide any request (documented operations override).
The assignment (approver_id) is NEVER overwritten — the actual decider
is recorded in resolved_by (F11: assignment and decider are separate).
"""
from sqlalchemy import select
from datetime import UTC, datetime
from sqlalchemy import select, update
if decision not in ("approved", "rejected"):
raise ValueError(f"invalid decision: {decision!r}")
result = await db.execute(
select(ApprovalRequest).where(
@@ -125,14 +185,67 @@ async def resolve_approval_request(
)
)
req = result.scalar_one_or_none()
if req is None or req.status != "pending":
if req is None:
return None
req.status = decision
req.approver_id = approver_id
req.comment = comment
req.resolved_at = datetime.now(UTC)
await db.flush()
# 1. Expiry check — an expired request can no longer be decided.
if (
req.status == "pending"
and req.expires_at is not None
and req.expires_at < datetime.now(UTC)
):
req.status = "expired"
req.resolved_at = datetime.now(UTC)
await db.flush()
raise ApprovalDecisionError(
"expired", "Approval request has expired", http_status=410
)
# 2. Approver check — who may decide this request?
if not is_system_admin:
assigned_user = req.approver_id
assigned_group = req.approver_group
allowed = False
if assigned_user is not None:
allowed = assigned_user == approver_id
if not allowed and assigned_group:
allowed = await _user_in_approver_group(
db, tenant_id, approver_id, assigned_group
)
if not allowed and assigned_user is None and assigned_group is None:
# Unassigned request: anyone with approvals:approve may decide.
allowed = True
if not allowed:
raise ApprovalDecisionError(
"wrong_approver",
"This approval request is assigned to a different approver",
http_status=403,
)
# 3. Atomic status transition — a concurrent decision must not win twice.
now = datetime.now(UTC)
upd = await db.execute(
update(ApprovalRequest)
.where(
ApprovalRequest.id == request_id,
ApprovalRequest.tenant_id == tenant_id,
ApprovalRequest.status == "pending",
)
.values(
status=decision,
resolved_by=approver_id,
comment=comment,
resolved_at=now,
)
)
if upd.rowcount == 0:
raise ApprovalDecisionError(
"not_pending",
"Approval request was already decided",
http_status=409,
)
await db.refresh(req)
return req
+49
View File
@@ -117,6 +117,55 @@ async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
return 0
async def revoke_user_sessions_all_stores(user_id: str | uuid.UUID) -> None:
"""F03 (Astra): revoke ALL sessions for a user in BOTH session stores.
Deactivation, deletion and password changes must take effect immediately —
including when Redis is down and requests fall back to the PostgreSQL
sessions table.
1. Redis runtime sessions are deleted (revoke_user_redis_sessions).
2. PostgreSQL session records are EXPIRED by setting ``expires_at = now()``
(not deleted — they stay as audit trail). The DB fallback path in
``get_session_data`` rejects sessions whose ``expires_at`` is past.
Never raises — best-effort per store, but errors are logged loudly.
"""
# 1. Redis runtime sessions
await revoke_user_redis_sessions(user_id)
# 2. PostgreSQL fallback sessions — expire instead of delete (audit trail)
try:
from datetime import UTC, datetime
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models.session import Session as SessionModel
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
factory = get_session_factory()
async with factory() as db:
result = await db.execute(
update(SessionModel)
.where(
SessionModel.user_id == uid,
SessionModel.expires_at > datetime.now(UTC),
)
.values(expires_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount:
logger.info(
"F03: expired %d PostgreSQL fallback sessions for user %s",
result.rowcount, uid,
)
except Exception:
logger.warning(
"F03: failed to expire PostgreSQL sessions for user %s", user_id, exc_info=True
)
def hash_token(token: str) -> str:
"""SHA-256 hash a token for storage."""
return hashlib.sha256(token.encode()).hexdigest()
+90
View File
@@ -0,0 +1,90 @@
"""Import/Export format registry — formats are provided by plugins.
A **format plugin** (e.g. ``importexport_formats``) registers handlers for
its supported file formats (csv, json, xlsx, ...). An **entity module**
(e.g. contacts) contributes via its contract which formats it supports and
provides the import/export logic for its data.
The core orchestrator (routes + background jobs) resolves the intersection:
capabilities(entity) = entity.formats ∩ format_registry.registered
The security policy layer (sensitive-data filter, tenant scoping, audit)
runs in the orchestrator, independently of the module contribution.
"""
from __future__ import annotations
import logging
from typing import Any, Protocol, runtime_checkable
logger = logging.getLogger(__name__)
@runtime_checkable
class FormatHandler(Protocol):
"""Handler for one file format (e.g. csv, json, xlsx).
Provided by a format plugin via the registry.
"""
format_id: str
@staticmethod
def parse(content: bytes) -> list[dict[str, Any]]:
"""Parse file content into a list of row dicts."""
...
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
"""Serialize rows into file bytes with the given column order."""
...
class ImportExportFormatRegistry:
"""Registry for file-format handlers contributed by format plugins."""
def __init__(self) -> None:
self._formats: dict[str, FormatHandler] = {}
def register(self, handler: FormatHandler) -> None:
"""Register (or replace) a format handler."""
self._formats[handler.format_id] = handler
logger.debug("Registered import/export format '%s'", handler.format_id)
def unregister(self, format_id: str) -> None:
self._formats.pop(format_id, None)
logger.debug("Unregistered import/export format '%s'", format_id)
def get(self, format_id: str) -> FormatHandler | None:
return self._formats.get(format_id)
def list_formats(self) -> list[str]:
return sorted(self._formats.keys())
def available_for(self, entity_formats: list[str]) -> list[str]:
"""Return the intersection of an entity's declared formats and
the currently registered (plugin-provided) format handlers."""
return sorted(set(entity_formats) & set(self._formats.keys()))
def clear(self) -> None:
"""Clear all registrations (testing only)."""
self._formats.clear()
# ─── module-level singleton ─────────────────────────────────────────────────
_registry: ImportExportFormatRegistry | None = None
def get_format_registry() -> ImportExportFormatRegistry:
global _registry
if _registry is None:
_registry = ImportExportFormatRegistry()
return _registry
def reset_format_registry_for_testing() -> ImportExportFormatRegistry:
global _registry
_registry = ImportExportFormatRegistry()
return _registry
+45 -139
View File
@@ -160,18 +160,21 @@ register_job("send_password_reset_email", send_password_reset_email)
async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict[str, Any]:
"""Collect every data category the dsgvo-export route promises.
Shared by type=access (full export) so both paths stay consistent.
Core collects ONLY core-owned data (profile, audit log, notifications).
Plugin-owned categories (contacts, mail accounts, tasks, calendar
entries, comm messages, ...) are contributed by each plugin's contract
via ``dsar_collect()`` — the core must not know plugin internals.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import or_ as sa_or_
from sqlalchemy import select as sa_select
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
from app.plugins.builtins.contracts import get_contract
from app.plugins.registry import get_registry
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
@@ -195,27 +198,6 @@ async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict
"created_at": user.created_at.isoformat() if user.created_at else None,
}
# Contacts owned by the user
contacts = (
await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
).scalars().all()
export_data["data"]["contacts"] = [
{
"id": str(c.id),
"type": c.type,
"displayname": c.displayname,
"email_1": c.email_1,
"email_2": c.email_2,
}
for c in contacts
]
# Audit trail entries by/about the user (bounded to keep payloads sane)
audit_entries = (
await db.execute(
@@ -253,104 +235,25 @@ async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict
for n in notifications
]
# ── Categories promised by the dsgvo-export route docstring ──
# (G1-b: mail accounts, tasks, calendar entries, comm messages)
try:
from app.plugins.builtins.mail.models import MailAccount
mail_accounts = (
await db.execute(
sa_select(MailAccount).where(
MailAccount.tenant_id == tid,
MailAccount.user_id == uid,
).limit(500)
# ── Plugin-owned categories via contracts ──
# For every discovered plugin, resolve its contract (lazy-load) and ask
# it to contribute its DSAR categories. Inactive/absent plugins simply
# contribute nothing — same semantics as the former per-plugin try/except.
registry = get_registry()
for plugin_name in registry.list_discovered():
contract = get_contract(plugin_name)
dsar_collect = getattr(contract, "dsar_collect", None) if contract else None
if dsar_collect is None:
continue
try:
categories = await dsar_collect(db, tid, uid)
export_data["data"].update(categories)
except Exception:
logger.warning(
"DSAR collect failed for plugin '%s' — category skipped",
plugin_name,
exc_info=True,
)
).scalars().all()
export_data["data"]["mail_accounts"] = [
{
"id": str(a.id),
"email_address": a.email_address,
"display_name": a.display_name,
"is_shared": a.is_shared,
"is_active": a.is_active,
}
for a in mail_accounts
]
except ImportError:
pass
try:
from app.plugins.builtins.tasks.models import Task as TaskModel
tasks = (
await db.execute(
sa_select(TaskModel).where(
sa_or_(TaskModel.owner_id == uid, TaskModel.assigned_to == uid),
TaskModel.tenant_id == tid,
TaskModel.deleted_at.is_(None),
).limit(1000)
)
).scalars().all()
export_data["data"]["tasks"] = [
{
"id": str(t.id),
"title": t.title,
"status": t.status,
"priority": t.priority,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks
]
except ImportError:
pass
try:
from app.plugins.builtins.calendar.models import CalendarEntry as CalEntry
cal_entries = (
await db.execute(
sa_select(CalEntry).where(
CalEntry.tenant_id == tid,
CalEntry.owner_id == uid,
CalEntry.deleted_at.is_(None),
).limit(1000)
)
).scalars().all()
export_data["data"]["calendar_entries"] = [
{
"id": str(e.id),
"title": e.title,
"entry_type": e.entry_type,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
}
for e in cal_entries
]
except ImportError:
pass
try:
from app.plugins.builtins.kommunikation.models import CommMessage
comm_messages = (
await db.execute(
sa_select(CommMessage).where(
CommMessage.sender_id == uid,
CommMessage.tenant_id == tid,
).limit(1000)
)
).scalars().all()
export_data["data"]["comm_messages"] = [
{
"id": str(m.id),
"sender_type": m.sender_type,
"content": m.content[:500],
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in comm_messages
]
except ImportError:
pass
return export_data
@@ -359,42 +262,45 @@ async def _dsar_execute_deletion(db: Any, tenant_id: str, user_id: str) -> dict[
"""Execute GDPR Art. 17 erasure for a user within one tenant.
Strategy (respects retention duties):
- Contacts owned by the user → soft-delete via deleted_at
(audit history must remain intact — it is not personal data of the
subject but business record; retention policy governs its cleanup)
- Notifications owned by the user → hard delete
- Plugin-owned personal data (contacts, ...) → erased via each plugin's
contract ``dsar_erase()`` — the core must not know plugin internals
- Notifications owned by the user → hard delete (core-owned)
- User account → deactivate (is_active=False), clear personal fields,
scramble password hash and email (keeps FK integrity for audit rows)
Returns counters for the audit entry.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import select as sa_select
from sqlalchemy import update as sa_update
from app.core.audit import log_audit
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
from app.plugins.builtins.contracts import get_contract
from app.plugins.registry import get_registry
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
counts: dict[str, int] = {}
# 1. Soft-delete contacts owned by the user
contact_result = await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
contacts = contact_result.scalars().all()
for c in contacts:
c.deleted_at = datetime.now(UTC)
counts["contacts_soft_deleted"] = len(contacts)
# 1. Plugin-owned erasure via contracts (contacts, ...)
registry = get_registry()
for plugin_name in registry.list_discovered():
contract = get_contract(plugin_name)
dsar_erase = getattr(contract, "dsar_erase", None) if contract else None
if dsar_erase is None:
continue
try:
plugin_counts = await dsar_erase(db, tid, uid)
counts.update(plugin_counts)
except Exception:
logger.warning(
"DSAR erase failed for plugin '%s' — counters may be incomplete",
plugin_name,
exc_info=True,
)
# 2. Hard-delete notifications owned by the user
notif_result = await db.execute(
+17
View File
@@ -74,6 +74,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if request.headers.get("upgrade", "").lower() == "websocket":
return await call_next(request)
# Bearer-token requests are CSRF-immune by design: the Authorization
# header is never attached automatically by browsers, so cross-site
# requests cannot forge it. Exempts programmatic API clients
# (external agent API, MCP, integrations) from Origin+CSRF checks —
# they authenticate via get_current_user_bearer instead.
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
return await call_next(request)
# F09 (Astra P1): internal delegation calls carry a short-lived
# HMAC-signed X-Delegation-Token (created server-side by the
# CRM-API tool / MCP server, max 60 seconds) — CSRF-immune for the
# same reason as Bearer: browsers never attach this header to a
# cross-site request.
if request.headers.get("x-delegation-token"):
return await call_next(request)
if request.method in self.UNSAFE_METHODS:
# 1. Origin header check
origin = request.headers.get("origin")
+15 -1
View File
@@ -212,7 +212,21 @@ def _json_payload(payload: dict[str, Any]) -> str:
def _get_handler_name(handler: Any) -> str:
"""Extract a human-readable name from a handler callable."""
"""Extract a human-readable name from a handler callable.
For bound methods (plugin handlers are bound methods, e.g.
``AutomationPlugin.on_contact_created``) prefers ``__qualname__`` so
the consumer registry distinguishes handlers that share a method name
across plugins (automation/unified_search/system_notif all define
``on_contact_created`` three distinct handlers, same short name).
Plain functions keep their ``__name__`` (nested test functions have
verbose qualnames like ``test_x.<locals>.handler``).
"""
if hasattr(handler, "__self__"):
qualname = getattr(handler, "__qualname__", None)
if qualname:
return qualname
name = getattr(handler, "__name__", None)
if name:
return name
+35 -45
View File
@@ -64,9 +64,25 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
{"key": "approvals:read", "label": "Approvals: Read", "category": "core", "module": "approvals"},
{"key": "approvals:write", "label": "Approvals: Write", "category": "core", "module": "approvals"},
{"key": "approvals:approve", "label": "Approvals: Approve/Reject", "category": "core", "module": "approvals"},
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
{"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"},
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
# Audit P1 (permission catalog): these keys were required by core routes
# but never registered, so non-admin roles could never be granted them.
{"key": "automation:admin", "label": "Automation: Admin (backups, self-improvement)", "category": "core", "module": "automation"},
{"key": "bank-accounts:read", "label": "Bank Accounts: Read", "category": "core", "module": "bank_accounts"},
{"key": "bank-accounts:write", "label": "Bank Accounts: Write", "category": "core", "module": "bank_accounts"},
{"key": "delegations:read", "label": "Delegations: Read", "category": "core", "module": "delegations"},
{"key": "delegations:write", "label": "Delegations: Write", "category": "core", "module": "delegations"},
{"key": "policies:read", "label": "Policies: Read", "category": "core", "module": "policies"},
{"key": "policies:write", "label": "Policies: Write", "category": "core", "module": "policies"},
{"key": "templates:read", "label": "Permission Templates: Read", "category": "core", "module": "templates"},
{"key": "templates:write", "label": "Permission Templates: Write", "category": "core", "module": "templates"},
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
# tags, entity_links, reports, search, mcp, permissions, agents, dashboard)
# tags, entity_links, reports, search, mcp, permissions, agents)
# are registered dynamically via register_plugin_permissions() from plugin
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
]
@@ -74,50 +90,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
# ── Core field definitions for field-level permissions ──
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
# ── Contact fields ──
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
{"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"},
{"module": "contacts", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "contacts", "field": "email_1", "label": "Email 1", "sensitivity": "normal"},
{"module": "contacts", "field": "email_2", "label": "Email 2", "sensitivity": "normal"},
{"module": "contacts", "field": "phone_1", "label": "Phone 1", "sensitivity": "normal"},
{"module": "contacts", "field": "phone_2", "label": "Phone 2", "sensitivity": "normal"},
{"module": "contacts", "field": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"},
{"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"},
{"module": "contacts", "field": "website", "label": "Website", "sensitivity": "normal"},
{"module": "contacts", "field": "status", "label": "Status", "sensitivity": "normal"},
{"module": "contacts", "field": "type", "label": "Type", "sensitivity": "normal"},
{"module": "contacts", "field": "gender", "label": "Gender", "sensitivity": "normal"},
{"module": "contacts", "field": "suffix", "label": "Suffix", "sensitivity": "normal"},
{"module": "contacts", "field": "ext_name_line", "label": "Extra Name Line", "sensitivity": "normal"},
{"module": "contacts", "field": "country", "label": "Country", "sensitivity": "normal"},
# ── Financial / sensitive fields ──
{"module": "contacts", "field": "code", "label": "Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "accounting_code", "label": "Accounting Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "vendor_accounting_code", "label": "Vendor Accounting Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "vat_code", "label": "VAT Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "fiscal_code", "label": "Fiscal Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "commerce_code", "label": "Commerce Code", "sensitivity": "sensitive"},
{"module": "contacts", "field": "purchase_number", "label": "Purchase Number", "sensitivity": "sensitive"},
{"module": "contacts", "field": "bic", "label": "BIC", "sensitivity": "sensitive"},
# ── Addresses ──
{"module": "contacts", "field": "mailing_street", "label": "Mailing Street", "sensitivity": "normal"},
{"module": "contacts", "field": "mailing_city", "label": "Mailing City", "sensitivity": "normal"},
{"module": "contacts", "field": "mailing_postalcode", "label": "Mailing Postal Code", "sensitivity": "normal"},
{"module": "contacts", "field": "mailing_country", "label": "Mailing Country", "sensitivity": "normal"},
{"module": "contacts", "field": "visit_street", "label": "Visit Street", "sensitivity": "normal"},
{"module": "contacts", "field": "visit_city", "label": "Visit City", "sensitivity": "normal"},
{"module": "contacts", "field": "visit_postalcode", "label": "Visit Postal Code", "sensitivity": "normal"},
{"module": "contacts", "field": "visit_country", "label": "Visit Country", "sensitivity": "normal"},
{"module": "contacts", "field": "invoice_street", "label": "Invoice Street", "sensitivity": "normal"},
{"module": "contacts", "field": "invoice_city", "label": "Invoice City", "sensitivity": "normal"},
{"module": "contacts", "field": "invoice_postalcode", "label": "Invoice Postal Code", "sensitivity": "normal"},
{"module": "contacts", "field": "invoice_country", "label": "Invoice Country", "sensitivity": "normal"},
# ── Notes & Tags ──
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
{"module": "contacts", "field": "tags", "label": "Tags", "sensitivity": "sensitive"},
# ── User fields ──
# Audit P1/P2 (contact field definitions): all contacts:* field
# definitions moved to the ContactsPlugin manifest (field_definitions=)
# so the plugin fully owns its field structure. The core keeps only
# genuinely core-owned fields (users). Plugin field definitions are
# registered at activation time via register_field_definitions().
# ── User fields (core-owned) ──
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
@@ -224,6 +202,18 @@ class PermissionRegistry:
self._field_definitions[plugin_name] = field_defs
logger.info("Registered %d field definitions for plugin '%s'", len(field_defs), plugin_name)
def unregister_field_definitions(self, plugin_name: str) -> None:
"""Remove field definitions of a deactivated/uninstalled plugin.
Audit P1/P2 (field-definitions lifecycle): the contribution type was
only half-integrated register_field_definitions() existed but no
matching unregister, so a deactivated plugin kept serving its field
definitions in the permission UI.
"""
removed = self._field_definitions.pop(plugin_name, None)
if removed is not None:
logger.info("Unregistered %d field definitions for plugin '%s'", len(removed), plugin_name)
def get_all_field_definitions(self) -> list[dict[str, str]]:
"""Return all registered field definitions."""
result = list(self._core_field_definitions)
+6 -1
View File
@@ -431,7 +431,12 @@ def check_permission(resolved: dict[str, Any], required: str) -> bool:
return True
permissions = set(resolved.get("permissions", []))
denied = set(resolved.get("denied", []))
# F01/Astra: session user contexts carry ``denied_permissions`` while
# resolved permission dicts use ``denied`` — accept both so the deny
# list is never silently ignored.
denied = set(
resolved.get("denied", resolved.get("denied_permissions", [])) or []
)
# Check deny list first
for d in denied:
+7 -4
View File
@@ -173,12 +173,15 @@ def _derive_policy_from_sensitivity(
if field_name in entity_policy:
return dict(entity_policy[field_name])
# Try to get sensitivity from permission registry (lazy import to avoid
# circular dependencies at module load time).
# Try to get sensitivity from the permission registry (lazy import to
# avoid circular dependencies at module load time). Use the registry's
# combined view (core + plugin field definitions) — contact fields moved
# to the ContactsPlugin manifest (audit P1/P2), so CORE_FIELD_DEFINITIONS
# alone no longer covers them.
try:
from app.core.permission_registry import CORE_FIELD_DEFINITIONS
from app.core.permission_registry import get_permission_registry
for fd in CORE_FIELD_DEFINITIONS:
for fd in get_permission_registry().get_all_field_definitions():
if fd.get("module") == entity_type and fd.get("field") == field_name:
sensitivity = fd.get("sensitivity", "normal")
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
+63
View File
@@ -0,0 +1,63 @@
"""Core-owned system MiniApps (Phase M4).
Host-level MiniApps that are not owned by a single plugin: audit activity
feed and system metrics. They register in the universal registry with
``plugin_name="system"`` at app startup and unregister with the registry
reset (tests) they never depend on plugin activation state.
Permissions follow the owning data source:
- audit_activity -> audit:read (audit log route guard, CORE_PERMISSIONS)
- system_metrics -> settings:read (Roadmap M4; the /system/dashboard
endpoint itself stays require_admin the widget degrades gracefully
with a permission hint for non-admins)
"""
from __future__ import annotations
from app.plugins.miniapp_registry import get_miniapp_registry
SYSTEM_PLUGIN_NAME = "system"
def register_system_miniapps() -> None:
"""Register the core system MiniApps in the universal registry."""
registry = get_miniapp_registry()
registry.register(
app_id="audit_activity",
name="Aktivitäten",
icon="History",
description="Letzte Aktivitäten aus dem Audit-Log (Benutzer, Aktion, Zeitpunkt).",
plugin_name=SYSTEM_PLUGIN_NAME,
permission="audit:read",
settings_schema={
"fields": [
{
"name": "max_items",
"label": "Max. Einträge",
"type": "number",
"default": 10,
}
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/AuditActivityWidget",
order=40,
)
registry.register(
app_id="system_metrics",
name="System Status",
icon="Server",
description="Datenbank-, Redis-, Worker- und API-Metriken (Administration).",
plugin_name=SYSTEM_PLUGIN_NAME,
permission="settings:read",
settings_schema={},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/SystemMetricsWidget",
order=50,
)
+8 -2
View File
@@ -7,7 +7,8 @@ import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy import cast, select
from sqlalchemy.dialects.postgresql import JSONB
from app.core.db import get_session_factory
from app.core.event_bus import EventBus, get_event_bus
@@ -48,7 +49,12 @@ async def _dispatch_event(payload: dict[str, Any]) -> None:
stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id,
Webhook.is_active == True, # noqa: E712
Webhook.events.any(event_name),
# Webhook.events is a JSONB array column (NOT a relationship):
# events @> '["<event_name>"]' — JSONB containment instead of
# the invalid relationship .any() call that crashed every event
# with "Neither 'AnnotatedColumn' nor 'Comparator' object has an
# attribute 'any'" (158 failed outbox events in production).
cast(Webhook.events, JSONB).contains([event_name]),
)
result = await db.execute(stmt)
webhooks = list(result.scalars().all())
+8 -10
View File
@@ -396,7 +396,6 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory
from app.models.contact import Contact
from app.models.entity_attachment import EntityAttachment
factory = get_worker_session_factory()
@@ -414,16 +413,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
{"tid": str(tenant_id)},
)
# Delete soft-deleted contacts
result = await db.execute(
sa_delete(Contact).where(
Contact.deleted_at.is_not(None),
Contact.deleted_at < cutoff,
)
)
total_deleted += result.rowcount
# Delete soft-deleted entity attachments
# (Contacts trash cleanup moved to the contacts plugin:
# cleanup_contacts_trash — audit P2, no core->contacts import)
result = await db.execute(
sa_delete(EntityAttachment).where(
EntityAttachment.deleted_at.is_not(None),
@@ -488,6 +480,12 @@ class WorkerSettings:
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
hour=4, minute=0,
),
# Contacts trash cleanup — daily at 04:15, owned by the contacts
# plugin (audit P2: no core->contacts import in the worker).
cron(
_wrap_cron_with_lock("cleanup_contacts_trash", get_job("cleanup_contacts_trash"), ttl_seconds=300),
hour=4, minute=15,
),
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
# Function comes from the knowledge plugin via the job registry.
cron(
+168 -17
View File
@@ -7,7 +7,7 @@ import uuid
from typing import Any
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -74,7 +74,62 @@ async def get_current_user(
Returns session data dict with user_id, tenant_id, email, name, role,
and resolved permissions from Redis cache.
F09 (Astra P1): also accepts a short-lived HMAC-signed delegation
token (X-Delegation-Token header) for INTERNAL calls made on behalf
of a user e.g. the generic CRM-API tool used by AI agents and the
MCP server. Previously those tools sent unauthenticated
X-Internal-Call headers that the protected API never accepted.
"""
# F09: internal delegation path — HMAC-signed, max 60 seconds
delegation_header = request.headers.get("X-Delegation-Token", "")
if delegation_header:
from app.core.delegation_token import verify_delegation_token
payload = verify_delegation_token(delegation_header)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid or expired delegation token", "code": "delegation_invalid"},
)
deleg_user_id = str(payload.get("user_id", ""))
deleg_tenant_id = str(payload.get("tenant_id", ""))
if not deleg_user_id or not deleg_tenant_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Delegation token missing user/tenant", "code": "delegation_invalid"},
)
tenant_id = uuid.UUID(deleg_tenant_id)
user_id = uuid.UUID(deleg_user_id)
await set_tenant_context(db, tenant_id)
from app.core.permissions import get_cached_permissions
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
from sqlalchemy import select as _select
from app.models.group import UserGroup
groups_q = await db.execute(
_select(UserGroup.group_id)
.where(UserGroup.user_id == user_id)
.where(UserGroup.tenant_id == tenant_id)
)
group_ids = [row[0] for row in groups_q]
is_admin = bool(resolved.get("is_system_admin", False))
await set_user_context(db, user_id, group_ids, is_admin)
return {
"user_id": deleg_user_id,
"tenant_id": deleg_tenant_id,
"email": "", # not needed for permission decisions
"name": "delegated",
"role": "",
"permissions": resolved.get("permissions", []),
"denied_permissions": resolved.get("denied", []),
"field_permissions": resolved.get("field_permissions", {}),
"is_system_admin": is_admin,
"delegated_by": payload.get("agent_id", ""),
}
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
@@ -130,10 +185,20 @@ async def get_current_user(
membership_row = membership_q.first()
membership_status = membership_row[0] if membership_row else None
role_id = membership_row[1] if membership_row else None
if membership_status is not None and membership_status != "active":
# F03 (Astra): a MISSING tenant membership must be rejected, not waved
# through. Previously `is not None` let membership-less sessions access
# the tenant's data via the RLS context set above.
if membership_status is None or membership_status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": f"Mitgliedschaft ist {membership_status}, Zugriff verweigert", "code": "membership_suspended"},
detail={
"detail": (
f"Mitgliedschaft ist {membership_status}, Zugriff verweigert"
if membership_status
else "Keine aktive Mandanten-Mitgliedschaft, Zugriff verweigert"
),
"code": "membership_suspended",
},
)
# Cache user principals for this request — avoids N+1 queries in visibility.py
@@ -247,6 +312,54 @@ async def get_current_user_or_bearer(
return await get_current_user(request, db, redis)
def require_permission_or_bearer(permission: str):
"""F08 (Astra P1): permission dependency for routes that serve BOTH
session-cookie clients (SPA) and pure Bearer API clients.
``require_permission`` resolves via ``get_current_user`` (session
cookie only) a Bearer client fails with 401 before the route's own
Bearer verification is ever reached. This dependency accepts either
auth path and enforces the SAME effective permission:
- session users: normal permission check
- Bearer tokens: token scopes are an UPPER BOUND (F10) the user's
own permissions must grant the permission AND the scope must match
"""
async def _check(
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
) -> dict[str, Any]:
token_scopes = current_user.get("_token_scopes")
if token_scopes is not None:
from app.core.permissions import _permission_matches_any
if not _permission_matches_any(set(token_scopes), permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Token scope '{permission}' required",
"code": "insufficient_scope",
},
)
# fall through — user permissions apply too (F10 semantics)
if current_user.get("is_system_admin"):
return current_user
from app.core.permissions import check_permission
if check_permission(current_user, permission):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Permission '{permission}' required",
"code": "forbidden",
},
)
return _check
async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
@@ -311,6 +424,10 @@ def require_permission(permission: str):
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
# API token scope enforcement (Problem 2 fix)
# F10 (Astra): token scopes are an UPPER BOUND, not a replacement —
# the user's own permissions must ALSO grant the permission. A token
# can never grant more than its owner has; revoking the user's
# permission takes effect on existing tokens.
token_scopes = current_user.get("_token_scopes")
if token_scopes is not None:
from app.core.permissions import _permission_matches_any
@@ -322,7 +439,7 @@ def require_permission(permission: str):
"code": "insufficient_scope",
},
)
return current_user
# fall through: the normal user-permission check applies too
if current_user.get("is_system_admin"):
return current_user
@@ -382,6 +499,30 @@ async def get_current_user_id(
return uuid.UUID(current_user["user_id"])
def require_workspace_scope(module_key: str):
"""FastAPI dependency factory (Phase N3): resolve the active workspace
scope config for a module from the X-Workspace-ID header.
Returns the scope dict (e.g. ``{"folder_ids": [...]}``) or ``None``
when no restriction applies (no header, admin, unassigned, empty config).
Callers apply it as a pure AND-restriction never a grant.
Usage:
scope: dict | None = Depends(require_workspace_scope("contacts"))
"""
async def _resolve(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"),
) -> dict[str, Any] | None:
from app.services.workspace_scope_service import resolve_workspace_scope
return await resolve_workspace_scope(db, current_user, x_workspace_id, module_key)
return _resolve
def require_active_plugin(plugin_name: str):
"""FastAPI dependency factory: require that a plugin is active.
@@ -400,7 +541,16 @@ def require_active_plugin(plugin_name: str):
"""
async def _check(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
) -> None:
"""F05 (Astra P1): the plugin gate runs AFTER authentication.
Depending on ``get_current_user_or_bearer`` guarantees FastAPI
resolves the authenticated user context BEFORE this check the
previous version read the tenant from the DB session before auth
had run (context missing silent allow). Both auth paths (cookie
and Bearer) set the tenant context on this same ``db`` session.
"""
from app.core.permission_registry import get_permission_registry
try:
registry = get_permission_registry()
@@ -412,19 +562,20 @@ def require_active_plugin(plugin_name: str):
"code": "plugin_inactive",
},
)
# Get tenant_id from existing db session (NOT a new session)
# The tenant context is set by middleware/get_current_user on this same session
from sqlalchemy import text as sa_text
result = await db.execute(
sa_text("SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid")
)
tenant_id = result.scalar()
if tenant_id is None:
# No tenant context — plugin is active by default (backward compatible)
# TODO: Fix in production to deny access when no tenant context
return
# Tenant comes from the AUTHENTICATED user context — never from
# the DB session (which may not have the context set yet).
raw_tid = current_user.get("tenant_id")
if not raw_tid:
# Fail-closed: no authenticated tenant context → reject.
# (Previously this returned silently = plugin active.)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": "Plugin gate requires an authenticated tenant context",
"code": "plugin_gate_no_tenant",
},
)
tenant_id = uuid.UUID(str(raw_tid))
# Per-tenant activation check with Redis cache
import json
+31 -4
View File
@@ -45,8 +45,8 @@ from app.routes import ( # noqa: E402
compliance,
currencies,
custom_field_definitions,
custom_fields,
dashboard,
dashboards,
delegations,
entity_history,
entity_permissions,
@@ -56,6 +56,7 @@ from app.routes import ( # noqa: E402
health,
import_export,
metrics,
miniapps,
notifications,
outbox,
owner_transfer,
@@ -217,6 +218,16 @@ async def lifespan(app: FastAPI):
registry.initialize(get_migration_engine(), app)
registry.discover_builtins()
# Core system MiniApps (Phase M4): host-level, independent of plugin state
from app.core.system_miniapps import register_system_miniapps
register_system_miniapps()
# Core AI agent tools for MiniApp output (Phase M6)
from app.ai.miniapp_tools import register_miniapp_tools
register_miniapp_tools()
# Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -326,6 +337,22 @@ async def lifespan(app: FastAPI):
if plugin and plugin.manifest.permissions:
register_plugin_permissions(record.name, plugin.manifest.permissions)
# Audit P1 (contract lazy loading, restart edge case): plugins that
# were already inactive in the DB when this process started never get
# a runtime deactivate() call, so the ContractRegistry would
# lazy-load their contracts module and resurrect the contract.
# Mark them once here so get_contract() fails closed for them.
inactive_result = await db.execute(
sa_select(PluginModel.name).where(PluginModel.active == False) # noqa: E712
)
inactive_names = {row[0] for row in inactive_result}
if inactive_names:
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().mark_db_inactive(inactive_names)
logger.info(
"Contract registry: %d plugins marked DB-inactive", len(inactive_names)
)
init_permission_registry(active_plugin_names)
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
@@ -348,7 +375,7 @@ async def lifespan(app: FastAPI):
plugin = registry.get_plugin(name)
if plugin:
for entity_type, model_class in plugin.get_entity_models().items():
register_entity_model(entity_type, model_class)
register_entity_model(entity_type, model_class, plugin_name=name)
logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
# Register field definitions from active plugins only
@@ -438,7 +465,6 @@ def create_app() -> FastAPI:
{"name": "entity-history", "description": "Audit trail and entity change history."},
{"name": "import-export", "description": "Bulk import and export of contacts and data."},
{"name": "plugins", "description": "Plugin management: list, install, activate, deactivate."},
{"name": "ai-copilot", "description": "AI copilot: chat, suggestions, conversation history."},
{"name": "workflows", "description": "Workflow definitions, instances, and execution."},
{"name": "user-preferences", "description": "Per-user preference settings."},
{"name": "currencies", "description": "Currency management for multi-currency support."},
@@ -567,6 +593,7 @@ def create_app() -> FastAPI:
# require_active_plugin("contacts") protection.
app.include_router(entity_permissions.router)
app.include_router(dashboard.router)
app.include_router(dashboards.router)
app.include_router(entity_history.router)
app.include_router(import_export.router)
app.include_router(plugins.router)
@@ -585,7 +612,6 @@ def create_app() -> FastAPI:
app.include_router(compliance.router)
app.include_router(owner_transfer.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
app.include_router(saved_views.router)
app.include_router(webhooks.router)
@@ -598,6 +624,7 @@ def create_app() -> FastAPI:
app.include_router(outbox.router)
app.include_router(api_tokens.router)
app.include_router(approvals.router)
app.include_router(miniapps.router)
# ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete.
+22 -1
View File
@@ -8,16 +8,22 @@ from app.models.backup import Backup
from app.models.bank_account import BankAccount
from app.models.compliance import ComplianceIncident
from app.models.consumer_inbox import ConsumerInbox
from app.models.contact import Contact, ContactPerson
# Contact/ContactPerson: lazy via package __getattr__ (Paket 6) — the physical
# model lives in app.plugins.builtins.contacts.models; importing the plugin
# framework while app.models is still initializing caused a proven circular
# ImportError (app.core.auth -> app.models.session -> ... -> app.plugins).
from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory
from app.models.currency import Currency
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.dashboard import Dashboard
from app.models.entity_history import EntityHistory
from app.models.entity_permission import EntityPermission
from app.models.entity_policy import EntityPolicy
from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType
from app.models.outbox import EventOutbox, OutboxDelivery # noqa: F401
from app.models.owned_mixin import OwnedMixin
from app.models.permission_delegation import PermissionDelegation
from app.models.permission_template import PermissionTemplate
@@ -75,6 +81,7 @@ __all__ = [
"WorkflowInstance",
"WorkflowStepHistory",
"SavedView",
"Dashboard",
]
from app.models.entity_attachment import EntityAttachment # noqa: F401
from app.models.workspace import ( # noqa: F401
@@ -83,3 +90,17 @@ from app.models.workspace import ( # noqa: F401
WorkspaceUser,
WorkspaceWidget,
)
# ── Lazy Contact re-export (Paket 6, #357) ──────────────────────────────────
# The physical home of Contact/ContactPerson is the ContactsPlugin
# (app.plugins.builtins.contacts.models). Resolving them lazily via package
# __getattr__ keeps ``from app.models import *`` (alembic/env.py) working
# while avoiding a plugin-framework import during app.models initialization
# (proven circular ImportError, see app/models/contact.py).
def __getattr__(name: str):
if name in {"Contact", "ContactPerson"}:
from app.models.contact import Contact, ContactPerson
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-53
View File
@@ -1,53 +0,0 @@
"""AI Conversation and Message models — tenant-scoped with RLS."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class AIConversation(Base, TenantMixin, OwnedMixin):
"""AI Copilot conversation thread — tenant-scoped."""
__tablename__ = "ai_conversations"
__table_args__ = (Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Untitled")
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
class AIMessage(Base, TenantMixin, OwnedMixin):
"""Individual messages within an AI conversation — user input, AI response, actions."""
__tablename__ = "ai_messages"
__table_args__ = (Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
content: Mapped[str] = mapped_column(Text, nullable=False)
proposed_actions: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
executed_action: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
execution_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
message_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+31 -248
View File
@@ -1,258 +1,41 @@
"""Unified Contact model — company or person, with inline addresses.
"""Contact model - backwards-compatibility re-export (Paket 6, #357).
Based on Rentman's contact model: a single table with type field
('company' or 'person'). ContactPerson is a 1:N child for
ansprechpartner (company employees / contact persons).
The physical home of Contact/ContactPerson moved to the ContactsPlugin:
app/plugins.builtins.contacts.models
This module re-exports both classes lazily (PEP 562 ``__getattr__``) so every
existing import keeps working - ``from app.models.contact import Contact``
resolves at attribute-access time:
- alembic/env.py (``from app.models import *`` -> Base.metadata stays
complete; Autogenerate never sees the tables as removed)
- Core services (worker.py, jobs.py, address_service.py, ...)
- 19 test files and scripts
Why LAZY and not a top-level import: app/models/__init__.py is imported very
early (app.core.auth imports app.models.session). A top-level plugin import
here would pull in app.plugins -> registry -> service_container -> cache ->
app.core.auth while app.core.auth is still initializing -> circular ImportError
(proven in the Paket 6 red run). With PEP 562 the plugin framework is only
touched when Contact is actually accessed, long after app.models finished
initializing - every entry order is cycle-free.
The cross-plugin checker (scripts/check_cross_plugin_imports.py) lists this
file in EXEMPT_PATHS: the re-export is the deliberate, documented bridge -
the plugin OWNS the model; the core only mirrors it for import stability.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Computed,
DateTime,
Float,
ForeignKey,
Index,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
_EXPORTS = {"Contact", "ContactPerson"}
class Contact(Base, TenantMixin, OwnedMixin):
"""Unified contact entity — can be a company or a person.
def __getattr__(name: str):
if name in _EXPORTS:
from app.plugins.builtins.contacts.models import Contact, ContactPerson
type='company': name is the company name, firstname/surname empty.
type='person': firstname/surname are the person's name, name empty.
Both types can have contactpersons (1:N) and inline addresses
(mailing, visit, invoice).
"""
__tablename__ = "contacts"
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_type", "tenant_id", "type"),
Index("ix_contacts_tenant_name", "tenant_id", "name"),
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
Index("ix_contacts_email", "email_1"),
Index("ix_contacts_code", "code"),
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity & Type ──
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
# ── Customer / Accounting ──
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Mailing Address (inline) ──
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Visit Address (inline) ──
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Invoice Address (inline) ──
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── General country ──
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Communication ──
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
# ── Financial & Tax ──
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
# ── Discounts ──
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
# ── Geo ──
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ── Notes & Warnings ──
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
# ── Default contact persons (self-referential via contactpersons table) ──
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
# ── Folder assignment ──
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── FTS ──
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
Computed(
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
persisted=True,
),
nullable=True,
)
# ── Embedding (pgvector, 768-dim) ──
from pgvector.sqlalchemy import Vector
embedding: Mapped[Any | None] = mapped_column(
Vector(768), nullable=True, default=None
)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Relationships ──
contact_persons: Mapped[list[ContactPerson]] = relationship(
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
)
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class ContactPerson(Base, TenantMixin, OwnedMixin):
"""Ansprechpartner — 1:N child of a Contact.
Represents a person working at / associated with a company contact.
Has its own address and communication fields.
"""
__tablename__ = "contactpersons"
__table_args__ = (
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contactpersons_contact", "contact_id"),
Index("ix_contactpersons_email", "email"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Parent contact ──
contact_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
)
# ── Name ──
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
# ── Communication ──
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── Own address ──
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Other ──
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Relationship ──
contact: Mapped[Contact] = relationship(
back_populates="contact_persons", foreign_keys=[contact_id]
)
# Keep old names for backward compat during migration
def __dir__() -> list[str]:
return sorted(_EXPORTS | {"__getattr__", "__dir__"})
+52
View File
@@ -0,0 +1,52 @@
"""Dashboard model — personal per-user dashboards (Phase M2).
Dashboards are personal (user-owned) layouts of MiniApp instances: tabs,
widget placements and per-instance settings, stored as JSONB. Access is
owner-only (saved_views precedent) the active workspace limits only the
available widget types (Phase N), never this personal layout.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Dashboard(Base, TenantMixin):
"""Personal dashboard — per-user layout of MiniApp instances."""
__tablename__ = "dashboards"
__table_args__ = (
# Partial unique: soft-deleted dashboards free their name (unlike the
# saved_views plain constraint, which keeps names occupied forever).
Index(
"uq_dashboards_tenant_user_name",
"tenant_id",
"user_id",
"name",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
Index("ix_dashboards_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Layout JSONB (validated by app.schemas.dashboard.DashboardLayout):
# {version, tabs: [{id, name, widgets: [{app_id, settings, col, row, spans}]}]}
layout: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
+4
View File
@@ -58,3 +58,7 @@ class PluginMigration(Base, TimestampMixin):
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
# F40 (Astra): SHA-256 of the applied SQL content. The runner compares
# this on subsequent runs — a modified already-applied migration
# (repaired) becomes VISIBLE instead of being silently skipped.
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
+82 -8
View File
@@ -53,15 +53,16 @@ class BasePlugin(ABC):
) -> None:
"""Called when the plugin is activated.
Override to register event listeners and prepare runtime state.
Default implementation subscribes to events listed in the manifest.
Default implementation subscribes to events listed in the manifest and
registers manifest MiniApps (Phase M1): ``miniapps`` contributions plus
``dashboard_widgets`` entries (alias one contribution type, #359
philosophy). Registered automatically here; no per-plugin code needed.
"""
for event_name in self.manifest.events:
handler = self._make_event_handler(event_name)
self._event_handlers[event_name] = handler
event_bus.subscribe(event_name, handler)
self._register_manifest_events(event_bus)
self._container = service_container
self._register_manifest_miniapps()
async def on_deactivate(
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
) -> None:
@@ -80,6 +81,57 @@ class BasePlugin(ABC):
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
# Unregister MiniApps owned by this plugin (Phase M1)
from app.plugins.miniapp_registry import get_miniapp_registry
get_miniapp_registry().unregister_plugin(self.manifest.name)
def _register_manifest_miniapps(self) -> None:
"""Register manifest MiniApps in the universal registry (Phase M1).
Sources:
- ``manifest.miniapps`` native MiniApp contributions
- ``manifest.dashboard_widgets`` alias: FrontendDashboardWidget entries
become MiniApps with component path + spans + permission so existing
plugin manifests keep working without changes.
"""
from app.plugins.miniapp_registry import get_miniapp_registry
registry = get_miniapp_registry()
name = self.manifest.name
for m in getattr(self.manifest, "miniapps", None) or []:
registry.register(
app_id=m.app_id,
name=m.name,
icon=m.icon,
description=m.description,
plugin_name=name,
render_schema=m.render_schema,
permission=getattr(m, "permission", ""),
settings_schema=getattr(m, "settings_schema", {}),
col_span=getattr(m, "col_span", 1),
row_span=getattr(m, "row_span", 1),
hosts=getattr(m, "hosts", None),
component=getattr(m, "component", ""),
order=getattr(m, "order", 100),
)
for w in getattr(self.manifest, "dashboard_widgets", None) or []:
registry.register(
app_id=w.id,
name=w.label or w.id,
icon=w.icon,
description="",
plugin_name=name,
permission=w.permission,
col_span=w.col_span,
row_span=w.row_span,
hosts=["chat", "dashboard", "window"],
component=w.component,
order=w.order,
)
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
"""Called when the plugin is uninstalled (before data tables are dropped).
@@ -102,9 +154,31 @@ class BasePlugin(ABC):
The worker calls this on every active plugin at startup so plugins
can subscribe to events even when the web process is separate.
Default: no-op. Override to subscribe handlers.
F06 (Astra P1): the default now subscribes the plugin's manifest
events via the SAME idempotent path as ``on_activate`` previously
this was a no-op, so the worker registered 0 of the 44 declared
event handlers and Outbox events reached no plugin handler. Plugins
that override this MUST call ``await super().register_event_handlers(
event_bus)`` to keep the manifest subscription.
"""
return None
self._register_manifest_events(event_bus)
def _register_manifest_events(self, event_bus: EventBus) -> None:
"""Subscribe to manifest events — shared, idempotent (F06).
Used by BOTH the API activation path (``on_activate``) and the
worker startup hook (``register_event_handlers``). Idempotent: an
event already subscribed in this instance is not subscribed twice.
DB-writing lifecycle work (seeding, cron registration) stays in
``on_activate`` the worker path deliberately skips it.
"""
for event_name in self.manifest.events:
if event_name in self._event_handlers:
continue # already subscribed — idempotent
handler = self._make_event_handler(event_name)
self._event_handlers[event_name] = handler
event_bus.subscribe(event_name, handler)
# ─── Job Modules ───
+26 -1
View File
@@ -3,7 +3,12 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
PluginManifest,
PluginRouteDef,
)
class AgentMemoryPlugin(BasePlugin):
@@ -28,6 +33,26 @@ class AgentMemoryPlugin(BasePlugin):
"agent_memory:read",
"agent_memory:write",
],
# UI-Backlog Modul 8 (2026-09-13): agent memory page, registered
# via the manifest (Phase Q pattern).
menu_items=[
FrontendMenuItem(
label_key="nav.agentMemory",
label="Agent Memory",
path="/agent-memory",
icon="Brain",
order=86,
permission="agent_memory:read",
),
],
page_routes=[
FrontendPageRoute(
path="/agent-memory",
component="@/pages/AgentMemory",
protected=True,
permission="agent_memory:read",
),
],
is_core=True,
author="LeoCRM Team",
min_app_version="1.0.0",
@@ -20,12 +20,56 @@ _openapi_cache: dict[str, Any] | None = None
def _get_base_url() -> str:
"""Get the internal base URL for API calls."""
"""Get the internal base URL for API calls.
F09 (Astra P1): inside the API container this is 127.0.0.1; the
WORKER must reach the API service instead - 127.0.0.1 there points
at the worker itself. INTERNAL_API_URL overrides (compose sets it
to http://crm_app:PORT for the worker service).
"""
import os
override = os.environ.get("INTERNAL_API_URL")
if override:
return override.rstrip("/")
port = os.environ.get("PORT", "8000")
return f"http://127.0.0.1:{port}"
async def _make_internal_api_request(method, path, tenant_id, user_id, body=None):
"""F09 (Astra P1): authenticated internal API request.
Sends a short-lived HMAC-signed delegation token (max 60 s) instead
of the previous unauthenticated X-Internal-Call headers that the
protected API never accepted. The token acts ON BEHALF OF the user
- their real permissions apply (RBAC + RLS), no special rights.
"""
from app.core.delegation_token import create_delegation_token
token = create_delegation_token(
user_id=str(user_id),
tenant_id=str(tenant_id),
agent_id="crm-api-tool",
)
headers = {
"Content-Type": "application/json",
"X-Delegation-Token": token,
}
url = f"{_get_base_url()}{path}"
async with httpx.AsyncClient() as client:
if method == "GET":
return await client.get(url, headers=headers, timeout=30.0)
if method == "POST":
return await client.post(url, headers=headers, json=body, timeout=30.0)
if method == "PATCH":
return await client.patch(url, headers=headers, json=body, timeout=30.0)
if method == "PUT":
return await client.put(url, headers=headers, json=body, timeout=30.0)
if method == "DELETE":
return await client.delete(url, headers=headers, timeout=30.0)
raise ValueError(f"Unsupported method: {method}")
async def get_openapi_spec() -> dict[str, Any]:
"""Get the CRM OpenAPI spec, cached."""
global _openapi_cache
@@ -101,34 +145,15 @@ async def call_crm_api_handler(arguments: dict[str, Any], context: dict[str, Any
path = "/" + path
try:
base_url = _get_base_url()
# Get user context for auth
tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "")
# Create a DB session to resolve a valid session token for this user
# We'll use internal service-level auth bypass
headers = {
"Content-Type": "application/json",
"X-Internal-Call": "true",
"X-Tenant-Id": str(tenant_id),
"X-User-Id": str(user_id),
}
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
elif method == "POST":
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PATCH":
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PUT":
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "DELETE":
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
else:
return json.dumps({"error": f"Unsupported method: {method}"})
# F09 (Astra P1): authenticated request via short-lived delegation
# token - the acting user's real permissions apply.
resp = await _make_internal_api_request(
method, path, tenant_id=tenant_id, user_id=user_id, body=body
)
# Return response body (truncated if too large)
try:
@@ -16,8 +16,8 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context
from app.deps import get_current_user_bearer, require_permission
from app.core.db import get_db, get_session_factory, set_tenant_context
from app.deps import get_current_user_bearer, require_permission_or_bearer
from app.plugins.builtins.ai_assistant.schemas import (
ExternalAgentRequest,
ExternalAgentResponse,
@@ -39,7 +39,7 @@ async def _check_external_rate_limit(request: Request, tenant_id: str, token_pre
@router.post(
"/{agent_id}/run",
dependencies=[Depends(require_permission("ai:write"))],
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
)
async def run_agent_external(
agent_id: str,
@@ -123,7 +123,8 @@ async def run_agent_external(
# Run the agent via streaming chat (non-streaming mode)
full_response = ""
async with get_db() as stream_db:
_factory = get_session_factory()
async with _factory() as stream_db:
await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat(
stream_db,
@@ -155,7 +156,7 @@ async def run_agent_external(
@router.get(
"/{agent_id}/status",
dependencies=[Depends(require_permission("ai:read"))],
dependencies=[Depends(require_permission_or_bearer("ai:read"))],
)
async def get_agent_status_external(
agent_id: str,
@@ -218,7 +219,7 @@ async def get_agent_status_external(
@router.post(
"/{agent_id}/stream",
dependencies=[Depends(require_permission("ai:write"))],
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
)
async def stream_agent_external(
agent_id: str,
@@ -1,4 +1,7 @@
-- AI Assistant plugin initial migration
-- FIX 2026-09-16: ai_chat_sessions/ai_chat_messages removed — AI chat moved
-- to comm_conversations/comm_messages (Alembic 0137 dropped these tables).
-- Fresh installs must NOT recreate the ghost tables.
CREATE TABLE IF NOT EXISTS ai_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -68,35 +71,3 @@ CREATE TABLE IF NOT EXISTS ai_agents (
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id);
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
agent_id UUID REFERENCES ai_agents(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL DEFAULT 'Neuer Chat',
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
is_sidebar BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_sessions_user ON ai_chat_sessions(user_id);
CREATE INDEX IF NOT EXISTS ix_ai_sessions_tenant ON ai_chat_sessions(tenant_id);
CREATE TABLE IF NOT EXISTS ai_chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls JSONB,
tool_results JSONB,
tokens INTEGER NOT NULL DEFAULT 0,
model_used VARCHAR(200) NOT NULL DEFAULT '',
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_messages_session ON ai_chat_messages(session_id);
CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant ON ai_chat_messages(tenant_id);
@@ -1,4 +1,8 @@
-- AI Assistant plugin migration 0002: chat folders + attachments
-- AI Assistant plugin migration 0002: chat folders
-- FIX 2026-09-16: ai_chat_attachments and the folder_id ALTER on
-- ai_chat_sessions removed — AI chat moved to comm_conversations/
-- comm_messages (Alembic 0137 dropped the legacy tables). Only the
-- ai_chat_folders table remains (still used for chat folder ordering).
CREATE TABLE IF NOT EXISTS ai_chat_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -13,22 +17,3 @@ CREATE TABLE IF NOT EXISTS ai_chat_folders (
CREATE INDEX IF NOT EXISTS ix_ai_folders_user ON ai_chat_folders(user_id);
CREATE INDEX IF NOT EXISTS ix_ai_folders_tenant ON ai_chat_folders(tenant_id);
CREATE INDEX IF NOT EXISTS ix_ai_folders_parent ON ai_chat_folders(parent_id);
ALTER TABLE ai_chat_sessions ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES ai_chat_folders(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS ai_chat_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID REFERENCES ai_chat_messages(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL DEFAULT 0,
storage_path VARCHAR(1024) NOT NULL,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_message ON ai_chat_attachments(message_id);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_session ON ai_chat_attachments(session_id);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_tenant ON ai_chat_attachments(tenant_id);
@@ -1,8 +1,11 @@
-- Migration 0003: Add sort_order columns for drag&drop reordering
-- FIX 2026-09-16: ai_chat_sessions was dropped by Alembic 0137 (AI chat
-- moved to comm_conversations/comm_messages). The ALTER/INDEX statements
-- targeting ai_chat_sessions made this migration fail on every startup
-- ("relation ai_chat_sessions does not exist"), which deactivated the
-- whole ai_assistant plugin. Only the ai_chat_folders statements remain
-- (that table still exists and is used for chat folder ordering).
ALTER TABLE ai_chat_sessions ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ai_chat_folders ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS ix_ai_sessions_folder ON ai_chat_sessions(folder_id);
CREATE INDEX IF NOT EXISTS ix_ai_sessions_sort ON ai_chat_sessions(sort_order);
CREATE INDEX IF NOT EXISTS ix_ai_folders_sort ON ai_chat_folders(sort_order);
@@ -59,6 +59,7 @@ class AIAssistantPlugin(BasePlugin):
],
settings_pages=[
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
FrontendSettingsPage(path='external-agents', label_key='settings.externalAgents', label='External Agents API', component='@/pages/SettingsExternalAgents', icon='Bot', order=61, permission='ai:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
+14 -1
View File
@@ -234,6 +234,10 @@ async def stream_chat_comm(
tools.append(crm_api_tool)
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
# F01 (Astra P0): allowlist — only the tools offered above may execute.
# A hallucinated/injected tool name must never reach a handler.
allowed_tool_names = {t.name for t in tools}
# Build LLM params
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
@@ -290,8 +294,17 @@ async def stream_chat_comm(
except json.JSONDecodeError:
tool_args = {}
# F01 (Astra P0): allowlist enforcement — reject any tool
# name that was not offered to the LLM before it reaches a
# handler. registered ≠ permitted.
tool = registry.get(tool_name)
if tool is None:
if tool_name not in allowed_tool_names:
logger.warning(
"stream_chat_comm F01 guard: tool '%s' is registered but NOT offered to this agent — rejected",
tool_name,
)
result = f"Error: Tool '{tool_name}' is not available to this agent"
elif tool is None:
result = f"Tool '{tool_name}' not found"
else:
result = await execute_tool_call(tool, tool_args, user_context)
@@ -75,7 +75,7 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
from app.core.db import get_worker_session_factory
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.models import CommConversation
komm = get_contract_registry().get("kommunikation")
komm = get_contract_registry().get_contract("kommunikation")
if komm:
factory = get_worker_session_factory()
async with factory() as db:
@@ -15,7 +15,7 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.deps import get_current_user, require_permission, require_workspace_scope
from app.plugins.builtins.automation.models import (
AgentDefinition,
AgentRun,
@@ -118,8 +118,13 @@ async def list_agents(
offset: int = Query(0, ge=0),
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
workspace_scope: dict | None = Depends(require_workspace_scope("agents")),
):
"""List agent definitions with optional filters."""
"""List agent definitions with optional filters.
Phase N4: an active workspace scope restricts the list to the
configured agent subset (pure AND never a grant).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
@@ -127,6 +132,14 @@ async def list_agents(
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
user_id=user_id, is_system_admin=is_system_admin,
)
# Phase N4: workspace scope — agent subset (pure AND)
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
agent_scope = scope_uuid_set(workspace_scope.get("agent_ids"))
if agent_scope is not None:
items = [a for a in items if a.id in agent_scope]
total = len(items)
return AgentDefinitionListResponse(
items=[_agent_to_response(a) for a in items],
total=total,
@@ -166,14 +179,14 @@ async def list_tools(
)
registry = get_tool_registry()
tools = registry.list_tools()
tools = registry.list_for_api()
return {
"items": [
{
"id": t.get("id", t.get("name", "")),
"id": t.get("name", ""),
"name": t.get("name", ""),
"description": t.get("description", ""),
"plugin": t.get("plugin", ""),
"plugin": t.get("plugin_name", ""),
}
for t in tools
],
@@ -651,6 +664,7 @@ async def stream_agent_run(
tenant_id=tenant_id,
user_id=user_id,
agent_run_id=aid,
user_permissions=current_user, # F01: enforce allowlist+permission at execution time
),
media_type="text/event-stream",
)
+20 -10
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
@@ -65,7 +65,7 @@ async def run_agent(
# ── Safety Check 1: Rate Limit ──
if agent.max_executions_per_hour:
async with factory() as db:
one_hour_ago = datetime.now(UTC)
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
count_result = await db.execute(
select(func.count())
.select_from(AgentRun)
@@ -113,7 +113,7 @@ async def run_agent(
mail_contract = get_contract("mail")
if mail_contract and hasattr(mail_contract, "Mail"):
from sqlalchemy import select as _select
Mail = mail_contract.Mail
Mail = mail_contract.Mail # noqa: N806 — class alias
async with factory() as db:
mail_q = await db.execute(
_select(Mail)
@@ -238,13 +238,22 @@ async def run_agent(
)
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ──
# F14 (Astra P1): pass a REAL DB session so provider compliance
# (data residency / allowed data classes) is actually loaded —
# previously db=None silently skipped the compliance check.
from app.ai.data_policy import enforce_data_policy
messages = await enforce_data_policy(
db=None,
tenant_id=agent.tenant_id,
messages=messages,
agent_definition=agent,
)
from app.core.db import get_session_factory as _dp_factory
from app.core.db import set_tenant_context as _dp_set_tenant
_factory = _dp_factory()
async with _factory() as _dp_db:
await _dp_set_tenant(_dp_db, agent.tenant_id)
messages = await enforce_data_policy(
db=_dp_db,
tenant_id=agent.tenant_id,
messages=messages,
agent_definition=agent,
)
react_result: ReActResult = await asyncio.wait_for(
run_react_loop(
@@ -260,6 +269,7 @@ async def run_agent(
timeout_seconds=max_duration,
require_approval=bool(getattr(agent, "require_approval", False)),
approval_tools=getattr(agent, "approval_tools", None),
user_permissions=perm_ctx.user_permissions, # F01: enforce at execution time
),
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
)
@@ -377,7 +387,7 @@ async def run_agent(
# ── Post agent result to Communication (F-COMM) ──
try:
from app.plugins.builtins.contracts import get_contract_registry
komm = get_contract_registry().get("kommunikation")
komm = get_contract_registry().get_contract("kommunikation")
if komm:
async with factory() as db:
# Find or create agent conversation room via contract
@@ -63,6 +63,31 @@ class AutomationContract:
# ─── agent_comm ───
send_agent_message = staticmethod(send_agent_message)
# ─── Workspace Scopes contribution (Phase N4) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des agents-Moduls: Agenten-Teilmengen (N4)."""
return [
{
"module_key": "agents",
"dimensions": [
{
"key": "agent_ids",
"label": "Agenten",
"control": "multiselect",
"options": [],
"value_source": {
"endpoint": "/api/v1/agents",
"items_path": "items",
"value_key": "id",
"label_key": "name",
},
},
],
}
]
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
+46 -30
View File
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -63,6 +64,25 @@ class AutomationPlugin(BasePlugin):
"workflow.timeout",
],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
miniapps=[
MiniAppContribution(
app_id="automation_status",
name="Automationen",
icon="Workflow",
description="Aktive und inaktive Automations-Definitionen auf einen Blick.",
permission="automation:read",
settings_schema={
"fields": [
{"name": "max_items", "label": "Max. Einträge", "type": "number", "default": 6},
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/AutomationStatusWidget",
order=100,
),
],
permissions=[
"automation:read",
"automation:write",
@@ -82,7 +102,7 @@ class AutomationPlugin(BasePlugin):
path="/workflows",
icon="Workflow",
order=52,
permission="automation:read",
permission="workflows:read",
),
FrontendMenuItem(
label_key="nav.importExport",
@@ -92,6 +112,15 @@ class AutomationPlugin(BasePlugin):
order=53,
permission="import_export:read",
),
# UI-Backlog Modul 7: skills menu item (Phase Q pattern)
FrontendMenuItem(
label_key="nav.skills",
label="Skills",
path="/skills",
icon="Sparkles",
order=54,
permission="automation:read",
),
FrontendMenuItem(
label_key="nav.dedupMerge",
label="Duplikate",
@@ -117,24 +146,15 @@ class AutomationPlugin(BasePlugin):
permission="contacts:read",
),
],
# Phase Q1: /agents and /automation are served by the static
# StartLayout hub trees (sub-navigation). Flat manifest entries for
# them were dead duplicates (never matched) and were removed.
page_routes=[
FrontendPageRoute(
path="/automation",
component="@/pages/AutomationDashboard",
order=50,
permission="automation:read",
),
FrontendPageRoute(
path="/agents",
component="@/pages/AgentDashboard",
order=51,
permission="agents:read",
),
FrontendPageRoute(
path="/workflows",
component="@/pages/Workflows",
order=52,
permission="automation:read",
permission="workflows:read",
),
FrontendPageRoute(
path="/import-export",
@@ -142,6 +162,14 @@ class AutomationPlugin(BasePlugin):
order=53,
permission="import_export:read",
),
# UI-Backlog Modul 7 (2026-09-13): skills definitions page,
# registered via the manifest (Phase Q pattern).
FrontendPageRoute(
path="/skills",
component="@/pages/Skills",
order=54,
permission="automation:read",
),
],
settings_pages=[
FrontendSettingsPage(
@@ -224,22 +252,10 @@ class AutomationPlugin(BasePlugin):
self._register_workflow_agent_tools()
except Exception:
logger.exception("Failed to register workflow agent tools")
# Register MiniApps from manifest
try:
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
registry = get_miniapp_registry()
for miniapp in self.manifest.miniapps:
registry.register(
app_id=miniapp.app_id,
name=miniapp.name,
icon=miniapp.icon,
description=miniapp.description,
plugin_name=self.manifest.name,
render_schema=miniapp.render_schema,
)
logger.info("Registered MiniApp '%s' from manifest", miniapp.app_id)
except Exception:
logger.exception("Failed to register MiniApps from manifest")
# NOTE: Manifest MiniApps are registered by super().on_activate()
# (BasePlugin, Phase M1) WITH all fields (permission, component,
# settings_schema). The legacy re-registration here dropped those
# fields and overwrote the correct entries — removed (M5 fix).
# Register own cron jobs from manifest
try:
await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
@@ -2,6 +2,11 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
from app.plugins.builtins.contracts import get_contract_registry
@@ -15,6 +20,68 @@ class CalendarContract:
CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink
@staticmethod
async def dsar_collect(
db: AsyncSession, tenant_id: Any, user_id: Any
) -> dict[str, Any]:
"""GDPR Art. 15: collect calendar entries owned by the user."""
cal_entries = (
await db.execute(
select(CalendarEntry).where(
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.owner_id == user_id,
CalendarEntry.deleted_at.is_(None),
).limit(1000)
)
).scalars().all()
return {
"calendar_entries": [
{
"id": str(e.id),
"title": e.title,
"entry_type": e.entry_type,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
}
for e in cal_entries
]
}
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des calendar-Moduls für den Workspace-Editor (N1)."""
return [
{
"module_key": "calendar",
"dimensions": [
{
"key": "calendar_ids",
"label": "Kalender",
"control": "multiselect",
"value_source": {
"endpoint": "/api/v1/calendars",
"items_path": "",
"value_key": "id",
"label_key": "name",
},
},
{
"key": "default_view",
"label": "Standard-Ansicht",
"control": "select",
"options": [
{"value": "day", "label": "Tag"},
{"value": "week", "label": "Woche"},
{"value": "month", "label": "Monat"},
{"value": "range", "label": "Zeitraum"},
],
},
],
}
]
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
+2
View File
@@ -64,6 +64,8 @@ class CalendarPlugin(BasePlugin):
],
page_routes=[
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'),
# Q1: kanban view was static-only before - now manifest-declared.
FrontendPageRoute(path='/calendar/kanban', component='@/pages/CalendarKanban', protected=True, permission='calendar:read'),
],
# BUG (ghost component): ContactCalendarTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
+28 -3
View File
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import get_current_user, require_admin, require_permission
from app.deps import get_current_user, require_admin, require_permission, require_workspace_scope
from app.plugins.builtins.calendar.ics_utils import (
export_entries_to_ics,
ics_events_to_entry_data,
@@ -168,8 +168,13 @@ async def _check_write_permission(
async def list_calendars(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
):
"""AC1: GET /api/v1/calendars → 200 + calendar list."""
"""AC1: GET /api/v1/calendars → 200 + calendar list.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (calendar subsets) never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(Calendar).where(
@@ -178,6 +183,13 @@ async def list_calendars(
)
)
cals = result.scalars().all()
# Phase N3: workspace scope — calendar picker restriction
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
if calendar_scope is not None:
cals = [c for c in cals if c.id in calendar_scope]
return [_calendar_to_dict(c) for c in cals]
@@ -359,8 +371,13 @@ async def list_entries(
end: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
):
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (calendar subsets) never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
@@ -370,6 +387,14 @@ async def list_entries(
CalendarEntry.deleted_at.is_(None),
)
# Phase N3: workspace scope — calendar subsets, pure AND
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
if calendar_scope is not None:
query = query.where(CalendarEntry.calendar_id.in_(calendar_scope))
# Filter private entries: only owner + admin can see
if role != "admin":
query = query.where(
+413
View File
@@ -50,6 +50,352 @@ class ContactsContract:
"total": results[0],
}
@staticmethod
async def dsar_collect(
db: AsyncSession, tenant_id: Any, user_id: Any
) -> dict[str, Any]:
"""GDPR Art. 15: collect contact data owned by the user.
Owned by the contacts plugin core/jobs.py calls this generically
via the contract, it must not know contact internals.
"""
contacts = (
await db.execute(
select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.owner_id == user_id,
Contact.deleted_at.is_(None),
)
)
).scalars().all()
return {
"contacts": [
{
"id": str(c.id),
"type": c.type,
"displayname": c.displayname,
"email_1": c.email_1,
"email_2": c.email_2,
}
for c in contacts
]
}
@staticmethod
async def dsar_erase(
db: AsyncSession, tenant_id: Any, user_id: Any
) -> dict[str, int]:
"""GDPR Art. 17: soft-delete contacts owned by the user.
Soft-delete via deleted_at (audit history must remain intact it is
a business record, not personal data of the subject; retention
policy governs its cleanup).
"""
from datetime import UTC, datetime
contacts = (
await db.execute(
select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.owner_id == user_id,
Contact.deleted_at.is_(None),
)
)
).scalars().all()
for c in contacts:
c.deleted_at = datetime.now(UTC)
return {"contacts_soft_deleted": len(contacts)}
# ─── Import/Export contribution (W4a, Spec #359) ───
# The contacts plugin owns its import/export domain logic; the core
# orchestrator resolves formats via the format registry and enforces
# the security policy (sensitive filter, tenant scoping, audit).
IE_COLUMNS = {
"contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"],
"companies": ["name", "industry", "phone", "email", "website"],
}
IE_TARGET_FIELDS = {
"contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"],
"companies": ["name", "industry", "phone", "email", "website"],
}
IE_VALIDATORS = {
"contacts": {"email": {"type": "email"}},
"companies": {"email": {"type": "email"}, "website": {"type": "url"}},
}
@staticmethod
def importexport_entities() -> list[str]:
"""Entity types offered by this plugin's import/export."""
return ["contacts", "companies"]
@staticmethod
def importexport_formats() -> list[str]:
"""File formats this plugin's import/export supports."""
return ["csv", "json", "xlsx"]
@staticmethod
def ie_columns(entity_type: str) -> list[str]:
return list(ContactsContract.IE_COLUMNS[entity_type])
@staticmethod
def ie_target_fields(entity_type: str) -> list[str]:
return list(ContactsContract.IE_TARGET_FIELDS[entity_type])
@staticmethod
def ie_validators(entity_type: str) -> dict[str, dict]:
return dict(ContactsContract.IE_VALIDATORS[entity_type])
@staticmethod
def ie_normalize_row(entity_type: str, row: dict[str, str]) -> dict[str, str]:
"""Normalize an imported row to unified field names."""
if entity_type == "contacts":
firstname = (row.get("firstname") or row.get("first_name") or "").strip()
surname = (row.get("surname") or row.get("last_name") or "").strip()
row["firstname"] = firstname
row["surname"] = surname
if "email" not in row and "email_address" in row:
row["email"] = row["email_address"]
if "mobile" not in row and "phone_2" in row:
row["mobile"] = row["phone_2"]
if "function" not in row and "position" in row:
row["function"] = row["position"]
return row
name = (row.get("name") or row.get("company") or row.get("company_name") or "").strip()
row["name"] = name
if "email" not in row and "email_address" in row:
row["email"] = row["email_address"]
if "website" not in row and "url" in row:
row["website"] = row["url"]
if "website" not in row and "homepage" in row:
row["website"] = row["homepage"]
return row
@staticmethod
def ie_required(entity_type: str) -> list[str]:
"""Required columns enforced by generic validate_row (old semantics)."""
return ["name"] if entity_type == "companies" else []
@staticmethod
def ie_row_valid(entity_type: str, row: dict[str, str]) -> tuple[bool, str]:
"""Early either-or check for contacts only.
NOTE: companies' required-name check must NOT happen here —
generic validate_row(row, ['name'], validators) must see the row
so a missing name AND an invalid email yield two errors (as the
original semantics did).
"""
if entity_type == "contacts":
if not row.get("firstname") and not row.get("surname"):
return False, "Missing required field: firstname or surname"
return True, ""
@staticmethod
async def ie_fetch_rows(
db: AsyncSession,
tenant_id: Any,
entity_type: str,
user_id: Any = None,
is_system_admin: bool = False,
contact_type: str | None = None,
search: str | None = None,
) -> tuple[list[str], list[dict[str, Any]]]:
"""Fetch export rows (headers + row dicts), visibility-filtered.
Optional filters mirror the former export_service.py semantics:
- contact_type: 'company' or 'person' (None = both)
- search: FTS full-text search via contacts.search_tsv
"""
from app.core.sensitive_data import get_sensitive_fields
q = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if entity_type == "companies":
q = q.where(Contact.type == "company").order_by(Contact.name)
else:
if contact_type:
q = q.where(Contact.type == contact_type)
q = q.order_by(Contact.surname, Contact.firstname)
if search:
q = q.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
if user_id:
q = await apply_visibility_filter(
db, q, "contact", Contact, user_id, tenant_id, is_system_admin
)
records = (await db.execute(q)).scalars().all()
# Sensitive-data safety net (core policy) — drop sensitive headers
sensitive = get_sensitive_fields("contact")
if entity_type == "companies":
all_headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"]
export_headers = [h for h in all_headers if h not in sensitive]
rows = [
{h: (getattr(c, h, None) or "") for h in export_headers}
for c in records
]
else:
# Original export_service.py profile (test_performance.py contract):
# 17 columns incl. displayname, code, email_1/email_2, phone_1/phone_2,
# website, mailing_*, vat_code, tags — NOT the import profile.
all_headers = [
"id", "type", "displayname", "name", "firstname", "surname", "code",
"email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_city", "mailing_postalcode", "mailing_country",
"vat_code", "tags",
]
export_headers = [h for h in all_headers if h not in sensitive]
rows = [
{h: (getattr(c, h, None) or "") for h in export_headers}
for c in records
]
return export_headers, rows
@staticmethod
async def ie_persist_row(
db: AsyncSession,
tenant_id: Any,
user_id: Any,
entity_type: str,
row: dict[str, str],
) -> dict[str, Any]:
"""Persist one imported row as Contact; returns the serialized record."""
from app.core.audit import log_audit
from app.services.contact_service import _serialize_contact
if entity_type == "companies":
contact = Contact(
tenant_id=tenant_id,
type="company",
name=row["name"].strip(),
displayname=row["name"].strip(),
email_1=row.get("email", "").strip() or None,
phone_1=row.get("phone", "").strip() or None,
website=row.get("website", "").strip() or None,
owner_id=user_id,
created_by=user_id,
updated_by=user_id,
)
else:
contact = Contact(
tenant_id=tenant_id,
type="person",
firstname=row["firstname"].strip() or None,
surname=row["surname"].strip() or None,
displayname=f"{row['firstname']} {row['surname']}".strip(),
email_1=row.get("email", "").strip() or None,
phone_1=row.get("phone", "").strip() or None,
phone_2=row.get("mobile", "").strip() or None,
owner_id=user_id,
created_by=user_id,
updated_by=user_id,
)
db.add(contact)
await db.flush()
await log_audit(
db,
tenant_id,
user_id,
"import",
"contact",
contact.id,
changes={
"type": entity_type.rstrip("s"),
"name": contact.name or f"{contact.firstname} {contact.surname}",
},
)
return _serialize_contact(contact)
# ─── Document Generator contribution (Phase L1, #359 pattern) ───
# The documents generator resolves placeholders + entity data via these
# contract hooks. Same philosophy as importexport_entities(): the module
# owns its domain data, the generic renderer stays module-agnostic.
@staticmethod
def document_entity_types() -> list[str]:
"""Entity types this plugin serves in the documents generator."""
return ["contact", "company", "person"]
@staticmethod
def document_placeholders(entity_type: str) -> list[dict]:
"""Placeholder descriptors (key/label/example) for the drag/drop editor."""
return _placeholders_for(entity_type)
@staticmethod
async def document_data(
db: AsyncSession,
tenant_id: Any,
entity_id: Any,
entity_type: str,
) -> dict[str, Any]:
"""Load one entity as template data ({} when not found)."""
contact = (
await db.execute(
select(Contact).where(
Contact.id == entity_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if contact is None:
return {}
fields = _contacts_document_fields()
data: dict[str, Any] = {}
for key in fields:
value = getattr(contact, key, None)
data[key] = value if value is not None else ""
return data
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
# Declares the scope dimensions the contacts module supports; the N2
# workspace editor renders its filter UI from these definitions. Scope
# VALUES live per workspace in workspace_modules.config (JSONB).
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des contacts-Moduls für den Workspace-Editor."""
return [
{
"module_key": "contacts",
"dimensions": [
{
"key": "folder_ids",
"label": "Kontakt-Ordner",
"control": "multiselect",
"value_source": {
"endpoint": "/api/v1/contact-folders",
"items_path": "items",
"value_key": "id",
"label_key": "name",
},
},
{
"key": "contact_types",
"label": "Kontakt-Typen",
"control": "multiselect",
"options": [
{"value": "company", "label": "Firmen"},
{"value": "person", "label": "Personen"},
],
},
{
"key": "default_saved_view_id",
"label": "Standard-Ansicht",
"control": "select",
"value_source": {
"endpoint": "/api/v1/saved-views?entity_type=contact",
"items_path": "",
"value_key": "id",
"label_key": "name",
},
},
],
}
]
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
@@ -60,3 +406,70 @@ class ContactsContract:
_contract = ContactsContract()
get_contract_registry().register("contacts", _contract)
def _contacts_document_fields() -> dict[str, str]:
"""Contact/company fields available in document templates (L1).
Keys map to Contact model attributes; labels/examples feed the
drag/drop editor palette and the preview fallback values.
"""
return {
"displayname": "Anzeigename",
"firstname": "Vorname",
"surname": "Nachname",
"name": "Firmenname",
"email": "E-Mail",
"email_1": "E-Mail 1",
"email_2": "E-Mail 2",
"phone": "Telefon",
"phone_1": "Telefon 1",
"phone_2": "Telefon 2",
"mobile": "Mobil",
"website": "Website",
"industry": "Branche",
"city": "Stadt",
"postalcode": "PLZ",
"country": "Land",
"vat_code": "USt-IdNr.",
"function": "Funktion",
"department": "Abteilung",
}
_CONTACT_DOC_EXAMPLES = {
"displayname": "Max Mustermann",
"firstname": "Max",
"surname": "Mustermann",
"name": "Muster GmbH",
"email": "max@example.com",
"email_1": "max@example.com",
"email_2": "buero@example.com",
"phone": "+49 30 123456",
"phone_1": "+49 30 123456",
"phone_2": "+49 171 1234567",
"mobile": "+49 171 1234567",
"website": "https://example.com",
"industry": "IT",
"city": "Berlin",
"postalcode": "10115",
"country": "Deutschland",
"vat_code": "DE123456789",
"function": "Geschäftsführer",
"department": "Vertrieb",
}
def _placeholders_for(entity_type: str) -> list[dict]:
"""Placeholder descriptors for contact/company templates."""
if entity_type not in ("contact", "company", "person"):
return []
fields = _contacts_document_fields()
result = []
for key, label in fields.items():
result.append({
"key": key,
"label": label,
"example": _CONTACT_DOC_EXAMPLES.get(key, ""),
})
return result
+64
View File
@@ -0,0 +1,64 @@
"""ARQ background jobs for the contacts plugin.
Registered via ``register_job()`` at import time; the worker discovers this
module through ``ContactsPlugin.get_job_modules()`` the core worker must
not import contact models directly (audit P2: hidden core->contacts
coupling in the trash cleanup).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.job_registry import register_job
logger = logging.getLogger(__name__)
_TRASH_RETENTION_DAYS = 90
async def cleanup_contacts_trash_job(ctx: dict[str, Any]) -> None:
"""Permanently delete soft-deleted contacts older than the retention window.
Runs daily. Iterates per-tenant for RLS compliance.
Moved from app.core.worker.cleanup_trash_job (audit P2) so the core
worker only handles core-owned entities (entity_attachments).
"""
from app.core.db import get_worker_session_factory
from app.models.contact import Contact
factory = get_worker_session_factory()
async with factory() as db:
try:
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result]
cutoff = datetime.now(UTC) - timedelta(days=_TRASH_RETENTION_DAYS)
total_deleted = 0
for tenant_id in tenant_ids:
await db.execute(
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
{"tid": str(tenant_id)},
)
result = await db.execute(
sa_delete(Contact).where(
Contact.deleted_at.is_not(None),
Contact.deleted_at < cutoff,
)
)
total_deleted += result.rowcount
await db.commit()
if total_deleted:
logger.info("Contacts trash cleanup: permanently deleted %d old contacts", total_deleted)
except Exception:
logger.error("Contacts trash cleanup failed", exc_info=True)
await db.rollback()
register_job("cleanup_contacts_trash", cleanup_contacts_trash_job)
+262
View File
@@ -0,0 +1,262 @@
"""Unified Contact model - company or person, with inline addresses.
Plugin-owned since Paket 6 (#357): this module is the physical home of the
Contact/ContactPerson ORM models. app/models/contact.py re-exports them
for backwards compatibility (Alembic env.py, Core services, tests).
Based on Rentman's contact model: a single table with type field
('company' or 'person'). ContactPerson is a 1:N child for
ansprechpartner (company employees / contact persons).
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Computed,
DateTime,
Float,
ForeignKey,
Index,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class Contact(Base, TenantMixin, OwnedMixin):
"""Unified contact entity — can be a company or a person.
type='company': name is the company name, firstname/surname empty.
type='person': firstname/surname are the person's name, name empty.
Both types can have contactpersons (1:N) and inline addresses
(mailing, visit, invoice).
"""
__tablename__ = "contacts"
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_type", "tenant_id", "type"),
Index("ix_contacts_tenant_name", "tenant_id", "name"),
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
Index("ix_contacts_email", "email_1"),
Index("ix_contacts_code", "code"),
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity & Type ──
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
# ── Customer / Accounting ──
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Mailing Address (inline) ──
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Visit Address (inline) ──
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Invoice Address (inline) ──
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── General country ──
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Communication ──
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
# ── Financial & Tax ──
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
# ── Discounts ──
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
# ── Geo ──
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ── Notes & Warnings ──
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
# ── Default contact persons (self-referential via contactpersons table) ──
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
# ── Folder assignment ──
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── FTS ──
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
Computed(
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
persisted=True,
),
nullable=True,
)
# ── Embedding (pgvector, 768-dim) ──
from pgvector.sqlalchemy import Vector
embedding: Mapped[Any | None] = mapped_column(
Vector(768), nullable=True, default=None
)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Relationships ──
contact_persons: Mapped[list[ContactPerson]] = relationship(
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
)
class ContactPerson(Base, TenantMixin, OwnedMixin):
"""Ansprechpartner — 1:N child of a Contact.
Represents a person working at / associated with a company contact.
Has its own address and communication fields.
"""
__tablename__ = "contactpersons"
__table_args__ = (
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contactpersons_contact", "contact_id"),
Index("ix_contactpersons_email", "email"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Parent contact ──
contact_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
)
# ── Name ──
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
# ── Communication ──
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── Own address ──
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Other ──
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Relationship ──
contact: Mapped[Contact] = relationship(
back_populates="contact_persons", foreign_keys=[contact_id]
)
# Keep old names for backward compat during migration
+86
View File
@@ -10,7 +10,11 @@ import logging
from app.plugins.base import BasePlugin
from app.plugins.manifest import (
FieldDefinition,
FrontendDashboardWidget,
FrontendMenuItem,
FrontendPageRoute,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -58,6 +62,26 @@ class ContactsPlugin(BasePlugin):
],
events=[],
migrations=[],
miniapps=[
MiniAppContribution(
app_id="contacts_stats",
name="Kontakt-Zähler",
icon="Building2",
description="Firmen- und Kontakt-Zähler (persönliche StatCards).",
permission="contacts:read",
settings_schema={
"fields": [
{"name": "show_companies", "label": "Firmen anzeigen", "type": "boolean", "default": True},
{"name": "show_persons", "label": "Personen anzeigen", "type": "boolean", "default": True},
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/ContactsStatsWidget",
order=5,
),
],
dashboard_widgets=[
FrontendDashboardWidget(
id="recent_contacts",
@@ -70,23 +94,85 @@ class ContactsPlugin(BasePlugin):
permission="contacts:read",
),
],
menu_items=[
FrontendMenuItem(label_key='nav.contacts', label='Kontakte', path='/contacts', icon='Users', order=10, permission='contacts:read'),
FrontendMenuItem(label_key='nav.companies', label='Firmen', path='/companies', icon='Building2', order=11, permission='contacts:read'),
],
page_routes=[
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
FrontendPageRoute(path='/companies', component='@/pages/Companies', protected=True, permission='contacts:read'),
],
permissions=[
"contacts:read",
"contacts:write",
"contacts:delete",
],
# Audit P1/P2: contact field definitions are plugin-owned (moved
# from CORE_FIELD_DEFINITIONS) — registered at activation time via
# register_field_definitions() and removed on deactivation.
field_definitions=[
FieldDefinition(module="contacts", field="firstname", label="First Name", sensitivity="normal"),
FieldDefinition(module="contacts", field="surname", label="Last Name", sensitivity="normal"),
FieldDefinition(module="contacts", field="displayname", label="Display Name", sensitivity="normal"),
FieldDefinition(module="contacts", field="name", label="Name", sensitivity="normal"),
FieldDefinition(module="contacts", field="email_1", label="Email 1", sensitivity="normal"),
FieldDefinition(module="contacts", field="email_2", label="Email 2", sensitivity="normal"),
FieldDefinition(module="contacts", field="phone_1", label="Phone 1", sensitivity="normal"),
FieldDefinition(module="contacts", field="phone_2", label="Phone 2", sensitivity="normal"),
FieldDefinition(module="contacts", field="mobilephone", label="Mobile", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="function", label="Position", sensitivity="normal"),
FieldDefinition(module="contacts", field="website", label="Website", sensitivity="normal"),
FieldDefinition(module="contacts", field="status", label="Status", sensitivity="normal"),
FieldDefinition(module="contacts", field="type", label="Type", sensitivity="normal"),
FieldDefinition(module="contacts", field="gender", label="Gender", sensitivity="normal"),
FieldDefinition(module="contacts", field="suffix", label="Suffix", sensitivity="normal"),
FieldDefinition(module="contacts", field="ext_name_line", label="Extra Name Line", sensitivity="normal"),
FieldDefinition(module="contacts", field="country", label="Country", sensitivity="normal"),
FieldDefinition(module="contacts", field="code", label="Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="accounting_code", label="Accounting Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="vendor_accounting_code", label="Vendor Accounting Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="vat_code", label="VAT Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="fiscal_code", label="Fiscal Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="commerce_code", label="Commerce Code", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="purchase_number", label="Purchase Number", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="bic", label="BIC", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="mailing_street", label="Mailing Street", sensitivity="normal"),
FieldDefinition(module="contacts", field="mailing_city", label="Mailing City", sensitivity="normal"),
FieldDefinition(module="contacts", field="mailing_postalcode", label="Mailing Postal Code", sensitivity="normal"),
FieldDefinition(module="contacts", field="mailing_country", label="Mailing Country", sensitivity="normal"),
FieldDefinition(module="contacts", field="visit_street", label="Visit Street", sensitivity="normal"),
FieldDefinition(module="contacts", field="visit_city", label="Visit City", sensitivity="normal"),
FieldDefinition(module="contacts", field="visit_postalcode", label="Visit Postal Code", sensitivity="normal"),
FieldDefinition(module="contacts", field="visit_country", label="Visit Country", sensitivity="normal"),
FieldDefinition(module="contacts", field="invoice_street", label="Invoice Street", sensitivity="normal"),
FieldDefinition(module="contacts", field="invoice_city", label="Invoice City", sensitivity="normal"),
FieldDefinition(module="contacts", field="invoice_postalcode", label="Invoice Postal Code", sensitivity="normal"),
FieldDefinition(module="contacts", field="invoice_country", label="Invoice Country", sensitivity="normal"),
FieldDefinition(module="contacts", field="notes", label="Notes", sensitivity="sensitive"),
FieldDefinition(module="contacts", field="tags", label="Tags", sensitivity="sensitive"),
],
is_core=True,
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
def get_job_modules(self) -> list[str]:
"""Worker discovers the contacts trash-cleanup job here (audit P2)."""
return ["app.plugins.builtins.contacts.jobs"]
def get_entity_models(self) -> dict[str, type]:
from app.models.contact import Contact
from app.models.contact_folder import ContactFolder
return {
"contact": Contact,
"contacts": Contact,
"company": Contact,
# Audit P2: contact_folder is contacts-plugin-owned domain data
# (moved from the static core ENTITY_MODELS map).
"contact_folder": ContactFolder,
}
async def on_activate(self, db, service_container, event_bus) -> None:
+204 -5
View File
@@ -13,6 +13,7 @@ from typing import Any
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.contact_commands import (
@@ -23,7 +24,10 @@ from app.commands.contact_commands import (
)
from app.core.db import get_db
from app.core.visibility import check_single_entity_access
from app.deps import get_redis_dep, require_permission
from app.deps import get_current_user, get_redis_dep, require_permission, require_workspace_scope
from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry
from app.schemas.contact import (
ContactCreate,
ContactPersonCreate,
@@ -31,7 +35,6 @@ from app.schemas.contact import (
ContactUpdate,
)
from app.services import contact_service, dedup_service
from app.services.export_service import export_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
@@ -67,10 +70,13 @@ async def list_contacts(
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("contacts")),
):
"""List contacts with pagination, FTS search, type/folder filter, sorting.
Supports keyset pagination via ``cursor`` parameter for large datasets.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (folder subtree + contact types) never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -84,6 +90,7 @@ async def list_contacts(
user_id=user_id,
is_system_admin=is_admin,
cursor=cursor,
workspace_scope=workspace_scope,
)
@@ -95,14 +102,20 @@ async def export_contacts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Stream contacts as CSV."""
"""Stream contacts as CSV (W4c: via ContactsContract, export_service.py removed)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
csv_data = await export_service.export_contacts_csv(
db, tenant_id, contact_type=type, search=search,
from app.plugins.builtins.contacts.contracts import ContactsContract
headers, rows = await ContactsContract.ie_fetch_rows(
db, tenant_id, "contacts",
user_id=user_id, is_system_admin=is_admin,
contact_type=type, search=search,
)
from app.services.import_export_helpers import write_csv
csv_data = write_csv(rows, headers).decode("utf-8")
return StreamingResponse(
io.StringIO(csv_data),
media_type="text/csv",
@@ -316,3 +329,189 @@ async def merge_duplicate_contacts(
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return result.data
# ─── Custom Fields (W4c: migrated from app/routes/custom_fields.py) ─────────
class CustomFieldUpdateRequest(BaseModel):
"""Request body for updating custom field values."""
values: dict[str, Any] = {}
async def _collect_custom_field_definitions(
db: AsyncSession,
tenant_id: uuid.UUID,
entity: str = "contact",
) -> list[dict[str, Any]]:
"""Collect all custom field definitions from plugin manifests and DB.
DB-stored definitions override plugin definitions with the same name.
"""
definitions: list[dict[str, Any]] = []
seen_names: set[str] = set()
# 1. Collect from active plugin manifests
registry = get_registry()
for name in registry.list_discovered():
plugin = registry.get_plugin(name)
if plugin is None:
continue
manifest = plugin.manifest
for cf in manifest.custom_fields:
if cf.entity != entity:
continue
if cf.name in seen_names:
continue
seen_names.add(cf.name)
definitions.append(
{
"name": cf.name,
"label": cf.label,
"label_key": cf.label_key,
"field_type": cf.field_type,
"options": cf.options,
"default_value": cf.default_value,
"required": cf.required,
"entity": cf.entity,
"plugin": manifest.name,
}
)
# 2. Collect from DB (user-defined custom field definitions)
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.entity == entity,
CustomFieldDefinition.is_active == True, # noqa: E712
).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(stmt)
db_definitions = result.scalars().all()
for d in db_definitions:
if d.name in seen_names:
# DB definition overrides plugin definition — replace it
definitions = [x for x in definitions if x["name"] != d.name]
else:
seen_names.add(d.name)
definitions.append(
{
"name": d.name,
"label": d.label,
"label_key": "",
"field_type": d.field_type,
"options": d.options or [],
"default_value": d.default_value,
"required": d.required,
"entity": d.entity,
"plugin": "user_defined",
}
)
return definitions
async def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults."""
stored = stored or {}
result: list[dict[str, Any]] = []
for d in definitions:
name = d["name"]
value = stored.get(name, d.get("default_value"))
entry = {**d, "value": value}
result.append(entry)
return result
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
async def get_custom_fields(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all custom fields for a contact (merged definitions + stored values)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
async def update_custom_fields(
contact_id: str,
body: CustomFieldUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# Validate against definitions
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
def_map = {d["name"]: d for d in definitions}
current_custom = dict(contact.custom or {})
for name, value in body.values.items():
if name not in def_map:
raise HTTPException(
400,
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
)
field_def = def_map[name]
# Validate required
if field_def["required"] and (value is None or value == ""):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
)
# Validate select/multiselect options
if field_def["field_type"] == "select" and value is not None:
if value not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
)
if field_def["field_type"] == "multiselect" and value is not None:
if not isinstance(value, list):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
)
for v in value:
if v not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
)
current_custom[name] = value
contact.custom = current_custom
await db.flush()
merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
+38 -3
View File
@@ -60,6 +60,9 @@ class ContractRegistry:
cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set()
cls._instance._unregistered: set[str] = set()
# Plugins whose DB record says active=False (audit restart edge
# case) — marked once at API startup, see main.py lifespan.
cls._instance._db_inactive: set[str] = set()
return cls._instance
# ─── registration ───
@@ -92,20 +95,51 @@ class ContractRegistry:
On first access the registry attempts to lazy-load the plugin's
``contracts`` module, which will register itself on import.
"""
if plugin_name in self._contracts:
return self._contracts[plugin_name]
Audit P1 (contract lazy loading): the DB activation state is checked
BEFORE serving or lazy-loading. A plugin that was already inactive
when the process started never lands in ``_unregistered`` (it was
never deactivated at runtime), so the old guard alone let the lazy
loader import its contracts module and resurrect the contract.
The permission registry mirrors ``PluginModel.active`` at startup,
so an inactive plugin fails closed here. When the permission
registry is NOT initialized (worker process, early bootstrap)
the legacy lazy-load behaviour is kept.
"""
# Explicitly unregistered (deactivated): never resurrect via
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
if plugin_name in self._unregistered:
return None
# DB activation guard (audit restart edge case): plugins whose DB
# record was already inactive when the process started never land in
# _unregistered (they were never deactivated at runtime), so lazy
# loading could resurrect their contracts. main.py marks them once
# at startup; activation clears the marker again.
if plugin_name in self._db_inactive:
return None
if plugin_name in self._contracts:
return self._contracts[plugin_name]
if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name)
return self._contracts.get(plugin_name)
def mark_db_inactive(self, plugin_names: set[str]) -> None:
"""Mark plugins as DB-inactive (startup, audit restart edge case).
Called once from main.py lifespan with the names of plugins whose DB
record has active=False. get_contract() fails closed for these.
"""
self._db_inactive.update(plugin_names)
def mark_plugin_active(self, plugin_name: str) -> None:
"""Clear inactive markers (plugin activated/reinstalled at runtime)."""
self._db_inactive.discard(plugin_name)
self._unregistered.discard(plugin_name)
def require_contract(self, plugin_name: str) -> Any:
"""Like :meth:`get_contract` but raise if unavailable."""
contract = self.get_contract(plugin_name)
@@ -148,6 +182,7 @@ class ContractRegistry:
"""Clear all state — for unit tests only."""
self._contracts.clear()
self._loaded.clear()
self._db_inactive.clear()
# ─── module-level helpers ───
+111
View File
@@ -0,0 +1,111 @@
"""DMS gemeinsame Helper & Konstanten — BUG-018 God-Object-Split."""
from __future__ import annotations
import os
import uuid
from fastapi import HTTPException
OFFICE_EXTENSIONS = {
".docx": "docx",
".xlsx": "xlsx",
".pptx": "pptx",
}
# Max file size: 100 MB
MAX_FILE_SIZE = 100 * 1024 * 1024
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
"""Build relative storage path for a file (relative to storage base)."""
return f"{tenant_id}/{file_id}"
def _get_file_extension(filename: str) -> str:
"""Extract lowercase extension including dot."""
return os.path.splitext(filename)[1].lower()
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
# Extract basename only (strip any path components)
safe = os.path.basename(filename.replace('\\', '/'))
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
# Collapse consecutive dots (path traversal prevention)
safe = re.sub(r'\.{2,}', '_', safe)
# Collapse multiple spaces
safe = re.sub(r' {2,}', ' ', safe)
# Strip leading dots and whitespace
safe = safe.lstrip('.').strip()
# Limit length
if len(safe) > 200:
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
safe = name[:200] + ('.' + ext if ext else '')
return safe or 'file'
# Blocked file extensions for security
BLOCKED_EXTENSIONS = {
".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi",
".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf",
".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess",
".phtml", ".pht", ".cgi", ".cfm", ".erb",
}
# Allowed MIME types for upload validation
ALLOWED_MIME_PREFIXES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument",
"application/vnd.oasis.opendocument",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/zip",
"application/gzip",
"application/x-tar",
"application/json",
"application/xml",
"application/rtf",
"application/x-7z-compressed",
"application/x-rar-compressed",
"text/plain",
"text/csv",
"text/html",
"text/markdown",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/x-icon",
"audio/",
"video/",
"application/octet-stream",
}
def _is_blocked_filetype(filename: str) -> bool:
"""Check if a file has a blocked (dangerous) extension."""
ext = os.path.splitext(filename)[1].lower()
return ext in BLOCKED_EXTENSIONS
chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads
# ─── Folders ───
# Public stream-chunk constant (original contract name from tests/test_p1_6_dms_streaming.py)
CHUNK_SIZE = chunk_size
+34
View File
@@ -15,6 +15,40 @@ class DmsContract:
DmsFile = DmsFile
Folder = Folder
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des dms-Moduls für den Workspace-Editor (N1)."""
return [
{
"module_key": "dms",
"dimensions": [
{
"key": "folder_ids",
"label": "DMS-Ordner",
"control": "multiselect",
"value_source": {
"endpoint": "/api/v1/dms/folders",
"items_path": "",
"value_key": "id",
"label_key": "name",
},
},
{
"key": "file_types",
"label": "Datei-Typen",
"control": "multiselect",
"options": [
{"value": "application/pdf", "label": "PDF"},
{"value": "image/", "label": "Bilder"},
{"value": "spreadsheet", "label": "Tabellen"},
{"value": "word", "label": "Dokumente"},
{"value": "other", "label": "Sonstige"},
],
},
],
}
]
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
+398
View File
@@ -0,0 +1,398 @@
"""DMS Folder-CRUD Routen — extrahiert aus routes.py (BUG-018 God-Object-Split)."""
from __future__ import annotations
import uuid
from fastapi import (
APIRouter,
Depends,
HTTPException,
Response,
status,
)
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.deps import get_current_user, require_permission, require_workspace_scope
from app.plugins.builtins.dms.common import (
_parse_uuid,
)
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.models import Folder
from app.plugins.builtins.dms.schemas import FolderCreate, FolderUpdate
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
_perms_contract = get_perms_contract()
Permission = _perms_contract.Permission
router = APIRouter(tags=["dms"])
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
async def list_folders(
parent_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
):
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).
Phase N3: an active workspace scope (X-Workspace-ID) reduces the tree to
the folder subtree pure AND-restriction, never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Fetch all non-deleted folders for tenant with visibility filter
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
query = select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
all_folders = result.scalars().all()
# Phase N3: reduce to the scope subtree (folder_ids dimension)
if workspace_scope:
from app.services.workspace_scope_service import expand_folder_scope
scope_folder_ids = workspace_scope.get("folder_ids")
if isinstance(scope_folder_ids, list) and scope_folder_ids:
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
allowed = subtree or set()
all_folders = [f for f in all_folders if f.id in allowed]
# Build lookup map
folder_map: dict[uuid.UUID, dict] = {}
for f in all_folders:
folder_map[f.id] = {
"id": str(f.id),
"name": f.name,
"parent_id": str(f.parent_id) if f.parent_id else None,
"created_by": str(f.created_by),
"deleted_at": None,
"path": "",
"children": [],
}
# Build path for each folder
def _build_path(folder_id: uuid.UUID) -> str:
if folder_id not in folder_map:
return ""
f = folder_map[folder_id]
if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map:
parent_path = _build_path(uuid.UUID(f["parent_id"]))
return f"{parent_path}/{f['name']}"
return f["name"]
for fid in folder_map:
folder_map[fid]["path"] = _build_path(fid)
# Build tree
root_nodes: list[dict] = []
target_parent: uuid.UUID | None = None
if parent_id is not None:
target_parent = _parse_uuid(parent_id, "parent_id")
for f in all_folders:
node = folder_map[f.id]
if f.parent_id is not None and f.parent_id in folder_map:
folder_map[f.parent_id]["children"].append(node)
elif f.parent_id is None:
root_nodes.append(node)
if target_parent is not None:
# Return children of specified parent
parent_node = folder_map.get(target_parent)
if parent_node is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
return parent_node["children"]
return root_nodes
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
async def create_folder(
body: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None
# Validate parent exists if specified
if parent_id is not None:
parent_result = await db.execute(
select(Folder).where(
Folder.id == parent_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if parent_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
# Check name uniqueness within same parent (non-deleted)
existing = await db.execute(
select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.name == body.name,
Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None),
Folder.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
# Lifecycle hook: dms.folder.before_create
from app.core.hooks import do_action
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
folder = Folder(
tenant_id=tenant_id,
name=body.name,
parent_id=parent_id,
created_by=user_id,
)
db.add(folder)
await db.flush()
# Lifecycle hook: dms.folder.after_create
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
# Build path
path = body.name
if parent_id is not None:
parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id))
parent_folder = parent_path_result.scalar_one_or_none()
if parent_folder:
# Recursively build path
path_parts = [body.name]
current = parent_folder
while current is not None:
path_parts.insert(0, current.name)
if current.parent_id is not None:
cur_result = await db.execute(
select(Folder).where(Folder.id == current.parent_id)
)
current = cur_result.scalar_one_or_none()
else:
current = None
path = "/".join(path_parts)
return {
"id": str(folder.id),
"name": folder.name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"created_by": str(folder.created_by),
"deleted_at": None,
"path": path,
"children": [],
}
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
async def update_folder(
folder_id: str,
body: FolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(folder_id, "folder_id")
result = await db.execute(
select(Folder).where(
Folder.id == fid,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
# Check uniqueness if name is changing
new_parent_id = folder.parent_id
if "parent_id" in data and data["parent_id"] is not None:
new_parent_id = _parse_uuid(data["parent_id"], "parent_id")
dup = await db.execute(
select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.name == data["name"],
Folder.id != fid,
Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None),
Folder.deleted_at.is_(None),
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
folder.name = data["name"]
if "parent_id" in data:
new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None
if new_parent is not None:
# Validate parent exists and not creating a cycle
if new_parent == fid:
raise HTTPException(
400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"}
)
parent_result = await db.execute(
select(Folder).where(
Folder.id == new_parent,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if parent_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
# Check for cycle: ensure new_parent is not a descendant of folder
async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool:
cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id))
cur = cur_result.scalar_one_or_none()
while cur is not None and cur.parent_id is not None:
if cur.parent_id == ancestor_id:
return True
p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id))
cur = p_result.scalar_one_or_none()
return False
if await _is_descendant(fid, new_parent):
raise HTTPException(
400,
detail={
"detail": "Cannot move folder into its own descendant",
"code": "invalid_move",
},
)
folder.parent_id = new_parent
await db.flush()
# Build path
path_parts = [folder.name]
current_id = folder.parent_id
while current_id is not None:
cur_result = await db.execute(select(Folder).where(Folder.id == current_id))
cur = cur_result.scalar_one_or_none()
if cur is None:
break
path_parts.insert(0, cur.name)
current_id = cur.parent_id
path = "/".join(path_parts)
return {
"id": str(folder.id),
"name": folder.name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"created_by": str(folder.created_by),
"deleted_at": None,
"path": path,
"children": [],
}
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(folder_id, "folder_id")
result = await db.execute(
select(Folder).where(
Folder.id == fid,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.folder.before_delete
from app.core.hooks import do_action
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
from datetime import UTC, datetime
now = datetime.now(UTC)
# Recursively collect all descendant folder IDs
all_folder_ids: list[uuid.UUID] = [fid]
queue: list[uuid.UUID] = [fid]
while queue:
current_id = queue.pop(0)
children_result = await db.execute(
select(Folder).where(
Folder.parent_id == current_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
for child in children_result.scalars().all():
all_folder_ids.append(child.id)
queue.append(child.id)
# Soft-delete all folders
await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now))
# Soft-delete all files in those folders
await db.execute(
update(DmsFile)
.where(
DmsFile.tenant_id == tenant_id,
DmsFile.folder_id.in_(all_folder_ids),
DmsFile.deleted_at.is_(None),
)
.values(deleted_at=now)
)
await db.flush()
# Lifecycle hook: dms.folder.after_delete
from app.core.hooks import do_action
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Files ───
+25
View File
@@ -6,6 +6,7 @@ from app.plugins.base import BasePlugin
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -19,6 +20,11 @@ class DmsPlugin(BasePlugin):
version="1.0.0",
display_name="DMS",
description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
# Audit P1/P2 (ADR-020): DMS is a platform core plugin — the core schema
# (entity_attachments.files-FK) builds on the DMS files table, so DMS
# cannot be deactivated. Declared is_core=True so the registry enforces
# this instead of the FK being silently invalid.
is_core=True,
dependencies=["permissions"],
routes=[
PluginRouteDef(
@@ -29,6 +35,25 @@ class DmsPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql"],
miniapps=[
MiniAppContribution(
app_id="dms_folders",
name="DMS-Ordner",
icon="FolderOpen",
description="Ordnerübersicht des Dokumentenmanagements mit Dateizählern.",
permission="dms:read",
settings_schema={
"fields": [
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/DmsFoldersWidget",
order=60,
),
],
permissions=[
"dms:read",
"dms:write",
+56 -869
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import os
import uuid
from fastapi import (
APIRouter,
Body,
Depends,
File,
Form,
@@ -17,25 +15,39 @@ from fastapi import (
status,
)
from fastapi.responses import StreamingResponse
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.storage import LocalStorage, get_storage_backend
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.deps import get_current_user, require_permission
from app.deps import get_current_user, require_permission, require_workspace_scope
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
# Re-Exports sichern Import- und Patch-Kompatibilitaet
# (tests patchen app.plugins.builtins.dms.routes.MAX_FILE_SIZE fuer den Upload).
from app.plugins.builtins.dms.common import ( # noqa: F401
ALLOWED_MIME_PREFIXES,
BLOCKED_EXTENSIONS,
CHUNK_SIZE,
MAX_FILE_SIZE,
OFFICE_EXTENSIONS,
_file_storage_path,
_get_file_extension,
_is_blocked_filetype,
_parse_uuid,
_sanitize_filename,
chunk_size,
)
from app.plugins.builtins.dms.folders_routes import router as folders_router
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.models import Folder
from app.plugins.builtins.dms.schemas import (
BulkDeleteRequest,
BulkMoveRequest,
FileMetadataResponse,
FileUpdate,
FolderCreate,
FolderUpdate,
ShareRemoveRequest,
ShareRequest,
)
from app.plugins.builtins.dms.search_bulk_routes import router as search_bulk_router
from app.plugins.builtins.dms.sharing_routes import router as sharing_router
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
# Get Permission model from the permissions contract
@@ -45,460 +57,6 @@ Permission = _perms_contract.Permission
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
# Office file extensions mapped to Collabora file types
OFFICE_EXTENSIONS = {
".docx": "docx",
".xlsx": "xlsx",
".pptx": "pptx",
}
# Max file size: 100 MB
MAX_FILE_SIZE = 100 * 1024 * 1024
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
"""Build relative storage path for a file (relative to storage base)."""
return f"{tenant_id}/{file_id}"
def _get_file_extension(filename: str) -> str:
"""Extract lowercase extension including dot."""
return os.path.splitext(filename)[1].lower()
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
# Extract basename only (strip any path components)
safe = os.path.basename(filename.replace('\\', '/'))
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
# Collapse consecutive dots (path traversal prevention)
safe = re.sub(r'\.{2,}', '_', safe)
# Collapse multiple spaces
safe = re.sub(r' {2,}', ' ', safe)
# Strip leading dots and whitespace
safe = safe.lstrip('.').strip()
# Limit length
if len(safe) > 200:
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
safe = name[:200] + ('.' + ext if ext else '')
return safe or 'file'
# Blocked file extensions for security
BLOCKED_EXTENSIONS = {
".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi",
".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf",
".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess",
".phtml", ".pht", ".cgi", ".cfm", ".erb",
}
# Allowed MIME types for upload validation
ALLOWED_MIME_PREFIXES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument",
"application/vnd.oasis.opendocument",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/zip",
"application/gzip",
"application/x-tar",
"application/json",
"application/xml",
"application/rtf",
"application/x-7z-compressed",
"application/x-rar-compressed",
"text/plain",
"text/csv",
"text/html",
"text/markdown",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/x-icon",
"audio/",
"video/",
"application/octet-stream",
}
def _is_blocked_filetype(filename: str) -> bool:
"""Check if a file has a blocked (dangerous) extension."""
ext = os.path.splitext(filename)[1].lower()
return ext in BLOCKED_EXTENSIONS
chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads
# ─── Folders ───
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
async def list_folders(
parent_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Fetch all non-deleted folders for tenant with visibility filter
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
query = select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
all_folders = result.scalars().all()
# Build lookup map
folder_map: dict[uuid.UUID, dict] = {}
for f in all_folders:
folder_map[f.id] = {
"id": str(f.id),
"name": f.name,
"parent_id": str(f.parent_id) if f.parent_id else None,
"created_by": str(f.created_by),
"deleted_at": None,
"path": "",
"children": [],
}
# Build path for each folder
def _build_path(folder_id: uuid.UUID) -> str:
if folder_id not in folder_map:
return ""
f = folder_map[folder_id]
if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map:
parent_path = _build_path(uuid.UUID(f["parent_id"]))
return f"{parent_path}/{f['name']}"
return f["name"]
for fid in folder_map:
folder_map[fid]["path"] = _build_path(fid)
# Build tree
root_nodes: list[dict] = []
target_parent: uuid.UUID | None = None
if parent_id is not None:
target_parent = _parse_uuid(parent_id, "parent_id")
for f in all_folders:
node = folder_map[f.id]
if f.parent_id is not None and f.parent_id in folder_map:
folder_map[f.parent_id]["children"].append(node)
elif f.parent_id is None:
root_nodes.append(node)
if target_parent is not None:
# Return children of specified parent
parent_node = folder_map.get(target_parent)
if parent_node is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
return parent_node["children"]
return root_nodes
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
async def create_folder(
body: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None
# Validate parent exists if specified
if parent_id is not None:
parent_result = await db.execute(
select(Folder).where(
Folder.id == parent_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if parent_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
# Check name uniqueness within same parent (non-deleted)
existing = await db.execute(
select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.name == body.name,
Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None),
Folder.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
# Lifecycle hook: dms.folder.before_create
from app.core.hooks import do_action
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
folder = Folder(
tenant_id=tenant_id,
name=body.name,
parent_id=parent_id,
created_by=user_id,
)
db.add(folder)
await db.flush()
# Lifecycle hook: dms.folder.after_create
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
# Build path
path = body.name
if parent_id is not None:
parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id))
parent_folder = parent_path_result.scalar_one_or_none()
if parent_folder:
# Recursively build path
path_parts = [body.name]
current = parent_folder
while current is not None:
path_parts.insert(0, current.name)
if current.parent_id is not None:
cur_result = await db.execute(
select(Folder).where(Folder.id == current.parent_id)
)
current = cur_result.scalar_one_or_none()
else:
current = None
path = "/".join(path_parts)
return {
"id": str(folder.id),
"name": folder.name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"created_by": str(folder.created_by),
"deleted_at": None,
"path": path,
"children": [],
}
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
async def update_folder(
folder_id: str,
body: FolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(folder_id, "folder_id")
result = await db.execute(
select(Folder).where(
Folder.id == fid,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
# Check uniqueness if name is changing
new_parent_id = folder.parent_id
if "parent_id" in data and data["parent_id"] is not None:
new_parent_id = _parse_uuid(data["parent_id"], "parent_id")
dup = await db.execute(
select(Folder).where(
Folder.tenant_id == tenant_id,
Folder.name == data["name"],
Folder.id != fid,
Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None),
Folder.deleted_at.is_(None),
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
folder.name = data["name"]
if "parent_id" in data:
new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None
if new_parent is not None:
# Validate parent exists and not creating a cycle
if new_parent == fid:
raise HTTPException(
400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"}
)
parent_result = await db.execute(
select(Folder).where(
Folder.id == new_parent,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if parent_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Parent folder not found", "code": "not_found"}
)
# Check for cycle: ensure new_parent is not a descendant of folder
async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool:
cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id))
cur = cur_result.scalar_one_or_none()
while cur is not None and cur.parent_id is not None:
if cur.parent_id == ancestor_id:
return True
p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id))
cur = p_result.scalar_one_or_none()
return False
if await _is_descendant(fid, new_parent):
raise HTTPException(
400,
detail={
"detail": "Cannot move folder into its own descendant",
"code": "invalid_move",
},
)
folder.parent_id = new_parent
await db.flush()
# Build path
path_parts = [folder.name]
current_id = folder.parent_id
while current_id is not None:
cur_result = await db.execute(select(Folder).where(Folder.id == current_id))
cur = cur_result.scalar_one_or_none()
if cur is None:
break
path_parts.insert(0, cur.name)
current_id = cur.parent_id
path = "/".join(path_parts)
return {
"id": str(folder.id),
"name": folder.name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"created_by": str(folder.created_by),
"deleted_at": None,
"path": path,
"children": [],
}
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(folder_id, "folder_id")
result = await db.execute(
select(Folder).where(
Folder.id == fid,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.folder.before_delete
from app.core.hooks import do_action
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
from datetime import UTC, datetime
now = datetime.now(UTC)
# Recursively collect all descendant folder IDs
all_folder_ids: list[uuid.UUID] = [fid]
queue: list[uuid.UUID] = [fid]
while queue:
current_id = queue.pop(0)
children_result = await db.execute(
select(Folder).where(
Folder.parent_id == current_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
for child in children_result.scalars().all():
all_folder_ids.append(child.id)
queue.append(child.id)
# Soft-delete all folders
await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now))
# Soft-delete all files in those folders
await db.execute(
update(DmsFile)
.where(
DmsFile.tenant_id == tenant_id,
DmsFile.folder_id.in_(all_folder_ids),
DmsFile.deleted_at.is_(None),
)
.values(deleted_at=now)
)
await db.flush()
# Lifecycle hook: dms.folder.after_delete
from app.core.hooks import do_action
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Files ───
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=FileMetadataResponse, dependencies=[Depends(require_permission("dms:write"))])
async def upload_file(
file: UploadFile = File(...),
@@ -547,7 +105,7 @@ async def upload_file(
# Stream file to storage — avoid loading entire file into RAM
import hashlib
chunk_size = 1024 * 1024 # 1MB chunks
chunk_size = 1024 * 1024 # noqa: F811 (Original-Shadowing im Original auch so)
sha256 = hashlib.sha256()
file_size = 0
@@ -687,8 +245,13 @@ async def get_file(
async def list_all_files(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
):
"""List all non-deleted files for the current tenant."""
"""List all non-deleted files for the current tenant.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction folder subtree + file types. Never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
@@ -700,9 +263,32 @@ async def list_all_files(
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
# Phase N3: workspace scope filters (folder subtree + file types)
if workspace_scope:
from app.services.workspace_scope_service import (
DMS_FILE_TYPE_MATCHERS,
expand_folder_scope,
)
scope_folder_ids = workspace_scope.get("folder_ids")
if isinstance(scope_folder_ids, list) and scope_folder_ids:
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
query = query.where(DmsFile.folder_id.in_(subtree or set()))
result = await db.execute(query)
files = result.scalars().all()
# file_types needs Python-side matching (semantic matchers, not SQL-LIKE)
if workspace_scope:
from app.services.workspace_scope_service import DMS_FILE_TYPE_MATCHERS
scope_file_types = workspace_scope.get("file_types")
if isinstance(scope_file_types, list) and scope_file_types:
matchers = [DMS_FILE_TYPE_MATCHERS[t] for t in scope_file_types if t in DMS_FILE_TYPE_MATCHERS]
if matchers:
files = [f for f in files if any(m(f.mime_type) for m in matchers)]
return [
{
"id": str(f.id),
@@ -1087,406 +673,7 @@ async def download_file(
)
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
async def create_edit_session(
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = current_user["user_id"]
user_name = current_user.get("name", "Unknown")
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
result = await db.execute(
select(DmsFile).where(
DmsFile.id == fid,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
ext = _get_file_extension(dms_file.name)
if ext not in OFFICE_EXTENSIONS:
raise HTTPException(
400,
detail={
"detail": "Only Office files (docx, xlsx, pptx) are supported",
"code": "not_office",
},
)
file_type = OFFICE_EXTENSIONS[ext]
download_url = f"/api/v1/dms/files/{fid}/preview"
callback_url = f"/api/v1/dms/files/{fid}/callback"
config = {
"document": {
"fileType": file_type,
"key": str(uuid.uuid4()),
"title": dms_file.name,
"url": download_url,
},
"editorConfig": {
"mode": "edit",
"callbackUrl": callback_url,
"user": {
"id": user_id,
"name": user_name,
},
},
}
return config
# ─── Internal Sharing ───
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
async def share_file(
file_id: str,
body: ShareRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
# Verify file exists
file_result = await db.execute(
select(DmsFile).where(
DmsFile.id == fid,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
if file_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
created_perms: list[dict] = []
for uid_str in body.user_ids:
uid = _parse_uuid(uid_str, "user_id")
# Check if already exists
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is None:
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=uid,
group_id=None,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
created_perms.append(
{
"id": str(perm.id),
"file_id": str(fid),
"user_id": str(uid),
"group_id": None,
"access_level": body.access_level,
}
)
for gid_str in body.group_ids:
gid = _parse_uuid(gid_str, "group_id")
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.group_id == gid,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is None:
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=uuid.UUID(current_user["user_id"]),
group_id=gid,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
created_perms.append(
{
"id": str(perm.id),
"file_id": str(fid),
"user_id": str(perm.user_id),
"group_id": str(gid),
"access_level": body.access_level,
}
)
return {
"file_id": str(fid),
"shared_with": created_perms,
"count": len(created_perms),
}
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
async def remove_share(
file_id: str,
body: ShareRemoveRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
if body.user_id:
uid = _parse_uuid(body.user_id, "user_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
)
)
perms = result.scalars().all()
for p in perms:
await db.delete(p)
if body.group_id:
gid = _parse_uuid(body.group_id, "group_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.group_id == gid,
)
)
perms = result.scalars().all()
for p in perms:
await db.delete(p)
await db.flush()
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Search & Bulk ───
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
async def search_files(
q: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
DmsFile.name.ilike(f"%{q}%"),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"deleted_at": None,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in files
]
@router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
async def shared_with_me(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
# Query permissions for this user and join with files
perm_result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.user_id == user_id,
)
)
perms = perm_result.scalars().all()
file_ids = {p.file_id for p in perms}
if not file_ids:
return {"items": [], "total": 0}
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
# Map permissions for access_level
perm_map: dict[uuid.UUID, str] = {}
for p in perms:
if p.file_id in file_ids:
perm_map[p.file_id] = p.access_level
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"access_level": perm_map.get(f.id, "read"),
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in files
]
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
async def bulk_move(
body: BulkMoveRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
target_folder_id = (
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
)
# Validate target folder if specified
if target_folder_id is not None:
folder_result = await db.execute(
select(Folder).where(
Folder.id == target_folder_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if folder_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Target folder not found", "code": "not_found"}
)
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
moved_count = 0
for f in files:
f.folder_id = target_folder_id
moved_count += 1
await db.flush()
return {
"moved": moved_count,
"file_ids": [str(fid) for fid in file_ids],
"target_folder_id": str(target_folder_id) if target_folder_id else None,
}
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
async def bulk_delete(
body: BulkDeleteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
from datetime import UTC, datetime
now = datetime.now(UTC)
# Apply visibility filter to only delete files user has access to
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
accessible_files = result.scalars().all()
accessible_ids = [f.id for f in accessible_files]
result = await db.execute(
update(DmsFile)
.where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(accessible_ids),
DmsFile.deleted_at.is_(None),
)
.values(deleted_at=now)
)
deleted_count = result.rowcount
await db.flush()
return {
"deleted": deleted_count,
"file_ids": body.file_ids,
}
# Sub-Router einbinden (BUG-018 Split): folders, sharing/collabora, search/bulk
router.include_router(folders_router)
router.include_router(sharing_router)
router.include_router(search_bulk_router)
@@ -0,0 +1,223 @@
"""DMS Search / shared-with-me / Bulk Routen — extrahiert aus routes.py (BUG-018)."""
from __future__ import annotations
import uuid
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission
from app.plugins.builtins.dms.common import (
_parse_uuid,
)
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.models import Folder
from app.plugins.builtins.dms.schemas import BulkDeleteRequest, BulkMoveRequest
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
_perms_contract = get_perms_contract()
Permission = _perms_contract.Permission
router = APIRouter(tags=["dms"])
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
async def search_files(
q: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
DmsFile.name.ilike(f"%{q}%"),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"deleted_at": None,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in files
]
@router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
async def shared_with_me(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
# Query permissions for this user and join with files
perm_result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.user_id == user_id,
)
)
perms = perm_result.scalars().all()
file_ids = {p.file_id for p in perms}
if not file_ids:
return []
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
# Map permissions for access_level
perm_map: dict[uuid.UUID, str] = {}
for p in perms:
if p.file_id in file_ids:
perm_map[p.file_id] = p.access_level
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"access_level": perm_map.get(f.id, "read"),
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in files
]
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
async def bulk_move(
body: BulkMoveRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
target_folder_id = (
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
)
# Validate target folder if specified
if target_folder_id is not None:
folder_result = await db.execute(
select(Folder).where(
Folder.id == target_folder_id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if folder_result.scalar_one_or_none() is None:
raise HTTPException(
404, detail={"detail": "Target folder not found", "code": "not_found"}
)
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
files = result.scalars().all()
moved_count = 0
for f in files:
f.folder_id = target_folder_id
moved_count += 1
await db.flush()
return {
"moved": moved_count,
"file_ids": [str(fid) for fid in file_ids],
"target_folder_id": str(target_folder_id) if target_folder_id else None,
}
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
async def bulk_delete(
body: BulkDeleteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
from datetime import UTC, datetime
now = datetime.now(UTC)
# Apply visibility filter to only delete files user has access to
query = select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(file_ids),
DmsFile.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
accessible_files = result.scalars().all()
accessible_ids = [f.id for f in accessible_files]
result = await db.execute(
update(DmsFile)
.where(
DmsFile.tenant_id == tenant_id,
DmsFile.id.in_(accessible_ids),
DmsFile.deleted_at.is_(None),
)
.values(deleted_at=now)
)
deleted_count = result.rowcount
await db.flush()
return {
"deleted": deleted_count,
"file_ids": body.file_ids,
}
+242
View File
@@ -0,0 +1,242 @@
"""DMS Edit-Session/Collabora & Sharing Routen — extrahiert aus routes.py (BUG-018)."""
from __future__ import annotations
import uuid
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
Response,
status,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.visibility import check_single_entity_access
from app.deps import get_current_user, require_permission
from app.plugins.builtins.dms.common import (
OFFICE_EXTENSIONS,
_get_file_extension,
_parse_uuid,
)
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.schemas import ShareRemoveRequest, ShareRequest
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
_perms_contract = get_perms_contract()
Permission = _perms_contract.Permission
router = APIRouter(tags=["dms"])
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
async def create_edit_session(
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = current_user["user_id"]
user_name = current_user.get("name", "Unknown")
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
result = await db.execute(
select(DmsFile).where(
DmsFile.id == fid,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
ext = _get_file_extension(dms_file.name)
if ext not in OFFICE_EXTENSIONS:
raise HTTPException(
400,
detail={
"detail": "Only Office files (docx, xlsx, pptx) are supported",
"code": "not_office",
},
)
file_type = OFFICE_EXTENSIONS[ext]
download_url = f"/api/v1/dms/files/{fid}/preview"
callback_url = f"/api/v1/dms/files/{fid}/callback"
config = {
"document": {
"fileType": file_type,
"key": str(uuid.uuid4()),
"title": dms_file.name,
"url": download_url,
},
"editorConfig": {
"mode": "edit",
"callbackUrl": callback_url,
"user": {
"id": user_id,
"name": user_name,
},
},
}
return config
# ─── Internal Sharing ───
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
async def share_file(
file_id: str,
body: ShareRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
# Verify file exists
file_result = await db.execute(
select(DmsFile).where(
DmsFile.id == fid,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
if file_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
created_perms: list[dict] = []
for uid_str in body.user_ids:
uid = _parse_uuid(uid_str, "user_id")
# Check if already exists
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is None:
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=uid,
group_id=None,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
created_perms.append(
{
"id": str(perm.id),
"file_id": str(fid),
"user_id": str(uid),
"group_id": None,
"access_level": body.access_level,
}
)
for gid_str in body.group_ids:
gid = _parse_uuid(gid_str, "group_id")
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.group_id == gid,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is None:
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=uuid.UUID(current_user["user_id"]),
group_id=gid,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
created_perms.append(
{
"id": str(perm.id),
"file_id": str(fid),
"user_id": str(perm.user_id),
"group_id": str(gid),
"access_level": body.access_level,
}
)
return {
"file_id": str(fid),
"shared_with": created_perms,
"count": len(created_perms),
}
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
async def remove_share(
file_id: str,
body: ShareRemoveRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
fid = _parse_uuid(file_id, "file_id")
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
if body.user_id:
uid = _parse_uuid(body.user_id, "user_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
)
)
perms = result.scalars().all()
for p in perms:
await db.delete(p)
if body.group_id:
gid = _parse_uuid(body.group_id, "group_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.group_id == gid,
)
)
perms = result.scalars().all()
for p in perms:
await db.delete(p)
await db.flush()
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Search & Bulk ───
@@ -23,11 +23,15 @@ class ForgejoErrorReporterPlugin(BasePlugin):
version="1.0.0",
display_name="Forgejo Error Reporter",
description="Automatically reports errors to Forgejo as issues. Test environment only.",
is_core=True,
# Audit P2 (classification): a test/staging-only plugin must be
# deactivatable — is_core=True contradicts its own production guard.
is_core=False,
dependencies=[],
events=[],
migrations=[],
permissions=[],
# Audit P1 (permission catalog): /status route requires system:read —
# the key must be grantable via the manifest.
permissions=["system:read"],
routes=[
PluginRouteDef(
path="/api/v1/forgejo-error-reporter",
@@ -38,7 +42,8 @@ class ForgejoErrorReporterPlugin(BasePlugin):
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
contract_version="1.0.0",
)
def __init__(self) -> None:
super().__init__()
+44 -1
View File
@@ -3,7 +3,13 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
class GraphRAGPlugin(BasePlugin):
@@ -24,6 +30,43 @@ class GraphRAGPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql"],
miniapps=[
MiniAppContribution(
app_id="graph_overview",
name="Wissens-Graph",
icon="Share2",
description="Beziehungsübersicht des Knowledge-Graphs.",
permission="graph:read",
settings_schema={
"fields": [
{"name": "max_items", "label": "Max. Beziehungen", "type": "number", "default": 6},
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/GraphOverviewWidget",
order=90,
),
],
menu_items=[
FrontendMenuItem(
label_key="nav.graphRag",
label="Wissens-Graph",
path="/graph-rag",
icon="Share2",
order=87,
permission="graph:read",
),
],
page_routes=[
FrontendPageRoute(
path="/graph-rag",
component="@/pages/GraphRag",
protected=True,
permission="graph:read",
),
],
permissions=[
"graph:read",
"graph:write",
@@ -0,0 +1,5 @@
"""Import/export formats plugin — provides the standard file formats."""
from app.plugins.builtins.importexport_formats.plugin import ImportExportFormatsPlugin
__all__ = ["ImportExportFormatsPlugin"]
@@ -0,0 +1,48 @@
"""Standard format handlers — thin wrappers around the core helpers.
No business logic here: parse/serialize only. Business logic (columns,
normalization, validation, persistence) lives in the entity module's
contract contribution (e.g. ContactsContract.importexport).
"""
from __future__ import annotations
from typing import Any
from app.services import import_export_helpers as helpers
class CsvFormat:
format_id = "csv"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_csv(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_csv(rows, headers)
class JsonFormat:
format_id = "json"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_json(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_json(rows)
class XlsxFormat:
format_id = "xlsx"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_xlsx(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_xlsx(rows, headers)
@@ -0,0 +1,52 @@
"""Import/export formats plugin — provides standard file formats as plugins.
Part of the W4a contribution architecture (Spec #359): file formats live in
this plugin instead of being hardcoded in the core orchestrator. New formats
(e.g. PDF later) are added as additional format plugins without touching the
core.
Lifecycle: registers the bundled handlers (csv, json, xlsx) in the core
format registry on activate, unregisters them on deactivate.
"""
from __future__ import annotations
from typing import Any
from app.core.importexport_registry import get_format_registry
from app.plugins.base import BasePlugin
from app.plugins.builtins.importexport_formats.formats import CsvFormat, JsonFormat, XlsxFormat
from app.plugins.manifest import PluginManifest
class ImportExportFormatsPlugin(BasePlugin):
"""Bundles the standard import/export file formats."""
manifest = PluginManifest(
name="importexport_formats",
version="1.0.0",
display_name="Import/Export Formate",
description="Standard-Formate für Import/Export: CSV, JSON, XLSX.",
dependencies=[],
routes=[],
events=[],
migrations=[],
permissions=[],
is_core=True,
)
def __init__(self) -> None:
super().__init__()
self._handlers: list[Any] = [CsvFormat(), JsonFormat(), XlsxFormat()]
async def on_activate(self, db, service_container, event_bus) -> None:
await super().on_activate(db, service_container, event_bus)
registry = get_format_registry()
for handler in self._handlers:
registry.register(handler)
async def on_deactivate(self, db, service_container, event_bus) -> None:
await super().on_deactivate(db, service_container, event_bus)
registry = get_format_registry()
for handler in self._handlers:
registry.unregister(handler.format_id)
@@ -13,6 +13,11 @@ instead of importing from internal modules directly.
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.miniapp_registry import (
MiniAppDef,
@@ -74,6 +79,58 @@ class KommunikationContract:
CommMessage = CommMessage
CommParticipant = CommParticipant
@staticmethod
async def dsar_collect(
db: AsyncSession, tenant_id: Any, user_id: Any
) -> dict[str, Any]:
"""GDPR Art. 15: collect communication messages sent by the user."""
comm_messages = (
await db.execute(
select(CommMessage).where(
CommMessage.sender_id == user_id,
CommMessage.tenant_id == tenant_id,
).limit(1000)
)
).scalars().all()
return {
"comm_messages": [
{
"id": str(m.id),
"sender_type": m.sender_type,
"content": m.content[:500],
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in comm_messages
]
}
# ─── Workspace Scopes contribution (Phase N4) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des communication-Moduls: Räume-Teilmengen (N4)."""
return [
{
"module_key": "communication",
"dimensions": [
{
"key": "conversation_ids",
"label": "Räume",
"control": "multiselect",
"options": [],
"value_source": {
"endpoint": "/api/v1/comm/conversations",
"items_path": "items",
"value_key": "id",
"label_key": "title",
},
},
],
}
]
# ─── self-registration ───
@@ -0,0 +1,343 @@
"""Conversation CRUD and pin/mute actions for the kommunikation plugin."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.interactions import _get_unread_count
from app.plugins.builtins.kommunikation.messages import send_message
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommConversationMute,
CommConversationPin,
CommParticipant,
)
from app.plugins.builtins.kommunikation.rbac import CommRBAC
from app.plugins.builtins.kommunikation.serializers import conversation_to_response
logger = logging.getLogger(__name__)
# ─── conversations ───
async def list_conversations(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
include_archived: bool = False,
) -> list[dict[str, Any]]:
"""List all conversations for a user."""
# Get conversations where user is a participant
result = await db.execute(
select(CommConversation)
.join(CommParticipant, CommParticipant.conversation_id == CommConversation.id)
.where(
CommParticipant.participant_id == user_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
CommConversation.tenant_id == tenant_id,
CommConversation.deleted_at.is_(None),
)
.order_by(CommConversation.last_msg_at.desc().nullslast())
)
conversations = result.scalars().all()
# Get user's pinned conversations
pins_result = await db.execute(
select(CommConversationPin).where(
CommConversationPin.user_id == user_id,
CommConversationPin.tenant_id == tenant_id,
)
)
pinned_ids = {p.conversation_id for p in pins_result.scalars().all()}
conv_list = []
for conv in conversations:
if conv.is_archived and not include_archived:
continue
# Get participants
parts_result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conv.id,
CommParticipant.left_at.is_(None),
)
)
participants = list(parts_result.scalars().all())
# Get unread count
unread = await _get_unread_count(db, tenant_id, conv.id, user_id)
conv_list.append(
conversation_to_response(
conv, participants, unread_count=unread, is_pinned_by_user=conv.id in pinned_ids
)
)
# Sort: pinned first, then by last_msg_at
conv_list.sort(key=lambda c: (not c["is_pinned"], c["last_msg_at"] or ""), reverse=False)
# Actually: pinned first (True > False in reverse), then newest first
conv_list.sort(key=lambda c: c["last_msg_at"] or "0000", reverse=True)
conv_list.sort(key=lambda c: c["is_pinned"], reverse=True)
return conv_list
async def get_conversation(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Get a single conversation with participants."""
result = await db.execute(
select(CommConversation).where(
CommConversation.id == conversation_id,
CommConversation.tenant_id == tenant_id,
CommConversation.deleted_at.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is None:
return None
# Check user is participant
if not await CommRBAC.is_participant(db, conversation_id, user_id):
return None
parts_result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conv.id,
CommParticipant.left_at.is_(None),
)
)
participants = list(parts_result.scalars().all())
# Check pinned
pin_result = await db.execute(
select(CommConversationPin).where(
CommConversationPin.conversation_id == conv.id,
CommConversationPin.user_id == user_id,
)
)
is_pinned = pin_result.scalar_one_or_none() is not None
unread = await _get_unread_count(db, tenant_id, conv.id, user_id)
return conversation_to_response(conv, participants, unread_count=unread, is_pinned_by_user=is_pinned)
async def create_conversation(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
title: str | None = None,
participant_ids: list[str] | None = None,
is_direct: bool = False,
initial_message: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a new conversation."""
conv = CommConversation(
tenant_id=tenant_id,
title=title,
owner_id=user_id,
is_direct=is_direct,
created_by=user_id,
created_by_type="user",
metadata_=metadata or {},
)
from app.core.hooks import do_action
await do_action("comm.conversation.before_create", tenant_id=tenant_id, user_id=user_id)
db.add(conv)
await db.flush()
await do_action("comm.conversation.after_create", conversation_id=conv.id, tenant_id=tenant_id, user_id=user_id)
# Add creator as admin
creator = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=user_id,
participant_type="user",
role="admin",
)
db.add(creator)
# Add other participants
for pid_str in (participant_ids or []):
try:
pid = uuid.UUID(pid_str)
if pid == user_id:
continue
p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=pid,
participant_type="user",
role="member",
)
db.add(p)
except ValueError:
logger.warning(f"Invalid participant UUID: {pid_str}")
await db.flush()
# Send initial message if provided
if initial_message:
await send_message(
db, tenant_id, conv.id, user_id, "user",
content=initial_message, content_format="text",
)
# Publish event
event_bus = get_event_bus()
await event_bus.publish("conversation.created", {
"conversation_id": str(conv.id),
"tenant_id": str(tenant_id),
"created_by": str(user_id),
})
# Get all participants for response
parts_result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conv.id,
CommParticipant.left_at.is_(None),
)
)
participants = list(parts_result.scalars().all())
return conversation_to_response(conv, participants)
async def update_conversation(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
title: str | None = None,
is_archived: bool | None = None,
) -> dict[str, Any] | None:
"""Update a conversation."""
result = await db.execute(
select(CommConversation).where(
CommConversation.id == conversation_id,
CommConversation.tenant_id == tenant_id,
CommConversation.deleted_at.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is None:
return None
# Check locked
if conv.is_locked and title is not None:
# Only the locking plugin can change title on locked conversations
# Users cannot
pass
elif title is not None:
conv.title = title
conv.title_set_by = user_id
if is_archived is not None:
conv.is_archived = is_archived
await db.flush()
return await get_conversation(db, tenant_id, conversation_id, user_id)
async def pin_conversation(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Pin a conversation for a user."""
existing = await db.execute(
select(CommConversationPin).where(
CommConversationPin.conversation_id == conversation_id,
CommConversationPin.user_id == user_id,
)
)
if existing.scalar_one_or_none() is None:
pin = CommConversationPin(
tenant_id=tenant_id,
conversation_id=conversation_id,
user_id=user_id,
)
db.add(pin)
await db.flush()
return True
async def unpin_conversation(
db: AsyncSession,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Unpin a conversation for a user."""
result = await db.execute(
select(CommConversationPin).where(
CommConversationPin.conversation_id == conversation_id,
CommConversationPin.user_id == user_id,
)
)
pin = result.scalar_one_or_none()
if pin:
await db.delete(pin)
await db.flush()
return True
async def mute_conversation(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Mute a conversation for a user."""
existing = await db.execute(
select(CommConversationMute).where(
CommConversationMute.conversation_id == conversation_id,
CommConversationMute.user_id == user_id,
)
)
if existing.scalar_one_or_none() is None:
mute = CommConversationMute(
tenant_id=tenant_id,
conversation_id=conversation_id,
user_id=user_id,
)
db.add(mute)
await db.flush()
return True
async def unmute_conversation(
db: AsyncSession,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Unmute a conversation for a user."""
result = await db.execute(
select(CommConversationMute).where(
CommConversationMute.conversation_id == conversation_id,
CommConversationMute.user_id == user_id,
)
)
mute = result.scalar_one_or_none()
if mute:
await db.delete(mute)
await db.flush()
return True
# ─── Participant Management ───
@@ -0,0 +1,159 @@
"""Reactions and read-state handling for the kommunikation plugin."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.models import (
CommMessage,
CommMessageReaction,
CommMessageRead,
)
logger = logging.getLogger(__name__)
# ─── interactions ───
async def add_reaction(
db: AsyncSession,
tenant_id: uuid.UUID,
message_id: uuid.UUID,
user_id: uuid.UUID,
emoji: str,
) -> dict[str, Any] | None:
"""Add an emoji reaction to a message."""
existing = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id == message_id,
CommMessageReaction.user_id == user_id,
CommMessageReaction.emoji == emoji,
)
)
if existing.scalar_one_or_none() is not None:
return None # Already reacted
r = CommMessageReaction(
tenant_id=tenant_id,
message_id=message_id,
user_id=user_id,
emoji=emoji,
)
db.add(r)
await db.flush()
event_bus = get_event_bus()
await event_bus.publish("reaction.added", {
"message_id": str(message_id),
"emoji": emoji,
"user_id": str(user_id),
})
return {
"id": str(r.id),
"message_id": str(r.message_id),
"user_id": str(r.user_id),
"emoji": r.emoji,
}
async def remove_reaction(
db: AsyncSession,
message_id: uuid.UUID,
user_id: uuid.UUID,
emoji: str,
) -> bool:
"""Remove an emoji reaction."""
result = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id == message_id,
CommMessageReaction.user_id == user_id,
CommMessageReaction.emoji == emoji,
)
)
r = result.scalar_one_or_none()
if r is None:
return False
await db.delete(r)
await db.flush()
return True
# ─── Read State ───
async def mark_read(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
last_read_msg_id: str | None = None,
) -> bool:
"""Mark conversation as read up to a message."""
result = await db.execute(
select(CommMessageRead).where(
CommMessageRead.conversation_id == conversation_id,
CommMessageRead.user_id == user_id,
)
)
read = result.scalar_one_or_none()
msg_id = uuid.UUID(last_read_msg_id) if last_read_msg_id else None
if read is None:
read = CommMessageRead(
tenant_id=tenant_id,
conversation_id=conversation_id,
user_id=user_id,
last_read_msg_id=msg_id,
)
db.add(read)
else:
read.last_read_msg_id = msg_id
read.last_read_at = datetime.now(UTC)
await db.flush()
return True
async def _get_unread_count(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> int:
"""Get unread message count for a user in a conversation."""
# Get last read message
read_result = await db.execute(
select(CommMessageRead).where(
CommMessageRead.conversation_id == conversation_id,
CommMessageRead.user_id == user_id,
)
)
read = read_result.scalar_one_or_none()
query = select(func.count()).select_from(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
CommMessage.sender_type != "system", # Don't count system messages? Or count all?
)
if read and read.last_read_at:
query = query.where(CommMessage.created_at > read.last_read_at)
result = await db.execute(query)
return result.scalar() or 0
# ─── Plugin Room Creation ───
@@ -0,0 +1,394 @@
"""Message retrieval, sending and editing for the kommunikation plugin."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommMessage,
CommMessageAttachment,
CommMessageBlock,
CommMessageEdit,
CommMessageReaction,
CommParticipant,
)
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.serializers import (
conversation_to_response,
message_to_response,
parse_mentions,
participant_to_response,
)
logger = logging.getLogger(__name__)
MAX_TRIGGER_DEPTH = 3
# ─── messages ───
async def get_messages(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
page: int = 1,
page_size: int = 50,
before_id: uuid.UUID | None = None,
) -> dict[str, Any]:
"""Get paginated messages for a conversation."""
query = select(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
).order_by(CommMessage.created_at.desc())
if before_id:
before_msg = await db.execute(
select(CommMessage).where(CommMessage.id == before_id)
)
before = before_msg.scalar_one_or_none()
if before:
query = query.where(CommMessage.created_at < before.created_at)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
messages = list(result.scalars().all())
# Get blocks, attachments, reactions for each message
msg_ids = [m.id for m in messages]
blocks_map: dict[uuid.UUID, list] = {}
attachments_map: dict[uuid.UUID, list] = {}
reactions_map: dict[uuid.UUID, list] = {}
if msg_ids:
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id.in_(msg_ids),
CommMessageBlock.deleted_at.is_(None),
).order_by(CommMessageBlock.sort_order)
)
for b in blocks_result.scalars().all():
blocks_map.setdefault(b.message_id, []).append(b)
atts_result = await db.execute(
select(CommMessageAttachment).where(
CommMessageAttachment.message_id.in_(msg_ids),
CommMessageAttachment.deleted_at.is_(None),
)
)
for a in atts_result.scalars().all():
attachments_map.setdefault(a.message_id, []).append(a)
reactions_result = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id.in_(msg_ids),
)
)
for r in reactions_result.scalars().all():
reactions_map.setdefault(r.message_id, []).append(r)
items = []
for msg in reversed(messages): # chronological order
items.append(
message_to_response(
msg,
blocks=blocks_map.get(msg.id, []),
attachments=attachments_map.get(msg.id, []),
reactions=reactions_map.get(msg.id, []),
)
)
# Total count
count_result = await db.execute(
select(func.count()).select_from(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
)
)
total = count_result.scalar() or 0
has_more = (page * page_size) < total
return {"items": items, "total": total, "page": page, "has_more": has_more}
async def send_message(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
sender_id: uuid.UUID | None,
sender_type: str,
content: str = "",
content_format: str = "text",
blocks: list[dict[str, Any]] | None = None,
reply_to_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
trigger_depth: int = 0,
) -> dict[str, Any]:
"""Send a message to a conversation and trigger participant handlers."""
# Create message
msg = CommMessage(
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=sender_id,
sender_type=sender_type,
content=content,
content_format=content_format,
metadata_=metadata or {},
)
if reply_to_id:
try:
msg.reply_to_id = uuid.UUID(reply_to_id)
except ValueError:
pass
from app.core.hooks import do_action
await do_action("comm.before_message", conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
db.add(msg)
await db.flush()
await do_action("comm.after_message", message_id=msg.id, conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
# Create blocks
if blocks:
for i, block in enumerate(blocks):
b = CommMessageBlock(
tenant_id=tenant_id,
message_id=msg.id,
block_type=block["block_type"],
block_data=block["block_data"],
sort_order=i,
)
db.add(b)
# Create attachments
if attachments:
for att in attachments:
a = CommMessageAttachment(
tenant_id=tenant_id,
message_id=msg.id,
file_id=uuid.UUID(att["file_id"]) if att.get("file_id") else None,
file_source=att.get("file_source", "comm"),
file_name=att.get("file_name", ""),
file_type=att.get("file_type", "application/octet-stream"),
file_size=att.get("file_size"),
)
db.add(a)
await db.flush()
# Update conversation last_msg
await db.execute(
update(CommConversation)
.where(CommConversation.id == conversation_id)
.values(
last_msg_at=datetime.now(UTC),
last_msg_preview=content[:200] if content else "",
last_msg_sender_type=sender_type,
)
)
# Publish event
event_bus = get_event_bus()
await event_bus.publish("message.received", {
"conversation_id": str(conversation_id),
"message_id": str(msg.id),
"sender_type": sender_type,
"tenant_id": str(tenant_id),
"content": content,
"trigger_depth": trigger_depth,
})
# Trigger participant handlers (if not at max depth)
if trigger_depth < MAX_TRIGGER_DEPTH:
await _trigger_participants(
db, tenant_id, conversation_id, msg, trigger_depth
)
# Load blocks/attachments/reactions for response
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id == msg.id,
CommMessageBlock.deleted_at.is_(None),
).order_by(CommMessageBlock.sort_order)
)
msg_blocks = list(blocks_result.scalars().all())
atts_result = await db.execute(
select(CommMessageAttachment).where(
CommMessageAttachment.message_id == msg.id,
CommMessageAttachment.deleted_at.is_(None),
)
)
msg_atts = list(atts_result.scalars().all())
return message_to_response(msg, blocks=msg_blocks, attachments=msg_atts)
async def _trigger_participants(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
message: CommMessage,
trigger_depth: int,
) -> None:
"""Trigger participant handlers for non-user participants."""
# Get conversation participants
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.left_at.is_(None),
CommParticipant.participant_type != "user",
)
)
non_user_participants = list(result.scalars().all())
if not non_user_participants:
return
# Get conversation info
conv_result = await db.execute(
select(CommConversation).where(CommConversation.id == conversation_id)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
return
# Parse mentions
mentions = parse_mentions(message.content)
# Build conversation dict
all_parts_result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.left_at.is_(None),
)
)
all_parts = [participant_to_response(p) for p in all_parts_result.scalars().all()]
conv_dict = conversation_to_response(conv, [])
conv_dict["participants"] = all_parts
msg_dict = message_to_response(message)
context = {"tenant_id": str(tenant_id), "trigger_depth": trigger_depth}
registry = get_participant_registry()
for p in non_user_participants:
handler = registry.get_handler(p.participant_type)
if handler is None:
continue
try:
responses = await handler.on_message_received(
conversation_id=conversation_id,
message=msg_dict,
conversation=conv_dict,
mentions=mentions,
context=context,
)
if responses:
for resp in responses:
await send_message(
db,
tenant_id,
conversation_id,
sender_id=None,
sender_type=p.participant_type,
content=resp.get("content", ""),
content_format=resp.get("content_format", "text"),
blocks=resp.get("blocks"),
metadata={
**(resp.get("metadata") or {}),
"triggered_by": str(message.id),
"trigger_depth": trigger_depth + 1,
},
trigger_depth=trigger_depth + 1,
)
except Exception:
logger.exception(
f"Participant handler error for type {p.participant_type}"
)
async def edit_message(
db: AsyncSession,
tenant_id: uuid.UUID,
message_id: uuid.UUID,
user_id: uuid.UUID,
new_content: str,
) -> dict[str, Any] | None:
"""Edit a message, storing the old version in history."""
result = await db.execute(
select(CommMessage).where(
CommMessage.id == message_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
)
)
msg = result.scalar_one_or_none()
if msg is None:
return None
# Get old blocks
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id == message_id,
CommMessageBlock.deleted_at.is_(None),
)
)
old_blocks = [b.block_data for b in blocks_result.scalars().all()]
# Save edit history
edit = CommMessageEdit(
tenant_id=tenant_id,
message_id=message_id,
old_content=msg.content,
old_blocks=old_blocks,
edited_by=user_id,
)
db.add(edit)
from app.core.hooks import do_action
await do_action("comm.before_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
# Update message
msg.content = new_content
msg.edited_at = datetime.now(UTC)
await db.flush()
await do_action("comm.after_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
return message_to_response(msg)
async def delete_message(
db: AsyncSession,
message_id: uuid.UUID,
) -> bool:
"""Soft-delete a message."""
result = await db.execute(
select(CommMessage).where(CommMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if msg is None:
return False
from app.core.hooks import do_action
await do_action("comm.before_delete", message_id=message_id)
msg.deleted_at = datetime.now(UTC)
await db.flush()
await do_action("comm.after_delete", message_id=message_id)
return True
# ─── Reactions ───
@@ -1,92 +1,25 @@
"""Mini-App registry for plugin-provided interactive chat components."""
"""Compatibility bridge — the MiniApp registry moved to the plugin layer.
The registry is a platform concept now (Phase M1): MiniApps are universal
building blocks for Chat, Dashboard and Windows. This module re-exports the
universal registry so every existing importer (kommunikation contracts,
automation routes, tests) keeps working unchanged.
"""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.miniapp_registry import ( # noqa: F401
DEFAULT_HOSTS,
MiniAppDef,
MiniAppRegistry,
get_miniapp_registry,
reset_miniapp_registry,
)
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class MiniAppDef(BaseModel):
"""Definition of a mini-app that plugins can register."""
app_id: str = Field(..., description="Unique app identifier")
name: str = Field(..., description="Display name")
icon: str = Field(default="app", description="Icon name")
description: str = Field(default="", description="App description")
plugin_name: str = Field(..., description="Plugin that registered this app")
render_schema: dict[str, Any] = Field(
default_factory=dict, description="JSON schema for frontend rendering"
)
class MiniAppRegistry:
"""Registry for mini-apps that plugins provide for chat embedding."""
def __init__(self) -> None:
self._apps: dict[str, MiniAppDef] = {}
def register(
self,
app_id: str,
name: str,
icon: str,
description: str,
plugin_name: str,
render_schema: dict[str, Any] | None = None,
) -> None:
"""Register a mini-app."""
app = MiniAppDef(
app_id=app_id,
name=name,
icon=icon,
description=description,
plugin_name=plugin_name,
render_schema=render_schema or {},
)
self._apps[app_id] = app
logger.info(f"Mini-app registered: {app_id} by {plugin_name}")
def unregister(self, app_id: str) -> None:
"""Unregister a mini-app."""
app = self._apps.pop(app_id, None)
if app:
logger.info(f"Mini-app unregistered: {app_id}")
def unregister_plugin(self, plugin_name: str) -> None:
"""Unregister all mini-apps from a specific plugin."""
to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name]
for app_id in to_remove:
self._apps.pop(app_id, None)
if to_remove:
logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}")
def list_apps(self) -> list[dict[str, Any]]:
"""List all available mini-apps for frontend."""
return [app.model_dump() for app in self._apps.values()]
def get_app(self, app_id: str) -> MiniAppDef | None:
"""Get a specific mini-app definition."""
return self._apps.get(app_id)
# ─── Singleton helpers ───
_registry: MiniAppRegistry | None = None
def get_miniapp_registry() -> MiniAppRegistry:
"""Return the shared singleton MiniAppRegistry instance."""
global _registry
if _registry is None:
_registry = MiniAppRegistry()
return _registry
def reset_miniapp_registry() -> None:
"""Reset the singleton instance (useful for tests)."""
global _registry
_registry = None
__all__ = [
"MiniAppDef",
"MiniAppRegistry",
"get_miniapp_registry",
"reset_miniapp_registry",
"DEFAULT_HOSTS",
]
@@ -0,0 +1,127 @@
"""Participant management for the kommunikation plugin."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.models import (
CommParticipant,
)
from app.plugins.builtins.kommunikation.serializers import participant_to_response
logger = logging.getLogger(__name__)
# ─── participants ───
async def add_participant(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
participant_id: str,
participant_type: str = "user",
role: str = "member",
display_name: str | None = None,
) -> dict[str, Any] | None:
"""Add a participant to a conversation."""
try:
pid = uuid.UUID(participant_id) if participant_type == "user" else None
except ValueError:
return None
existing = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == pid if pid else CommParticipant.participant_type == participant_type,
CommParticipant.participant_type == participant_type,
CommParticipant.left_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
return None # Already a participant
p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conversation_id,
participant_id=pid,
participant_type=participant_type,
role=role,
display_name=display_name,
)
db.add(p)
await db.flush()
# Publish event
event_bus = get_event_bus()
await event_bus.publish("participant.joined", {
"conversation_id": str(conversation_id),
"participant_id": participant_id,
"participant_type": participant_type,
"tenant_id": str(tenant_id),
})
return participant_to_response(p)
async def remove_participant(
db: AsyncSession,
conversation_id: uuid.UUID,
participant_id: uuid.UUID,
) -> bool:
"""Remove a participant from a conversation (set left_at)."""
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == participant_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
p = result.scalar_one_or_none()
if p is None:
return False
p.left_at = datetime.now(UTC)
await db.flush()
event_bus = get_event_bus()
await event_bus.publish("participant.left", {
"conversation_id": str(conversation_id),
"participant_id": str(participant_id),
})
return True
async def change_role(
db: AsyncSession,
conversation_id: uuid.UUID,
participant_id: uuid.UUID,
new_role: str,
) -> dict[str, Any] | None:
"""Change a participant's role."""
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == participant_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
p = result.scalar_one_or_none()
if p is None:
return None
p.role = new_role
await db.flush()
return participant_to_response(p)
# ─── Messages ───
@@ -0,0 +1,345 @@
"""Plugin room creation and system channels for the kommunikation plugin."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.conversations import get_conversation
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommConversationPin,
CommMessage,
CommMessageBlock,
CommParticipant,
)
from app.plugins.builtins.kommunikation.serializers import conversation_to_response
logger = logging.getLogger(__name__)
# ─── plugin_rooms ───
async def find_locked_room_id(
db: AsyncSession,
tenant_id: uuid.UUID,
plugin_name: str,
title: str,
) -> uuid.UUID | None:
"""Find the conversation ID of a locked plugin room by tenant and title.
Matches the same room semantics as ``create_plugin_room``: locked rooms
are owned by the plugin (``locked_by == plugin_name``) and soft-deleted
conversations are excluded. Returns ``None`` when no room exists.
"""
result = await db.execute(
select(CommConversation.id).where(
CommConversation.tenant_id == tenant_id,
CommConversation.title == title,
CommConversation.is_locked.is_(True),
CommConversation.locked_by == plugin_name,
CommConversation.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def create_plugin_room(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
plugin_name: str,
title: str,
participant_type: str,
user_role: str = "member",
) -> dict[str, Any]:
"""Create a locked, pinned room for a plugin (System, Live KI, Assistent).
The room is locked (users can't change title/participants) and pinned for the user.
"""
# Check if room already exists for this user + plugin
result = await db.execute(
select(CommConversation).where(
CommConversation.tenant_id == tenant_id,
CommConversation.title == title,
CommConversation.is_locked.is_(True),
CommConversation.locked_by == plugin_name,
CommConversation.deleted_at.is_(None),
).join(CommParticipant, CommParticipant.conversation_id == CommConversation.id).where(
CommParticipant.participant_id == user_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
existing = result.scalar_one_or_none()
if existing:
# Already exists — return it
return await get_conversation(db, tenant_id, existing.id, user_id) or {}
# Create conversation
conv = CommConversation(
tenant_id=tenant_id,
title=title,
owner_id=user_id,
is_locked=True,
locked_by=plugin_name,
is_direct=False,
created_by=None,
created_by_type="plugin",
metadata_={"plugin": plugin_name},
)
db.add(conv)
await db.flush()
# Add plugin as participant
plugin_p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=None,
participant_type=participant_type,
role="admin",
display_name=title,
)
db.add(plugin_p)
# Add user as participant
user_p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=user_id,
participant_type="user",
role=user_role,
)
db.add(user_p)
# Pin for user
pin = CommConversationPin(
tenant_id=tenant_id,
conversation_id=conv.id,
user_id=user_id,
)
db.add(pin)
await db.flush()
# Publish event
event_bus = get_event_bus()
await event_bus.publish("conversation.created", {
"conversation_id": str(conv.id),
"tenant_id": str(tenant_id),
"created_by_type": "plugin",
"plugin_name": plugin_name,
})
parts = [plugin_p, user_p]
return conversation_to_response(conv, parts, is_pinned_by_user=True)
# ─── System Channel ───
async def get_or_create_system_channel(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> CommConversation:
"""Get or create the tenant-wide system channel.
The system channel is a locked, is_system=True conversation that serves as
the central destination for system notifications, user alerts, and agent messages.
All users of the tenant are automatically added as participants.
"""
result = await db.execute(
select(CommConversation).where(
CommConversation.tenant_id == tenant_id,
CommConversation.is_system.is_(True),
CommConversation.deleted_at.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is not None:
return conv
# Create the system channel
conv = CommConversation(
tenant_id=tenant_id,
title="System Channel",
is_pinned=False,
is_locked=True,
is_direct=False,
is_archived=False,
is_system=True,
created_by=None,
created_by_type="system",
metadata_={},
)
db.add(conv)
await db.flush()
# Add all tenant users as participants
from app.models.user import User, UserTenant
users_result = await db.execute(
select(User.id)
.join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
)
user_ids = [row[0] for row in users_result.all()]
for uid in user_ids:
p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=uid,
participant_type="user",
role="member",
)
db.add(p)
await db.flush()
return conv
async def post_system_message(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
message_type: str,
title: str,
body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
severity: str = "info",
) -> CommMessage | None:
"""Post a typed system message to the tenant system channel.
Creates a CommMessage in the system channel with:
- A text block containing title + body
- An action_card block with deep-link if entity_type/entity_id is set
- Block/message metadata: notification_type, severity, entity_ref
Returns the created CommMessage, or None if the user has muted this type.
"""
# Check user preferences — reuse the notification preference system
from app.models.notification import NotificationPreference, NotificationType
pref = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == message_type,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref_row = pref.scalar_one_or_none()
if pref_row and not pref_row.is_enabled:
return None
if not pref_row:
type_def = await db.execute(
select(NotificationType).where(NotificationType.type_key == message_type)
)
type_row = type_def.scalar_one_or_none()
if type_row and not type_row.is_enabled_by_default:
return None
# Get or create system channel
conv = await get_or_create_system_channel(db, tenant_id)
# Build message content
content = title
if body:
content = f"{title}\n{body}"
# Build metadata
msg_metadata: dict[str, Any] = {
"notification_type": message_type,
"severity": severity,
"target_user_id": str(user_id),
}
if entity_type and entity_id:
msg_metadata["entity_ref"] = {
"entity_type": entity_type,
"entity_id": str(entity_id),
}
# Build blocks
blocks: list[dict[str, Any]] = [
{
"block_type": "text",
"block_data": {"text": content, "title": title, "body": body or ""},
}
]
if entity_type and entity_id:
blocks.append(
{
"block_type": "action_card",
"block_data": {
"label": "Open",
"entity_type": entity_type,
"entity_id": str(entity_id),
},
}
)
# Create message directly (not via send_message to avoid trigger_depth issues)
msg = CommMessage(
tenant_id=tenant_id,
conversation_id=conv.id,
sender_id=None,
sender_type="system",
content=content,
content_format="text",
metadata_=msg_metadata,
)
db.add(msg)
await db.flush()
# Create blocks
for i, block in enumerate(blocks):
b = CommMessageBlock(
tenant_id=tenant_id,
message_id=msg.id,
block_type=block["block_type"],
block_data=block["block_data"],
sort_order=i,
)
db.add(b)
await db.flush()
# Update conversation last_msg
await db.execute(
update(CommConversation)
.where(CommConversation.id == conv.id)
.values(
last_msg_at=datetime.now(UTC),
last_msg_preview=content[:200],
last_msg_sender_type="system",
)
)
# Publish event
event_bus = get_event_bus()
await event_bus.publish("system.message.posted", {
"conversation_id": str(conv.id),
"message_id": str(msg.id),
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"message_type": message_type,
"severity": severity,
})
return msg
+14 -2
View File
@@ -19,7 +19,7 @@ from fastapi import (
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.deps import get_current_user, require_permission, require_workspace_scope
from app.plugins.builtins.kommunikation.content_types import list_block_types
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
from app.plugins.builtins.kommunikation.rbac import CommRBAC
@@ -74,11 +74,23 @@ async def list_user_conversations(
archived: bool = Query(False, description="Include archived conversations"),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
workspace_scope: dict | None = Depends(require_workspace_scope("communication")),
):
"""List all conversations for the current user."""
"""List all conversations for the current user.
Phase N4: an active workspace scope (X-Workspace-ID) restricts the list
to the configured conversation subset (pure AND never a grant).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
# Phase N4: conversation_ids scope — keep only scoped rooms
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
conv_scope = scope_uuid_set(workspace_scope.get("conversation_ids"))
if conv_scope is not None:
convs = [c for c in convs if uuid.UUID(c["id"]) in conv_scope]
return {"items": convs, "total": len(convs)}
@@ -0,0 +1,129 @@
"""Row/response serialization helpers for the kommunikation plugin."""
from __future__ import annotations
import logging
import re
from typing import Any
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommMessage,
CommMessageAttachment,
CommMessageBlock,
CommMessageReaction,
CommParticipant,
)
logger = logging.getLogger(__name__)
MAX_TRIGGER_DEPTH = 3
# ─── serializers ───
# ─── Mention Parsing ───
MENTION_PATTERN = re.compile(r"@(\w+)")
def parse_mentions(content: str) -> list[str]:
"""Parse @mentions from message content. Returns list of mentioned types/names."""
return MENTION_PATTERN.findall(content)
# ─── Conversation Helpers ───
def conversation_to_response(
conv: CommConversation,
participants: list[CommParticipant],
unread_count: int = 0,
is_pinned_by_user: bool = False,
) -> dict[str, Any]:
"""Convert a CommConversation to a response dict."""
return {
"id": str(conv.id),
"title": conv.title,
"is_locked": conv.is_locked,
"locked_by": conv.locked_by,
"is_direct": conv.is_direct,
"is_archived": conv.is_archived,
"is_pinned": is_pinned_by_user,
"created_by": str(conv.created_by) if conv.created_by else None,
"created_by_type": conv.created_by_type,
"last_msg_at": conv.last_msg_at.isoformat() if conv.last_msg_at else None,
"last_msg_preview": conv.last_msg_preview,
"last_msg_sender_type": conv.last_msg_sender_type,
"participants": [participant_to_response(p) for p in participants],
"unread_count": unread_count,
"metadata": conv.metadata_ or {},
}
def participant_to_response(p: CommParticipant) -> dict[str, Any]:
"""Convert a CommParticipant to a response dict."""
return {
"id": str(p.id),
"conversation_id": str(p.conversation_id),
"participant_id": str(p.participant_id) if p.participant_id else None,
"participant_type": p.participant_type,
"display_name": p.display_name,
"role": p.role,
"joined_at": p.joined_at.isoformat() if p.joined_at else None,
}
def message_to_response(
msg: CommMessage,
blocks: list[CommMessageBlock] | None = None,
attachments: list[CommMessageAttachment] | None = None,
reactions: list[CommMessageReaction] | None = None,
) -> dict[str, Any]:
"""Convert a CommMessage to a response dict."""
return {
"id": str(msg.id),
"conversation_id": str(msg.conversation_id),
"sender_id": str(msg.sender_id) if msg.sender_id else None,
"sender_type": msg.sender_type,
"content": msg.content,
"content_format": msg.content_format,
"metadata": msg.metadata_ or {},
"reply_to_id": str(msg.reply_to_id) if msg.reply_to_id else None,
"is_pinned": msg.is_pinned,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
"blocks": [
{
"id": str(b.id),
"block_type": b.block_type,
"block_data": b.block_data,
"sort_order": b.sort_order,
}
for b in (blocks or [])
],
"attachments": [
{
"id": str(a.id),
"file_id": str(a.file_id) if a.file_id else None,
"file_source": a.file_source,
"file_name": a.file_name,
"file_type": a.file_type,
"file_size": a.file_size,
"thumbnail_path": a.thumbnail_path,
}
for a in (attachments or [])
],
"reactions": [
{
"id": str(r.id),
"message_id": str(r.message_id),
"user_id": str(r.user_id),
"emoji": r.emoji,
}
for r in (reactions or [])
],
}
# ─── Conversation CRUD ───
File diff suppressed because it is too large Load Diff
+59 -3
View File
@@ -16,21 +16,77 @@ instead of importing from internal modules directly.
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.models import Mail, MailAccount
class MailContract:
"""Public API surface for the mail plugin.
Exposes the ``Mail`` ORM model so that other plugins can query the
mails table without importing from ``mail.models`` directly.
Exposes ORM models so that other plugins can query the mail tables
without importing from ``mail.models`` directly.
"""
contract_name = "mail"
# ─── models ───
Mail = Mail
MailAccount = MailAccount
@staticmethod
async def dsar_collect(
db: AsyncSession, tenant_id: Any, user_id: Any
) -> dict[str, Any]:
"""GDPR Art. 15: collect mail account data owned by the user."""
mail_accounts = (
await db.execute(
select(MailAccount).where(
MailAccount.tenant_id == tenant_id,
MailAccount.user_id == user_id,
).limit(500)
)
).scalars().all()
return {
"mail_accounts": [
{
"id": str(a.id),
"email_address": a.email_address,
"display_name": a.display_name,
"is_shared": a.is_shared,
"is_active": a.is_active,
}
for a in mail_accounts
]
}
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des mail-Moduls für den Workspace-Editor (N1)."""
return [
{
"module_key": "mail",
"dimensions": [
{
"key": "account_ids",
"label": "Postfächer",
"control": "multiselect",
"value_source": {
"endpoint": "/api/v1/mail/accounts",
"items_path": "",
"value_key": "id",
"label_key": "email",
},
},
],
}
]
@classmethod
def get_function(cls, name: str):
+20
View File
@@ -12,6 +12,7 @@ from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -138,6 +139,25 @@ class MailPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
miniapps=[
MiniAppContribution(
app_id="mail_unread",
name="Postfach-Status",
icon="Mail",
description="Ungelesene E-Mails je Konto und Ordner.",
permission="mail:read",
settings_schema={
"fields": [
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
]
},
col_span=2,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/MailUnreadWidget",
order=70,
),
],
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
menu_items=[
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
+26 -2
View File
@@ -21,7 +21,7 @@ import app.plugins.builtins.mail.services as mail_services
from app.core.db import get_db
from app.core.storage import get_storage_backend
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.deps import require_permission
from app.deps import require_permission, require_workspace_scope
from app.plugins.builtins.mail.models import (
ContactPgpKey,
Mail,
@@ -214,7 +214,8 @@ async def _check_delegate_access(
@router.get("/accounts")
async def list_accounts(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -223,6 +224,13 @@ async def list_accounts(
query = await apply_visibility_filter(
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
)
# Phase N3: workspace scope (X-Workspace-ID) — account picker restriction.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
query = query.where(MailAccount.id.in_(account_scope))
accounts = (await db.execute(query)).scalars().all()
return [account_to_response(a) for a in accounts]
@@ -878,12 +886,20 @@ async def list_threads(
account_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
if account_id:
a_id = _parse_uuid(account_id, "account_id")
stmt = stmt.where(Mail.account_id == a_id)
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
stmt = stmt.where(Mail.account_id.in_(account_scope))
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
threads: dict[str, dict] = {}
for mail in mails:
@@ -1887,6 +1903,7 @@ async def list_mails(
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
@@ -1896,6 +1913,13 @@ async def list_mails(
if account_id:
a_id = _parse_uuid(account_id, "account_id")
stmt = stmt.where(Mail.account_id == a_id)
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
stmt = stmt.where(Mail.account_id.in_(account_scope))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
# Dynamic sorting
sort_columns = {
+21 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
logger = logging.getLogger(__name__)
@@ -29,8 +29,26 @@ class MarketplacePlugin(BasePlugin):
events=[],
migrations=["0001_initial.sql"],
permissions=["marketplace:read", "marketplace:admin"],
menu_items=[],
page_routes=[],
# UI-Backlog Modul 5 (2026-09-13): marketplace browse/install page,
# registered via the manifest (Phase Q pattern).
menu_items=[
FrontendMenuItem(
label_key="nav.marketplace",
label="Marketplace",
path="/marketplace",
icon="Store",
order=85,
permission="marketplace:read",
),
],
page_routes=[
FrontendPageRoute(
path="/marketplace",
component="@/pages/Marketplace",
protected=True,
permission="marketplace:read",
),
],
settings_pages=[],
detail_tabs=[],
author="LeoCRM",
@@ -12,7 +12,6 @@ import os
import uuid
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
@@ -95,30 +94,16 @@ async def _handler_call_crm_api(db: AsyncSession, arguments: dict[str, Any], con
path = "/" + path
try:
base_url = _get_base_url()
tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "")
headers = {
"Content-Type": "application/json",
"X-Internal-Call": "true",
"X-Tenant-Id": str(tenant_id),
"X-User-Id": str(user_id),
}
# F09 (Astra P1): authenticated request via short-lived delegation
# token - the MCP session user's real permissions apply.
from app.plugins.builtins.ai_assistant.crm_api_tool import _make_internal_api_request
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
elif method == "POST":
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PATCH":
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PUT":
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "DELETE":
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
else:
return {"error": f"Unsupported method: {method}"}
resp = await _make_internal_api_request(
method, path, tenant_id=str(tenant_id), user_id=str(user_id), body=body
)
try:
resp_data = resp.json()
+6 -1
View File
@@ -30,7 +30,12 @@ class PermissionsPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql"],
permissions=[],
# Audit P1 (permission catalog): routes and settings pages use
# permissions:admin / permissions:read — they must be grantable.
permissions=[
"permissions:read",
"permissions:admin",
],
is_core=True,
settings_pages=[
FrontendSettingsPage(path='roles', label_key='settings.roles', label='Roles', component='@/pages/SettingsRoles', icon='Shield', order=10, permission='permissions:read'),
@@ -43,6 +43,31 @@ class ReportGeneratorContract:
PRESET_META = PRESET_META
PRESET_TEMPLATES = PRESET_TEMPLATES
# ─── Workspace Scopes contribution (Phase N4) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des reports-Moduls: Vorlagen-Teilmengen (N4)."""
return [
{
"module_key": "reports",
"dimensions": [
{
"key": "template_ids",
"label": "Vorlagen",
"control": "multiselect",
"options": [],
"value_source": {
"endpoint": "/api/v1/reports/print-templates",
"items_path": "items",
"value_key": "id",
"label_key": "name",
},
},
],
}
]
# ─── self-registration ───
@@ -0,0 +1,213 @@
"""Document block registry — builtin block types, validation, contributions.
Phase L1: the central registry every drag/drop editor and the renderer use.
Builtin blocks cover text, image, simple graphics (shapes), tables, spacers
and placeholders. Modules can contribute additional palette blocks via the
contract hook ``document_blocks()`` (same philosophy as
``importexport_entities()``, #359). Contributed blocks declare ``fields``
(data keys) and render as a key-value table; report_generator owns the
generic rendering so modules never inject executable code.
A block is a plain dict: ``{"id": str, "type": str, "config": dict}``.
The ``id`` is editor-local (stable within one template/letterhead).
"""
from __future__ import annotations
from typing import Any
# ─── Builtin block metadata (palette + validation) ───────────────────────────
BUILTIN_BLOCKS: dict[str, dict[str, Any]] = {
"text": {
"label": "Text",
"category": "basis",
"description": "Absatz mit Jinja2-Platzhaltern ({{firstname}})",
"fields": {
"content": "str (Pflicht)",
"style": "dict? (fontSize, align, bold, italic, color)",
},
},
"image": {
"label": "Bild",
"category": "basis",
"description": "Bild aus Briefpapier-Assets (wird als data:-URI ins PDF eingebettet)",
"fields": {
"asset_id": "uuid?",
"url": "str? (data:-URI)",
"width": "int? (px)",
"height": "int? (px)",
"alt": "str?",
"align": "left|center|right?",
},
},
"shape": {
"label": "Grafik / Form",
"category": "grafik",
"description": "Einfache Grafik: Linie, Rechteck, Kreis",
"fields": {
"shape": "line|rect|circle (Pflicht)",
"width": "str? (CSS, z.B. 100% oder 120px)",
"height": "int? (px)",
"color": "str? (CSS-Farbe)",
"background": "str? (CSS-Farbe, rect/circle)",
"radius": "int? (%)",
},
},
"table": {
"label": "Tabelle",
"category": "basis",
"description": "Statische oder datengetriebene Tabelle",
"fields": {
"columns": "list[str]?",
"rows": "list[list]?",
"striped": "bool?",
"width": "str? (CSS)",
},
},
"spacer": {
"label": "Abstand",
"category": "layout",
"description": "Vertikaler Abstand",
"fields": {"height": "int? (px, Standard 24)"},
},
"divider": {
"label": "Trennlinie",
"category": "layout",
"description": "Horizontale Trennlinie",
"fields": {"color": "str?", "thickness": "int? (px)"},
},
"placeholder": {
"label": "Platzhalter",
"category": "daten",
"description": "Einzelner Daten-Platzhalter mit Label",
"fields": {"key": "str (Pflicht)", "label": "str?"},
},
"pagebreak": {
"label": "Seitenwechsel",
"category": "layout",
"description": "Erzwingt einen Seitenumbruch im PDF",
"fields": {},
},
}
VALID_SHAPES = {"line", "rect", "circle"}
class BlockValidationError(ValueError):
"""Raised when a block composition is invalid (→ HTTP 422)."""
def _module_contributions() -> list[tuple[str, dict[str, Any]]]:
"""Collect ``document_blocks()`` contributions from plugin contracts."""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
contributions: list[tuple[str, dict[str, Any]]] = []
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
blocks_fn = getattr(contract, "document_blocks", None)
if blocks_fn is None:
continue
try:
blocks = blocks_fn() or []
except Exception: # noqa: BLE001 — a broken contribution must not break the registry
continue
for block in blocks:
btype = block.get("type") if isinstance(block, dict) else None
if btype and btype not in BUILTIN_BLOCKS:
meta = dict(block)
meta.setdefault("category", "modul")
meta["contributed_by"] = plugin_name
contributions.append((btype, meta))
return contributions
def get_document_blocks() -> list[dict[str, Any]]:
"""Return all block types (builtin + module contributions) for the palette."""
result = [
{"type": btype, "label": meta["label"], "category": meta.get("category", "basis"), "description": meta.get("description", ""), "fields": meta.get("fields", {}), "builtin": True}
for btype, meta in BUILTIN_BLOCKS.items()
]
for btype, meta in _module_contributions():
result.append({
"type": btype,
"label": meta.get("label", btype),
"category": meta.get("category", "modul"),
"description": meta.get("description", ""),
"fields": meta.get("fields", {}),
"builtin": False,
"contributed_by": meta.get("contributed_by"),
})
return result
def _known_types() -> set[str]:
types = set(BUILTIN_BLOCKS.keys())
for btype, _meta in _module_contributions():
types.add(btype)
return types
def _contribution_meta(btype: str) -> dict[str, Any] | None:
for ctype, meta in _module_contributions():
if ctype == btype:
return meta
return None
def validate_block(block: Any, *, index: int = 0, known_types: set[str] | None = None) -> None:
"""Validate a single block dict. Raises BlockValidationError."""
if not isinstance(block, dict):
raise BlockValidationError(f"Block {index} ist kein Objekt")
btype = block.get("type")
if not btype or not isinstance(btype, str):
raise BlockValidationError(f"Block {index}: 'type' fehlt")
if known_types is None:
known_types = _known_types()
if btype not in known_types:
raise BlockValidationError(
f"Unbekannter Block-Typ '{btype}' (Block {index})"
)
config = block.get("config") or {}
if not isinstance(config, dict):
raise BlockValidationError(f"Block {index} ({btype}): 'config' muss ein Objekt sein")
if btype == "text":
content = config.get("content")
if not isinstance(content, str) or not content.strip():
raise BlockValidationError("text-Block benötigt ein nicht-leeres 'content'")
elif btype == "shape":
shape = config.get("shape")
if shape not in VALID_SHAPES:
raise BlockValidationError(
f"shape-Block: 'shape' muss eine von {sorted(VALID_SHAPES)} sein"
)
elif btype == "table":
columns = config.get("columns")
rows = config.get("rows")
if columns is not None and not isinstance(columns, list):
raise BlockValidationError("table-Block: 'columns' muss eine Liste sein")
if rows is not None and not isinstance(rows, list):
raise BlockValidationError("table-Block: 'rows' muss eine Liste sein")
elif btype == "placeholder":
key = config.get("key")
if not isinstance(key, str) or not key.strip():
raise BlockValidationError("placeholder-Block benötigt ein 'key'")
def validate_blocks(blocks: Any) -> None:
"""Validate a full block list. Raises BlockValidationError (→ 422)."""
if not isinstance(blocks, list):
raise BlockValidationError("'blocks' muss eine Liste sein")
known = _known_types()
for i, block in enumerate(blocks):
validate_block(block, index=i, known_types=known)
def contribution_fields(btype: str) -> list[str] | None:
"""Data keys a contributed block renders (generic key-value table)."""
meta = _contribution_meta(btype)
if meta is None:
return None
return list(meta.get("fields") or [])
@@ -0,0 +1,386 @@
"""Document renderer — block composition → HTML → PDF (Phase L1-L3).
Owns the generic rendering for all block types. Modules contribute data and
metadata (placeholders, block descriptors) but never markup the renderer
turns every block into HTML itself, which keeps the PDF surface sandboxed
(WeasyPrint URL fetcher allows data: URIs only).
Pipeline:
blocks + letterhead config + data
``collect_placeholder_defaults`` fills missing data keys with the
module's example values (editor preview without entity)
``render_blocks_html`` renders each block (Jinja2 for text content,
escaped; shapes/dividers as styled divs; images as data:-URI img)
``render_document_html`` wraps content in the letterhead page frame
(@page geometry + running header/footer elements)
``generate_pdf`` (pdf_generator) bytes
"""
from __future__ import annotations
import base64
import html as _html
import re
import uuid
from typing import Any
from jinja2.sandbox import SandboxedEnvironment
# Page sizes in mm (CSS @page)
PAGE_SIZES = {
"A4": "210mm 297mm",
"A5": "148mm 210mm",
"letter": "8.5in 11in",
}
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}")
def _jinja_env() -> SandboxedEnvironment:
env = SandboxedEnvironment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
return env
# ─── Placeholder defaults ───────────────────────────────────────────────────
def collect_placeholder_defaults(entity_type: str | None) -> dict[str, Any]:
"""Collect placeholder example values for an entity type.
Aggregates ``document_placeholders(entity_type)`` contributions from
all plugin contracts. Returns ``{key: example}`` for the editor preview
(rendering without live entity data must not raise).
"""
if not entity_type:
return {}
defaults: dict[str, Any] = {}
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_placeholders", None)
if fn is None:
continue
try:
placeholders = fn(entity_type) or []
except Exception: # noqa: BLE001
continue
for p in placeholders:
if isinstance(p, dict) and p.get("key"):
defaults[p["key"]] = p.get("example", "")
return defaults
def merge_placeholder_defaults(data: dict | None, entity_type: str | None) -> dict[str, Any]:
"""Overlay missing keys with placeholder examples (preview-safe data)."""
merged: dict[str, Any] = dict(data or {})
for key, example in collect_placeholder_defaults(entity_type).items():
if key not in merged or merged[key] in (None, ""):
merged[key] = example
return merged
# ─── Block → HTML ───────────────────────────────────────────────────────────
def _style_attr(style: dict | None) -> str:
"""Convert a small style dict into an inline style attribute."""
if not isinstance(style, dict):
return ""
allowed = {
"fontSize": "font-size",
"font-size": "font-size",
"color": "color",
"textAlign": "text-align",
"text-align": "text-align",
}
parts = []
if style.get("bold"):
parts.append("font-weight: bold")
if style.get("italic"):
parts.append("font-style: italic")
for k, v in style.items():
css = allowed.get(k)
if css and isinstance(v, (str, int, float)):
parts.append(f"{css}: {_html.escape(str(v))}")
return f' style="{"; ".join(parts)}"' if parts else ""
def _render_text(content: str, data: dict[str, Any]) -> str:
"""Render Jinja2 placeholders inside a text block (autoescaped)."""
try:
template = _jinja_env().from_string(content)
return template.render(**data)
except Exception: # noqa: BLE001 — a broken expression renders literally
return _html.escape(content)
def _img_url(config: dict, assets_map: dict[str, str]) -> str | None:
"""Resolve an image block to a data:-URI (sandbox policy for WeasyPrint)."""
url = config.get("url")
if isinstance(url, str) and url.startswith("data:"):
return url
asset_id = config.get("asset_id")
if asset_id:
data_url = assets_map.get(str(asset_id))
if data_url:
return data_url
return None
def render_block_html(block: dict, data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
"""Render one block dict to HTML. Unknown types render nothing."""
assets_map = assets_map or {}
btype = block.get("type")
config = block.get("config") or {}
if btype == "text":
rendered = _render_text(str(config.get("content", "")), data)
return f'<p class="doc-block doc-text"{_style_attr(config.get("style"))}>{rendered}</p>'
if btype == "image":
url = _img_url(config, assets_map)
if not url:
return '<div class="doc-block doc-image-missing" data-missing="true"></div>'
dims = ""
if isinstance(config.get("width"), (int, float)):
dims += f' width="{int(config["width"])}"'
if isinstance(config.get("height"), (int, float)):
dims += f' height="{int(config["height"])}"'
alt = _html.escape(str(config.get("alt", "")))
align = config.get("align", "left")
return f'<div class="doc-block doc-image" style="text-align: {_html.escape(str(align))}"><img src="{url}" alt="{alt}"{dims} /></div>'
if btype == "shape":
shape = config.get("shape")
color = _html.escape(str(config.get("color", "#111827")))
background = _html.escape(str(config.get("background", "#e5e7eb")))
width = config.get("width", "100%")
height = int(config.get("height") or 2)
radius = int(config.get("radius") or 50)
if shape == "line":
return (f'<hr class="doc-block doc-shape" style="border: none; '
f'border-top: {height}px solid {color}; width: {_html.escape(str(width))}; margin: 8px 0;" />')
if shape == "rect":
return (f'<div class="doc-block doc-shape" style="width: {_html.escape(str(width))}; '
f'height: {height}px; background: {background}; border: 1px solid {color}; margin: 8px 0;"></div>')
if shape == "circle":
size = height if height > 4 else 40
return (f'<div class="doc-block doc-shape" style="width: {size}px; height: {size}px; '
f'background: {background}; border: 1px solid {color}; border-radius: {radius}%; margin: 8px 0;"></div>')
return ""
if btype == "divider":
color = _html.escape(str(config.get("color", "#d1d5db")))
thickness = int(config.get("thickness") or 1)
return f'<hr class="doc-block doc-divider" style="border: none; border-top: {thickness}px solid {color}; margin: 12px 0;" />'
if btype == "spacer":
height = int(config.get("height") or 24)
return f'<div class="doc-block doc-spacer" style="height: {height}px;"></div>'
if btype == "table":
columns = config.get("columns") or []
rows = config.get("rows") or []
striped = " doc-table-striped" if config.get("striped") else ""
head = ""
if columns:
head = "<thead><tr>" + "".join(f"<th>{_html.escape(str(c))}</th>" for c in columns) + "</tr></thead>"
body_rows = []
for row in rows:
if not isinstance(row, (list, tuple)):
row = [row]
cells = "".join(f"<td>{_render_text(str(c), data) if isinstance(c, str) else _html.escape(str(c))}</td>" for c in row)
body_rows.append(f"<tr>{cells}</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>" if body_rows else ""
width_style = f' style="width: {_html.escape(str(config["width"]))}"' if config.get("width") else ""
return f'<table class="doc-block doc-table{striped}"{width_style}>{head}{body}</table>'
if btype == "placeholder":
key = str(config.get("key", ""))
label = config.get("label") or key
value = data.get(key, "")
return (f'<div class="doc-block doc-placeholder"><span class="doc-placeholder-label">'
f'{_html.escape(str(label))}:</span> <span class="doc-placeholder-value">'
f'{_html.escape(str(value if value is not None else ""))}</span></div>')
if btype == "pagebreak":
return '<div class="doc-block doc-pagebreak" style="break-after: page;"></div>'
# Module-contributed block: generic key-value table over declared fields
from app.plugins.builtins.report_generator.document_blocks import contribution_fields
fields = contribution_fields(btype) if btype else None
if fields:
rows = "".join(
f"<tr><th>{_html.escape(str(f))}</th><td>{_html.escape(str(data.get(f, '')))}</td></tr>"
for f in fields
)
return f'<table class="doc-block doc-contribution"><tbody>{rows}</tbody></table>'
return ""
def render_blocks_html(blocks: list[dict], data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
"""Render a block list to a HTML fragment."""
return "\n".join(render_block_html(b, data, assets_map) for b in blocks if isinstance(b, dict))
# ─── Letterhead frame ───────────────────────────────────────────────────────
def _esc(value: Any) -> str:
return _html.escape(str(value))
def render_document_html(
blocks: list[dict],
data: dict[str, Any],
letterhead_config: dict | None = None,
assets_map: dict[str, str] | None = None,
) -> str:
"""Wrap rendered blocks in the letterhead page frame (full HTML doc)."""
config = letterhead_config or {}
page = config.get("page") or {}
size = page.get("size", "A4")
orientation = page.get("orientation", "portrait")
margins = page.get("margins") or {}
m_top = margins.get("top", 25)
m_right = margins.get("right", 20)
m_bottom = margins.get("bottom", 25)
m_left = margins.get("left", 20)
page_css = PAGE_SIZES.get(size, PAGE_SIZES["A4"])
if orientation == "landscape":
# swap width/height for landscape
w, h = page_css.split()
page_css = f"{h} {w}"
header = config.get("header") or {}
footer = config.get("footer") or {}
header_html = ""
footer_html = ""
extra_top = 0
extra_bottom = 0
if header.get("enabled"):
header_html = render_blocks_html(header.get("blocks") or [], data, assets_map)
extra_top = 20 # reserve space for the running header
if footer.get("enabled"):
footer_html = render_blocks_html(footer.get("blocks") or [], data, assets_map)
extra_bottom = 18
watermark = config.get("watermark") or {}
watermark_html = ""
if watermark.get("enabled"):
text = _esc(watermark.get("text", ""))
watermark_html = (
f'<div class="doc-watermark">{text}</div>'
)
content = render_blocks_html(blocks, data, assets_map)
header_css = ""
if header_html:
header_css = (
"#doc-header { position: running(header); }\n"
"@page { @top-center { content: element(header); } }\n"
)
footer_css = ""
if footer_html:
footer_css = (
"#doc-footer { position: running(footer); }\n"
"@page { @bottom-center { content: element(footer); } }\n"
)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page {{
size: {page_css};
margin: {int(m_top) + extra_top}mm {int(m_right)}mm {int(m_bottom) + extra_bottom}mm {int(m_left)}mm;
}}
body {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 11pt; color: #111827; line-height: 1.5; }}
.doc-text {{ margin: 0 0 10px 0; white-space: pre-wrap; }}
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
.doc-table th, .doc-table td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
.doc-table-striped tbody tr:nth-child(even) {{ background: #f9fafb; }}
.doc-placeholder-label {{ font-weight: 600; color: #374151; }}
.doc-placeholder {{ margin: 4px 0; }}
.doc-watermark {{ position: fixed; top: 45%; left: 0; right: 0; text-align: center; font-size: 48pt; color: rgba(107, 114, 128, 0.25); transform: rotate(-30deg); }}
{header_css}{footer_css}
</style>
</head>
<body>
{f'<div id="doc-header">{header_html}</div>' if header_html else ''}
{f'<div id="doc-footer">{footer_html}</div>' if footer_html else ''}
{watermark_html}
<div id="doc-content">{content}</div>
</body>
</html>"""
# ─── Assets ────────────────────────────────────────────────────────────────
async def load_assets_data_urls(
db,
tenant_id: uuid.UUID,
asset_ids: list[str] | None = None,
letterhead_id: str | None = None,
) -> dict[str, str]:
"""Load DocumentAssets and return ``{asset_id: data_url}``.
Images are embedded as data:-URIs because the WeasyPrint URL fetcher
blocks external resources (SSRF policy). Missing assets are skipped.
"""
from sqlalchemy import select
from app.plugins.builtins.report_generator.models import DocumentAsset
if not asset_ids and not letterhead_id:
return {}
q = select(DocumentAsset).where(
DocumentAsset.tenant_id == tenant_id,
DocumentAsset.deleted_at.is_(None),
)
if asset_ids:
try:
ids = [uuid.UUID(a) for a in asset_ids if a]
except (ValueError, TypeError):
ids = []
if not ids:
return {}
q = q.where(DocumentAsset.id.in_(ids))
elif letterhead_id:
try:
lh = uuid.UUID(letterhead_id)
except (ValueError, TypeError):
return {}
q = q.where(DocumentAsset.letterhead_id == lh)
from app.core.storage import get_storage_backend
assets = (await db.execute(q)).scalars().all()
storage = get_storage_backend()
result: dict[str, str] = {}
for asset in assets:
try:
content = await storage.read(asset.storage_path)
except Exception: # noqa: BLE001 — missing blob renders as empty
continue
b64 = base64.b64encode(content).decode("ascii")
result[str(asset.id)] = f"data:{asset.mime_type};base64,{b64}"
return result
def collect_block_asset_ids(blocks: list[dict]) -> list[str]:
"""Extract asset_id references from image blocks."""
ids: list[str] = []
for b in blocks or []:
if not isinstance(b, dict) or b.get("type") != "image":
continue
asset_id = (b.get("config") or {}).get("asset_id")
if isinstance(asset_id, str) and asset_id:
ids.append(asset_id)
return ids
@@ -0,0 +1,966 @@
"""Documents Generator routes (Phase L1-L3) — letterheads, print templates,
block registry, preview, render, assets.
Mounted under /api/v1/reports via the report_generator manifest (documents
endpoints live in their own module; the manifest registers it as a second
PluginRouteDef).
"""
from __future__ import annotations
import io
import uuid as uuid_mod
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete
from app.core.audit import log_audit
from app.core.db import get_db, set_tenant_context
from app.core.storage import get_storage_backend
from app.deps import require_permission, require_workspace_scope
from app.plugins.builtins.report_generator.document_blocks import (
BlockValidationError,
get_document_blocks,
validate_blocks,
)
from app.plugins.builtins.report_generator.document_renderer import (
collect_block_asset_ids,
collect_placeholder_defaults,
load_assets_data_urls,
merge_placeholder_defaults,
render_document_html,
)
from app.plugins.builtins.report_generator.einvoice import (
EInvoiceValidationError,
render_einvoice_xml,
resolve_einvoice_data,
validate_einvoice_data,
)
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
PrintTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import generate_pdf
from app.plugins.builtins.report_generator.schemas import (
DocumentAssetResponse,
DocumentPreviewRequest,
DocumentPreviewResponse,
DocumentRenderRequest,
DocumentSuggestRequest,
DocumentSuggestResponse,
EInvoiceRenderForRequest,
EInvoiceRenderRequest,
EInvoiceValidationResponse,
LetterheadCreate,
LetterheadResponse,
LetterheadUpdate,
PrintTemplateCreate,
PrintTemplateResponse,
PrintTemplateUpdate,
)
router = APIRouter(prefix="/api/v1/reports", tags=["documents"])
def _parse_uuid(val: str, field: str) -> uuid_mod.UUID:
try:
return uuid_mod.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
def _letterhead_to_response(lh: Letterhead) -> LetterheadResponse:
return LetterheadResponse(
id=str(lh.id),
name=lh.name,
description=lh.description,
config=lh.config or {},
is_default=lh.is_default,
created_by=str(lh.created_by),
created_at=lh.created_at,
updated_at=lh.updated_at,
)
def _template_to_response(t: PrintTemplate) -> PrintTemplateResponse:
return PrintTemplateResponse(
id=str(t.id),
name=t.name,
description=t.description,
letterhead_id=str(t.letterhead_id) if t.letterhead_id else None,
entity_type=t.entity_type,
blocks=t.blocks or [],
output_format=t.output_format,
created_by=str(t.created_by),
created_at=t.created_at,
updated_at=t.updated_at,
)
async def _load_entity_data(db, tenant_id, entity_type: str, entity_id):
"""Fetch document data for an entity via plugin contracts (L1).
Iterates contracts exposing ``document_data(db, tenant_id, entity_id,
entity_type)``; the first non-empty dict wins. Unknown entities None
( 404); a known entity with no data {} (renders empty placeholders).
"""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_data", None)
if fn is None:
continue
try:
data = await fn(db, tenant_id, entity_id, entity_type)
except Exception: # noqa: BLE001 — broken contribution must not 500
continue
if data:
return data
# No contribution produced data: unknown entity type or entity not
# found — both are 404 for the caller (never render an empty document
# silently).
return None
async def _get_letterhead(db, tenant_id, lh_id) -> Letterhead | None:
return (
await db.execute(
select(Letterhead).where(
Letterhead.id == lh_id,
Letterhead.tenant_id == tenant_id,
Letterhead.deleted_at.is_(None),
)
)
).scalar_one_or_none()
async def _get_template(db, tenant_id, tid) -> PrintTemplate | None:
return (
await db.execute(
select(PrintTemplate).where(
PrintTemplate.id == tid,
PrintTemplate.tenant_id == tenant_id,
PrintTemplate.deleted_at.is_(None),
)
)
).scalar_one_or_none()
# ─── Letterheads ─────────────────────────────────────────────────────────────
@router.get("/letterheads")
async def list_letterheads(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List letterheads for the current tenant."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
q = (
select(Letterhead)
.where(
Letterhead.tenant_id == tenant_id,
Letterhead.deleted_at.is_(None),
)
.order_by(Letterhead.name)
)
items = (await db.execute(q)).scalars().all()
return {
"items": [_letterhead_to_response(item).model_dump() for item in items],
"total": len(items),
}
@router.post("/letterheads", status_code=status.HTTP_201_CREATED)
async def create_letterhead(
body: LetterheadCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Create a letterhead."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id)
config = body.config or {}
for section in ("header", "footer"):
section_cfg = config.get(section) or {}
blocks = section_cfg.get("blocks")
if blocks is not None:
try:
validate_blocks(blocks)
except BlockValidationError as exc:
raise HTTPException(
422,
detail={"detail": str(exc), "code": "invalid_block"},
) from exc
lh = Letterhead(
tenant_id=tenant_id,
name=body.name,
description=body.description,
config=config,
is_default=body.is_default,
created_by=user_id,
owner_id=user_id,
)
db.add(lh)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "letterhead", lh.id,
changes={"name": lh.name},
)
return _letterhead_to_response(lh).model_dump()
@router.get("/letterheads/{letterhead_id}")
async def get_letterhead(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
return _letterhead_to_response(lh).model_dump()
@router.put("/letterheads/{letterhead_id}")
async def update_letterhead(
letterhead_id: str,
body: LetterheadUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
if body.config is not None:
for section in ("header", "footer"):
section_cfg = (body.config or {}).get(section) or {}
blocks = section_cfg.get("blocks")
if blocks is not None:
try:
validate_blocks(blocks)
except BlockValidationError as exc:
raise HTTPException(
422,
detail={"detail": str(exc), "code": "invalid_block"},
) from exc
lh.config = body.config
if body.name is not None:
lh.name = body.name
if body.description is not None:
lh.description = body.description
if body.is_default is not None:
lh.is_default = body.is_default
await db.flush()
await db.refresh(lh) # onupdate columns expire — refresh async-safe
await log_audit(
db, tenant_id, user_id, "update", "letterhead", lh.id,
changes={"name": lh.name},
)
return _letterhead_to_response(lh).model_dump()
@router.delete("/letterheads/{letterhead_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_letterhead(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
from datetime import UTC, datetime
lh.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "letterhead", lh.id,
changes={"name": lh.name},
)
return None
# ─── Letterhead Assets (logo/image upload) ──────────────────────────────────
ALLOWED_IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"}
MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post(
"/letterheads/{letterhead_id}/assets",
status_code=status.HTTP_201_CREATED,
)
async def upload_letterhead_asset(
letterhead_id: str,
file: UploadFile,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Upload an image asset for a letterhead (logo, header graphic)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
mime = (file.content_type or "").lower()
if mime not in ALLOWED_IMAGE_MIMES:
raise HTTPException(
422,
detail={
"detail": f"Nur Bild-Dateien sind erlaubt (erhalten: {mime})",
"code": "invalid_mime_type",
},
)
content = await file.read()
if len(content) > MAX_ASSET_SIZE:
raise HTTPException(
413,
detail={"detail": "Bild ist größer als 5 MB", "code": "asset_too_large"},
)
asset_id = uuid_mod.uuid4()
storage = get_storage_backend()
storage_path = f"documents/{tenant_id}/{asset_id}"
await storage.save(storage_path, content)
asset = DocumentAsset(
id=asset_id,
tenant_id=tenant_id,
letterhead_id=lh_id,
filename=file.filename or "asset",
mime_type=mime,
size_bytes=len(content),
storage_path=storage_path,
created_by=user_id,
owner_id=user_id,
)
db.add(asset)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "document_asset", asset.id,
changes={"filename": asset.filename, "letterhead_id": str(lh_id)},
)
import base64 as _b64
data_url = f"data:{mime};base64,{_b64.b64encode(content).decode('ascii')}"
return DocumentAssetResponse(
id=str(asset.id),
letterhead_id=str(asset.letterhead_id),
filename=asset.filename,
mime_type=asset.mime_type,
size_bytes=asset.size_bytes,
data_url=data_url,
created_at=asset.created_at,
).model_dump()
@router.get("/letterheads/{letterhead_id}/assets")
async def list_letterhead_assets(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List assets for a letterhead (metadata + data_url)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
assets_map = await load_assets_data_urls(db, tenant_id, letterhead_id=letterhead_id)
q = select(DocumentAsset).where(
DocumentAsset.tenant_id == tenant_id,
DocumentAsset.letterhead_id == lh_id,
DocumentAsset.deleted_at.is_(None),
)
assets = (await db.execute(q)).scalars().all()
return [
DocumentAssetResponse(
id=str(a.id),
letterhead_id=str(a.letterhead_id) if a.letterhead_id else None,
filename=a.filename,
mime_type=a.mime_type,
size_bytes=a.size_bytes,
data_url=assets_map.get(str(a.id)),
created_at=a.created_at,
).model_dump()
for a in assets
]
# ─── Print Templates ─────────────────────────────────────────────────────────
@router.get("/print-templates")
async def list_print_templates(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("reports")),
):
"""List print templates for the current tenant.
Phase N4: an active workspace scope restricts the template list to the
configured subset (pure AND never a grant).
"""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
q = (
select(PrintTemplate)
.where(
PrintTemplate.tenant_id == tenant_id,
PrintTemplate.deleted_at.is_(None),
)
.order_by(PrintTemplate.name)
)
items = (await db.execute(q)).scalars().all()
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
template_scope = scope_uuid_set(workspace_scope.get("template_ids"))
if template_scope is not None:
items = [t for t in items if t.id in template_scope]
return {
"items": [_template_to_response(t).model_dump() for t in items],
"total": len(items),
}
@router.post("/print-templates", status_code=status.HTTP_201_CREATED)
async def create_print_template(
body: PrintTemplateCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Create a print template (drag/drop block composition)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id)
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
letterhead_id = None
if body.letterhead_id:
letterhead_id = _parse_uuid(body.letterhead_id, "letterhead_id")
if await _get_letterhead(db, tenant_id, letterhead_id) is None:
raise HTTPException(
404, detail={"detail": "Letterhead not found", "code": "not_found"}
)
template = PrintTemplate(
tenant_id=tenant_id,
name=body.name,
description=body.description,
letterhead_id=letterhead_id,
entity_type=body.entity_type,
blocks=body.blocks,
output_format=body.output_format,
created_by=user_id,
owner_id=user_id,
)
db.add(template)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "print_template", template.id,
changes={"name": template.name},
)
return _template_to_response(template).model_dump()
@router.get("/print-templates/{template_id}")
async def get_print_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
return _template_to_response(template).model_dump()
@router.put("/print-templates/{template_id}")
async def update_print_template(
template_id: str,
body: PrintTemplateUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
if body.blocks is not None:
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
template.blocks = body.blocks
if body.name is not None:
template.name = body.name
if body.description is not None:
template.description = body.description
if body.entity_type is not None:
template.entity_type = body.entity_type
if body.output_format is not None:
template.output_format = body.output_format
if body.letterhead_id is not None:
if body.letterhead_id:
lh_id = _parse_uuid(body.letterhead_id, "letterhead_id")
if await _get_letterhead(db, tenant_id, lh_id) is None:
raise HTTPException(
404, detail={"detail": "Letterhead not found", "code": "not_found"}
)
template.letterhead_id = lh_id
else:
template.letterhead_id = None
await db.flush()
await db.refresh(template) # onupdate columns expire — refresh async-safe
await log_audit(
db, tenant_id, user_id, "update", "print_template", template.id,
changes={"name": template.name},
)
return _template_to_response(template).model_dump()
@router.delete("/print-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_print_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
from datetime import UTC, datetime
template.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "print_template", template.id,
changes={"name": template.name},
)
return None
# ─── Block Registry / Placeholders ───────────────────────────────────────────
@router.get("/document-blocks")
async def list_document_blocks(
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all available block types (builtin + module contributions)."""
return get_document_blocks()
@router.get("/document-placeholders")
async def list_document_placeholders(
entity_type: str | None = None,
current_user: dict = Depends(require_permission("reports:read")),
):
"""List available placeholders per entity type (module contributions)."""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
result: dict[str, list[dict]] = {}
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_placeholders", None)
if fn is None:
continue
try:
# contracts declare which entity types they serve
types_fn = getattr(contract, "document_entity_types", None)
entity_types = types_fn() if types_fn else ["contact"]
for etype in entity_types:
placeholders = fn(etype) or []
if placeholders:
result.setdefault(etype, []).extend(placeholders)
except Exception: # noqa: BLE001
continue
if entity_type:
return {entity_type: result.get(entity_type, [])}
return result
# ─── Preview (HTML) ──────────────────────────────────────────────────────────
@router.post("/documents/preview")
async def preview_document(
body: DocumentPreviewRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""Render blocks to HTML for the live editor preview (no PDF)."""
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
# load assets referenced by image blocks (editor preview shows images)
asset_ids = collect_block_asset_ids(body.blocks)
header_blocks = ((body.letterhead_config or {}).get("header") or {}).get("blocks") or []
footer_blocks = ((body.letterhead_config or {}).get("footer") or {}).get("blocks") or []
asset_ids += collect_block_asset_ids(header_blocks)
asset_ids += collect_block_asset_ids(footer_blocks)
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
data = merge_placeholder_defaults(body.data, body.entity_type)
html = render_document_html(
body.blocks,
data,
letterhead_config=body.letterhead_config,
assets_map=assets_map,
)
return DocumentPreviewResponse(html=html).model_dump()
# ─── Render (PDF) ────────────────────────────────────────────────────────────
@router.post("/print-templates/{template_id}/render")
async def render_print_template(
template_id: str,
body: DocumentRenderRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render a stored print template with entity data to PDF."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
entity_id = _parse_uuid(body.entity_id, "entity_id")
entity_data = await _load_entity_data(db, tenant_id, body.entity_type, entity_id)
if entity_data is None:
raise HTTPException(
404,
detail={
"detail": f"Kein Daten-Beitrag für entity_type '{body.entity_type}' — Modul nicht aktiv oder Entität unbekannt",
"code": "no_data_source",
},
)
# resolve letterhead
letterhead_config = None
letterhead_id = template.letterhead_id
if letterhead_id:
lh = await _get_letterhead(db, tenant_id, letterhead_id)
if lh:
letterhead_config = lh.config or {}
# load image assets from template blocks + letterhead blocks
asset_ids = collect_block_asset_ids(template.blocks or [])
if letterhead_config:
asset_ids += collect_block_asset_ids((letterhead_config.get("header") or {}).get("blocks") or [])
asset_ids += collect_block_asset_ids((letterhead_config.get("footer") or {}).get("blocks") or [])
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
data = merge_placeholder_defaults(entity_data, template.entity_type)
html = render_document_html(
template.blocks or [],
data,
letterhead_config=letterhead_config,
assets_map=assets_map,
)
# sync PDF generation — close DB before CPU-bound work (existing pattern)
await db.close()
try:
pdf_bytes = generate_pdf(html)
except Exception as exc:
raise HTTPException(
500,
detail={"detail": f"PDF-Generierung fehlgeschlagen: {exc}", "code": "generation_failed"},
) from exc
from datetime import UTC, datetime
filename = f"{template.name.replace(' ', '_')}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ────────────────────
@router.post("/einvoice/render")
async def render_einvoice(
body: EInvoiceRenderRequest,
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render inline invoice data to EN16931/XRechnung CII XML.
Pure format endpoint - the sales module later uses render-for with
its einvoice_data() contract hook.
"""
data = body.model_dump(exclude_none=True)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
xml = render_einvoice_xml(data)
filename = f"{data['invoice_number'].replace(' ', '_')}.xml"
return Response(
content=xml,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/einvoice/validate")
async def validate_einvoice(
body: EInvoiceRenderRequest,
current_user: dict = Depends(require_permission("reports:read")),
):
"""Validate invoice data without rendering (missing BT/BG terms -> 422)."""
data = body.model_dump(exclude_none=True)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
return EInvoiceValidationResponse(valid=True).model_dump()
@router.post("/einvoice/render-for")
async def render_einvoice_for_entity(
body: EInvoiceRenderForRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render an e-invoice for an entity via the einvoice_data() contract.
Docking point for the future sales module: it contributes
einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint
handles validation + XML generation. No contribution -> 404.
"""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
entity_id = _parse_uuid(body.entity_id, "entity_id")
data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id)
if data is None:
raise HTTPException(
404,
detail={
"detail": (
f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - "
"Modul nicht aktiv oder Entitaet unbekannt"
),
"code": "no_data_source",
},
)
try:
validate_einvoice_data(data)
except EInvoiceValidationError as exc:
raise HTTPException(
422,
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
) from exc
xml = render_einvoice_xml(data)
invoice_number = str(data.get("invoice_number") or "einvoice")
filename = f"{invoice_number.replace(' ', '_')}.xml"
return Response(
content=xml,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
_SUGGEST_HEADER = (
"Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n"
"Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n"
"Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n"
'{"blocks": [{"id": "b1", "type": "<block-type>", "config": {}}], "notes": "kurze Erklaerung"}\n\n'
"Verfuegbare Block-Typen mit ihren config-Feldern:\n"
)
_SUGGEST_MIDDLE = "\n\nVerfuegbare Platzhalter (in Text-Bloecken in der Form {key} verwendbar):\n"
_SUGGEST_FOOTER = (
"\n\nRegeln:\n"
"- Nutze nur gelistete Block-Typen.\n"
"- Jeder Block braucht eine eindeutige id (b1, b2, ...).\n"
"- Text-Inhalte koennen Jinja2-Platzhalter wie {firstname} enthalten.\n"
'- shape-Bloecke benoetigen "shape": "line", "rect" oder "circle".\n'
"- Antworte nur mit dem JSON-Objekt, kein Markdown, keine Code-Fences."
)
def _strip_code_fences(text: str) -> str:
"""Strip ```json ...``` fences LLMs like to add."""
stripped = text.strip()
if stripped.startswith("```"):
first_newline = stripped.find("\n")
if first_newline != -1:
stripped = stripped[first_newline + 1 :]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[:-3]
return stripped.strip()
def _sanitize_suggested_blocks(blocks: Any) -> list[dict]:
"""Filter AI blocks down to registry-valid entries with server ids."""
if not isinstance(blocks, list):
return []
from app.plugins.builtins.report_generator.document_blocks import (
BlockValidationError,
_known_types,
validate_block,
)
known = _known_types()
result: list[dict] = []
for i, block in enumerate(blocks):
if not isinstance(block, dict):
continue
candidate = {
"id": str(block.get("id") or f"ai_{uuid_mod.uuid4().hex[:12]}"),
"type": block.get("type"),
"config": block.get("config") or {},
}
try:
validate_block(candidate, index=i, known_types=known)
except BlockValidationError:
continue # drop invalid AI output instead of failing the request
result.append(candidate)
return result
@router.post("/documents/suggest")
async def suggest_document_blocks(
body: DocumentSuggestRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""KI-Steuerung (L4): natural language -> block composition suggestion.
Goes through the central llm_complete (cost tracking, tenant budget).
The AI response is sanitized against the block registry - invalid
blocks are dropped, ids are assigned server-side. LLM failures -> 502.
"""
import json as _json
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
block_types = get_document_blocks()
block_types_desc = _json.dumps(
[
{"type": b["type"], "label": b["label"], "fields": b.get("fields", {})}
for b in block_types
],
ensure_ascii=False,
)
placeholders = collect_placeholder_defaults(body.entity_type) if body.entity_type else {}
placeholders_desc = _json.dumps(placeholders, ensure_ascii=False)
system_prompt = (
_SUGGEST_HEADER + block_types_desc + _SUGGEST_MIDDLE + placeholders_desc + _SUGGEST_FOOTER
)
try:
result = await llm_complete(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": body.prompt},
],
temperature=0.3,
max_tokens=2000,
tenant_id=tenant_id,
db=db,
)
except Exception as exc:
raise HTTPException(
502,
detail={"detail": f"KI-Antwort fehlgeschlagen: {exc}", "code": "ai_unavailable"},
) from exc
raw_content = result.get("content") or ""
try:
parsed = _json.loads(_strip_code_fences(raw_content))
except (ValueError, TypeError) as exc:
raise HTTPException(
502,
detail={
"detail": "KI-Antwort war kein valides JSON",
"code": "invalid_ai_response",
},
) from exc
if not isinstance(parsed, dict):
raise HTTPException(
502,
detail={"detail": "KI-Antwort-Struktur ungueltig", "code": "invalid_ai_response"},
)
blocks = _sanitize_suggested_blocks(parsed.get("blocks"))
notes = str(parsed.get("notes") or "")
return DocumentSuggestResponse(blocks=blocks, notes=notes).model_dump()
@@ -0,0 +1,339 @@
"""E-Invoice format layer — EN16931 / XRechnung CII XML generation (Phase L5).
This module is deliberately a pure FORMAT layer: it turns validated invoice
data (plain dicts) into Cross Industry Invoice XML. It knows nothing about
how invoices are stored the future sales module will own the entities
and dock via the ``einvoice_data()`` contract hook
(``resolve_einvoice_data``).
Profiles:
- ``en16931``: GuidelineID ``urn:cen.eu:en16931:2017``
- ``xrechnung``: ``...#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0``
All monetary math uses ``Decimal`` quantized to 2 places (commercial
rounding) so header sums are consistent with line tax calculations.
XML escaping is delegated to ElementTree no manual string concatenation.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from xml.etree import ElementTree as ET
# ─── CII namespaces (XRechnung 3.0 / D16B) ──────────────────────────────────
NS_RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
NS_RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
NS_UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
GUIDELINE_EN16931 = "urn:cen.eu:en16931:2017"
GUIDELINE_XRECHNUNG = (
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
)
_CENT = Decimal("0.01")
def _money(value) -> Decimal:
"""Quantize to 2 decimal places (commercial rounding, BR-CL-16)."""
return Decimal(str(value)).quantize(_CENT, rounding=ROUND_HALF_UP)
def _fmt_date(value: str) -> str:
"""ISO date (YYYY-MM-DD) → CII format 102 (YYYYMMDD). Raises ValueError."""
parsed = date.fromisoformat(str(value).strip())
return parsed.strftime("%Y%m%d")
# ─── Validation ──────────────────────────────────────────────────────────────
@dataclass
class EInvoiceValidationError(ValueError):
"""Raised when mandatory EN16931 fields are missing or invalid.
``missing`` carries human-readable entries including BT/BG field codes
so the API can surface exactly which business terms fail.
"""
missing: list[str] = field(default_factory=list)
def _non_empty(data: dict, key: str) -> str | None:
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def validate_einvoice_data(data: dict) -> None:
"""Validate mandatory EN16931 business terms (subset enforced today).
Raises EInvoiceValidationError with the collected BT/BG entries.
"""
if not isinstance(data, dict):
raise EInvoiceValidationError(missing=["payload: Objekt erwartet"])
missing: list[str] = []
if not _non_empty(data, "invoice_number"):
missing.append("BT-1: Rechnungsnummer fehlt")
if not _non_empty(data, "issue_date"):
missing.append("BT-2: Rechnungsdatum fehlt")
else:
try:
_fmt_date(data["issue_date"])
except ValueError:
missing.append("BT-2: Rechnungsdatum muss YYYY-MM-DD sein")
if not _non_empty(data, "type_code"):
missing.append("BT-3: Rechnungsart fehlt")
if not _non_empty(data, "currency"):
missing.append("BT-5: Währung fehlt")
# Seller: name + at least one tax registration (BT-31 USt-IdNr or
# BT-32 Steuernummer — BR-DE-16 for German invoices).
if not _non_empty(data, "seller_name"):
missing.append("BT-27: Verkäufername fehlt")
seller_vat = _non_empty(data, "seller_vat_id")
seller_tax = _non_empty(data, "seller_tax_id")
if not seller_vat and not seller_tax:
missing.append("BT-31: USt-IdNr. oder BT-32: Steuernummer des Verkäufers fehlt")
if not _non_empty(data, "buyer_name"):
missing.append("BT-10: Empfängername fehlt")
if data.get("due_date"):
try:
_fmt_date(str(data["due_date"]))
except ValueError:
missing.append("BT-9: Fälligkeitsdatum muss YYYY-MM-DD sein")
lines = data.get("line_items")
if not isinstance(lines, list) or len(lines) == 0:
missing.append("BG-25: mindestens eine Rechnungsposition erforderlich")
else:
for i, line in enumerate(lines, start=1):
if not isinstance(line, dict):
missing.append(f"Position {i}: Objekt erwartet")
continue
if not _non_empty(line, "name"):
missing.append(f"BT-126: Positionsname fehlt (Position {i})")
try:
Decimal(str(line.get("unit_net_price", "0")))
except Exception: # noqa: BLE001
missing.append(f"BT-146: Einzelpreis ungültig (Position {i})")
if missing:
raise EInvoiceValidationError(missing=missing)
# ─── XML rendering ───────────────────────────────────────────────────────────
def _sub(parent: ET.Element, tag: str, text: str | None = None, **attrib) -> ET.Element:
el = ET.SubElement(parent, f"{{{NS_RAM}}}{tag}", {k: str(v) for k, v in attrib.items()})
if text is not None:
el.text = str(text)
return el
def _date_el(parent: ET.Element, tag: str, iso_date: str) -> None:
wrapper = _sub(parent, tag)
dt = ET.SubElement(wrapper, f"{{{NS_UDT}}}DateTimeString")
dt.set("format", "102")
dt.text = _fmt_date(iso_date)
def _address(parent: ET.Element, addr: dict | None) -> None:
addr = addr or {}
postal = _sub(parent, "PostalTradeAddress")
if addr.get("street"):
_sub(postal, "LineOne", str(addr["street"]))
if addr.get("postal_code"):
_sub(postal, "PostcodeCode", str(addr["postal_code"]))
if addr.get("city"):
_sub(postal, "CityName", str(addr["city"]))
_sub(postal, "CountryID", str(addr.get("country") or "DE"))
def _line_sums(data: dict) -> tuple[list[dict], Decimal, dict[str, list[Decimal]]]:
"""Compute per-line and total sums.
Returns (line_amounts, line_total, taxes) where line_amounts[i] is the
net amount of line i (supplied ``line_net_amount`` wins over
quantity × unit price discounts already applied), and taxes maps
vat_rate [basis, tax_amount] aggregated for the header breakdown.
"""
line_amounts: list[Decimal] = []
line_total = Decimal("0.00")
taxes: dict[str, list[Decimal]] = {}
for line in data.get("line_items") or []:
qty = Decimal(str(line.get("quantity", 1)))
price = _money(line.get("unit_net_price", 0))
if line.get("line_net_amount") is not None:
net = _money(line["line_net_amount"])
else:
net = _money(qty * price)
line_amounts.append(net)
line_total = _money(line_total + net)
rate = str(line.get("vat_rate", 0))
basis, tax = taxes.get(rate, [Decimal("0.00"), Decimal("0.00")])
tax_amount = _money(net * Decimal(rate) / Decimal(100))
taxes[rate] = [_money(basis + net), _money(tax + tax_amount)]
return line_amounts, _money(line_total), taxes
def render_einvoice_xml(data: dict) -> str:
"""Render validated invoice data to CII XML (UTF-8, declaration header)."""
validate_einvoice_data(data)
profile = str(data.get("profile") or "en16931").lower()
guideline = GUIDELINE_XRECHNUNG if profile == "xrechnung" else GUIDELINE_EN16931
ET.register_namespace("rsm", NS_RSM)
ET.register_namespace("ram", NS_RAM)
ET.register_namespace("udt", NS_UDT)
root = ET.Element(f"{{{NS_RSM}}}CrossIndustryInvoice")
# ── ExchangedDocumentContext (BT-24) ──
ctx = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocumentContext")
guideline_param = _sub(ctx, "GuidelineSpecifiedDocumentContextParameter")
_sub(guideline_param, "ID", guideline)
# ── ExchangedDocument (BT-1..BT-22) ──
doc = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocument")
_sub(doc, "ID", data["invoice_number"])
_sub(doc, "TypeCode", data["type_code"])
_date_el(doc, "IssueDateTime", data["issue_date"])
if data.get("note"):
note = _sub(doc, "IncludedNote")
_sub(note, "Content", str(data["note"]))
# ── SupplyChainTradeTransaction ──
txn = ET.SubElement(root, f"{{{NS_RSM}}}SupplyChainTradeTransaction")
line_amounts, line_total, taxes = _line_sums(data)
for idx, (line, net) in enumerate(zip(data["line_items"], line_amounts, strict=True), start=1):
item = ET.SubElement(txn, f"{{{NS_RSM}}}IncludedSupplyChainTradeLineItem")
line_doc = _sub(item, "AssociatedDocumentLineDocument")
_sub(line_doc, "LineID", str(idx))
product = _sub(item, "SpecifiedTradeProduct")
_sub(product, "Name", line["name"])
agreement = _sub(item, "SpecifiedLineTradeAgreement")
net_price = _sub(agreement, "NetPriceProductTradePrice")
_sub(net_price, "ChargeAmount", _money(line.get("unit_net_price", 0)))
delivery = _sub(item, "SpecifiedLineTradeDelivery")
_sub(delivery, "BilledQuantity", Decimal(str(line.get("quantity", 1))), unitCode=line.get("unit") or "HUR")
settlement = _sub(item, "SpecifiedLineTradeSettlement")
line_tax = _sub(settlement, "ApplicableTradeTax")
_sub(line_tax, "TypeCode", "VAT")
rate = Decimal(str(line.get("vat_rate", 0)))
_sub(line_tax, "RateApplicablePercent", rate)
_sub(line_tax, "BasisAmount", net)
_sub(line_tax, "CalculatedAmount", _money(net * rate / Decimal(100)))
line_sum = _sub(settlement, "SpecifiedTradeSettlementLineMonetarySummation")
_sub(line_sum, "LineTotalAmount", net)
# ── ApplicableHeaderTradeAgreement ──
agreement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeAgreement")
if data.get("buyer_reference"):
_sub(agreement_h, "BuyerReference", str(data["buyer_reference"]))
seller = _sub(agreement_h, "SellerTradeParty")
_sub(seller, "Name", data["seller_name"])
_address(seller, data.get("seller_address"))
tax_reg = _sub(seller, "SpecifiedTaxRegistration")
if data.get("seller_vat_id"):
_sub(tax_reg, "ID", str(data["seller_vat_id"]), schemeID="VA")
elif data.get("seller_tax_id"):
_sub(tax_reg, "ID", str(data["seller_tax_id"]), schemeID="FC")
buyer = _sub(agreement_h, "BuyerTradeParty")
_sub(buyer, "Name", data["buyer_name"])
_address(buyer, data.get("buyer_address"))
# ── ApplicableHeaderTradeDelivery (BT-72) ──
delivery_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeDelivery")
event = _sub(delivery_h, "ActualDeliverySupplyChainEvent")
_date_el(event, "OccurrenceDateTime", data.get("delivery_date") or data["issue_date"])
# ── ApplicableHeaderTradeSettlement ──
settlement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeSettlement")
currency = str(data["currency"])
_sub(settlement_h, "InvoiceCurrencyCode", currency)
if data.get("payment_means_code"):
means = _sub(settlement_h, "SpecifiedTradeSettlementPaymentMeans")
_sub(means, "TypeCode", str(data["payment_means_code"]))
# header tax breakdown per VAT rate (BG-23)
tax_total = Decimal("0.00")
for rate in sorted(taxes, key=Decimal):
basis, amount = taxes[rate]
header_tax = _sub(settlement_h, "ApplicableTradeTax")
_sub(header_tax, "TypeCode", "VAT")
_sub(header_tax, "BasisAmount", basis)
_sub(header_tax, "CalculatedAmount", amount)
_sub(header_tax, "RateApplicablePercent", Decimal(rate))
tax_total = _money(tax_total + amount)
grand_total = _money(line_total + tax_total)
terms = _sub(settlement_h, "SpecifiedTradePaymentTerms")
if data.get("payment_terms_text"):
_sub(terms, "Description", str(data["payment_terms_text"]))
if data.get("due_date"):
_date_el(terms, "DueDateDateTime", str(data["due_date"]))
sums = _sub(settlement_h, "SpecifiedTradeSettlementHeaderMonetarySummation")
_sub(sums, "LineTotalAmount", line_total)
_sub(sums, "TaxTotalAmount", tax_total, currencyID=currency)
_sub(sums, "GrandTotalAmount", grand_total)
_sub(sums, "DuePayableAmount", grand_total)
return ET.tostring(root, encoding="unicode", xml_declaration=False)
# ─── Contract resolution (sales module docking point) ───────────────────────
async def resolve_einvoice_data(
db,
tenant_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> dict | None:
"""Resolve invoice data for an entity via the ``einvoice_data()`` contract.
The future sales module will expose::
async def einvoice_data(db, tenant_id, entity_id, entity_type) -> dict
Returning validated-shaped invoice data (same fields as the inline
render endpoint). No contribution None (caller answers 404).
"""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "einvoice_data", None)
if fn is None:
continue
try:
result = await fn(db, tenant_id, entity_id, entity_type)
except Exception: # noqa: BLE001 — broken contribution must not 500
continue
if result:
return result
return None
@@ -77,7 +77,7 @@ async def generate_report_job(
import hashlib
from app.plugins.builtins.contracts import get_contract_registry
_dms_contract = get_contract_registry().get("dms")
_dms_contract = get_contract_registry().get_contract("dms")
dms_file = _dms_contract.dms_file
async with create_db_session() as db:
@@ -0,0 +1,74 @@
-- Documents Generator (Phase L1-L3): letterheads, print_templates, document_assets
-- Dual-path safe: idempotent (IF NOT EXISTS); Alembic 0143 converges core installs.
CREATE TABLE IF NOT EXISTS letterheads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_default BOOLEAN NOT NULL DEFAULT false,
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_letterheads_tenant ON letterheads(tenant_id);
CREATE INDEX IF NOT EXISTS ix_letterheads_name ON letterheads(name);
CREATE TABLE IF NOT EXISTS print_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
letterhead_id UUID REFERENCES letterheads(id) ON DELETE SET NULL,
entity_type VARCHAR(100) NOT NULL DEFAULT 'contact',
blocks JSONB NOT NULL DEFAULT '[]'::jsonb,
output_format VARCHAR(20) NOT NULL DEFAULT 'pdf',
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_print_templates_tenant ON print_templates(tenant_id);
CREATE INDEX IF NOT EXISTS ix_print_templates_name ON print_templates(name);
CREATE TABLE IF NOT EXISTS document_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letterhead_id UUID REFERENCES letterheads(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
storage_path VARCHAR(1024) NOT NULL,
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_document_assets_tenant ON document_assets(tenant_id);
CREATE INDEX IF NOT EXISTS ix_document_assets_letterhead ON document_assets(letterhead_id);
-- RLS fail-closed (matches migration 0084 pattern: FORCE + crm_api + USING/WITH CHECK)
DO $do$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['letterheads', 'print_templates', 'document_assets'] LOOP
BEGIN
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', t || '_tenant_isolation', t);
EXECUTE format(
'CREATE POLICY %I ON %I AS PERMISSIVE 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)',
t || '_tenant_isolation', t
);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'RLS setup skipped for %', t;
END;
END LOOP;
END
$do$;
@@ -4,7 +4,8 @@ from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, String, Text
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -63,3 +64,87 @@ class ReportInstance(Base, TenantMixin, OwnedMixin):
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class Letterhead(Base, TenantMixin, OwnedMixin):
"""Letterhead (Briefpapier) — page setup + header/footer block composition.
Phase L1: per-tenant letterhead. ``config`` stores the page geometry
(size/orientation/margins) plus header/footer/watermark block lists.
Blocks use the same ``{id, type, config}`` shape as print templates so
the drag/drop editor can edit both with one component set.
"""
__tablename__ = "letterheads"
__table_args__ = (
Index("ix_letterheads_tenant", "tenant_id"),
Index("ix_letterheads_name", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class PrintTemplate(Base, TenantMixin, OwnedMixin):
"""Print template — drag/drop block composition bound to a letterhead.
Phase L1: ``blocks`` is an ordered JSONB array of
``{id, type, config}`` entries validated against the document block
registry. ``entity_type`` selects the module placeholder contribution
(e.g. "company" contacts placeholders).
"""
__tablename__ = "print_templates"
__table_args__ = (
Index("ix_print_templates_tenant", "tenant_id"),
Index("ix_print_templates_name", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("letterheads.id", ondelete="SET NULL"),
nullable=True,
)
entity_type: Mapped[str] = mapped_column(String(100), nullable=False, default="contact")
blocks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="pdf")
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class DocumentAsset(Base, TenantMixin, OwnedMixin):
"""Image asset for letterheads/print templates (logos, pictures).
Stored via the central storage backend; ``data_url`` is rendered into
PDFs inline (WeasyPrint URL fetcher allows data: URIs only).
"""
__tablename__ = "document_assets"
__table_args__ = (
Index("ix_document_assets_tenant", "tenant_id"),
Index("ix_document_assets_letterhead", "letterhead_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("letterheads.id", ondelete="CASCADE"),
nullable=True,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
@@ -3,7 +3,13 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
PluginManifest,
PluginRouteDef,
)
class ReportGeneratorPlugin(BasePlugin):
@@ -17,12 +23,31 @@ class ReportGeneratorPlugin(BasePlugin):
is_core=True,
dependencies=["permissions"],
routes=[
# documents router MUST be registered before routes: its fixed
# single-segment paths (/letterheads, /print-templates, ...) would
# otherwise be shadowed by the /{report_id} catch-all in routes.
PluginRouteDef(
path="/api/v1/reports",
module="app.plugins.builtins.report_generator.documents",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/reports",
module="app.plugins.builtins.report_generator.routes",
router_attr="router",
),
],
settings_pages=[
FrontendSettingsPage(
path="documents",
label_key="settings.documents",
label="Dokumente",
component="@/pages/DocumentSettings",
icon="FileText",
order=75,
permission="reports:read",
),
],
events=["report.requested", "report.generated"],
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
@@ -38,8 +63,20 @@ class ReportGeneratorPlugin(BasePlugin):
contract_version="1.0.0")
def get_entity_models(self) -> dict[str, type]:
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate
return {"report_template": ReportTemplate, "report_instance": ReportInstance}
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
PrintTemplate,
ReportInstance,
ReportTemplate,
)
return {
"report_template": ReportTemplate,
"report_instance": ReportInstance,
"letterhead": Letterhead,
"print_template": PrintTemplate,
"document_asset": DocumentAsset,
}
async def on_activate(
self, db, service_container, event_bus
@@ -72,3 +72,168 @@ class ReportResponse(BaseModel):
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
# ─── Documents Generator (Phase L1-L3) ──────────────────────────────────────
class LetterheadCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
config: dict = Field(default_factory=dict)
is_default: bool = False
class LetterheadUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=2000)
config: dict | None = None
is_default: bool | None = None
class LetterheadResponse(BaseModel):
id: str
name: str
description: str
config: dict
is_default: bool
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
class PrintTemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
letterhead_id: str | None = None
entity_type: str = Field("contact", max_length=100)
blocks: list[dict] = Field(default_factory=list)
output_format: str = Field("pdf", pattern="^(pdf|print)$")
class PrintTemplateUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=2000)
letterhead_id: str | None = None
entity_type: str | None = Field(None, max_length=100)
blocks: list[dict] | None = None
output_format: str | None = Field(None, pattern="^(pdf|print)$")
class PrintTemplateResponse(BaseModel):
id: str
name: str
description: str
letterhead_id: str | None = None
entity_type: str
blocks: list[dict]
output_format: str
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
class DocumentPreviewRequest(BaseModel):
"""Preview: render blocks to HTML (live preview in the editor)."""
blocks: list[dict]
letterhead_config: dict | None = None
entity_type: str | None = None
data: dict | None = None
class DocumentPreviewResponse(BaseModel):
html: str
class DocumentRenderRequest(BaseModel):
"""Render a stored print template with entity data to PDF."""
entity_type: str
entity_id: str
output_format: str = Field("pdf", pattern="^(pdf|print)$")
class DocumentAssetResponse(BaseModel):
id: str
letterhead_id: str | None = None
filename: str
mime_type: str
size_bytes: int
data_url: str | None = None
created_at: datetime | None = None
# ─── E-Invoice (EN16931/XRechnung, Phase L5) ────────────────────────────────
class EInvoiceAddress(BaseModel):
"""Postal address (BT-50..53 seller, BT-65..68 buyer)."""
street: str | None = None
postal_code: str | None = None
city: str | None = None
country: str | None = None
class EInvoiceLineItem(BaseModel):
"""Invoice line (BG-25). ``line_net_amount`` wins over qty*price."""
name: str = ""
quantity: float = 1.0
unit: str = "HUR"
unit_net_price: float = 0.0
vat_rate: float = 0.0
line_net_amount: float | None = None
class EInvoiceRenderRequest(BaseModel):
"""Inline invoice data — field-level semantics validated by
``einvoice.validate_einvoice_data`` (BT/BG business terms, 422)."""
invoice_number: str = ""
issue_date: str = ""
type_code: str = "380"
currency: str = "EUR"
due_date: str | None = None
delivery_date: str | None = None
buyer_name: str = ""
buyer_reference: str | None = None
buyer_address: EInvoiceAddress | None = None
seller_name: str = ""
seller_vat_id: str | None = None
seller_tax_id: str | None = None
seller_address: EInvoiceAddress | None = None
payment_means_code: str | None = None
payment_terms_text: str | None = None
note: str | None = None
line_items: list[EInvoiceLineItem] = Field(default_factory=list)
profile: str = Field("en16931", pattern="^(en16931|xrechnung)$")
class EInvoiceRenderForRequest(BaseModel):
"""Render an e-invoice for an entity via the ``einvoice_data()``
contract hook (future sales module docking point)."""
entity_type: str = Field(..., min_length=1, max_length=100)
entity_id: str = Field(..., min_length=1)
class EInvoiceValidationResponse(BaseModel):
valid: bool
missing: list[str] = Field(default_factory=list)
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
class DocumentSuggestRequest(BaseModel):
"""Natural-language block composition request for the template editor."""
prompt: str = Field(..., min_length=1, max_length=2000)
entity_type: str | None = Field(None, max_length=100)
class DocumentSuggestResponse(BaseModel):
blocks: list[dict]
notes: str = ""
+25
View File
@@ -26,6 +26,31 @@ class TagsContract:
Tag = Tag
TagAssignment = TagAssignment
# ─── Workspace Scopes contribution (Phase N4) ───
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des tags-Moduls: Tag-Teilmengen (N4)."""
return [
{
"module_key": "tags",
"dimensions": [
{
"key": "tag_ids",
"label": "Tags",
"control": "multiselect",
"options": [],
"value_source": {
"endpoint": "/api/v1/tags",
"items_path": "",
"value_key": "id",
"label_key": "name",
},
},
],
}
]
# ─── self-registration ───
+21 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
class TagsPlugin(BasePlugin):
@@ -30,6 +30,26 @@ class TagsPlugin(BasePlugin):
"tags:delete",
"tags:admin",
],
# Q1: the /tags page was a static-only route before - now
# manifest-declared (route + sidebar menu item).
menu_items=[
FrontendMenuItem(
label_key="nav.tags",
label="Tags",
path="/tags",
icon="Tags",
order=80,
permission="tags:read",
),
],
page_routes=[
FrontendPageRoute(
path="/tags",
component="@/pages/Tags",
protected=True,
permission="tags:read",
),
],
is_core=True,
# BUG (ghost component): ContactTagsTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
+10 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission
from app.deps import get_current_user, require_permission, require_workspace_scope
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import (
TagAssignRequest,
@@ -44,6 +44,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
async def list_tags(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("tags")),
):
"""List all tags with entity counts."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -73,6 +74,14 @@ async def list_tags(
result = await db.execute(query)
rows = result.all()
# Phase N4: workspace scope — tag subset (pure AND, never a grant)
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
tag_scope = scope_uuid_set(workspace_scope.get("tag_ids"))
if tag_scope is not None:
rows = [(tag, count) for tag, count in rows if tag.id in tag_scope]
return [
{
"id": str(tag.id),

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